Enrich lesson comments: Markdown formatting, edit, higher limit

Add a formatting toolbar + safe Markdown rendering (react-markdown +
rehype-sanitize), raise the length limit 2000 -> 10000, and let authors
edit their own comments (new editComment action + editedAt column shown
as an 'изменено' badge).
This commit is contained in:
2026-07-31 19:35:58 +05:00
parent 08e212365d
commit 2498d0f8da
7 changed files with 859 additions and 134 deletions
+537 -1
View File
@@ -41,6 +41,8 @@
"playwright-core": "^1.61.1",
"react": "19.2.4",
"react-dom": "19.2.4",
"react-markdown": "^10.1.0",
"rehype-sanitize": "^6.0.0",
"remark-parse": "^11.0.0",
"resend": "^6.10.0",
"sonner": "^2.0.7",
@@ -5092,9 +5094,26 @@
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
"integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==",
"devOptional": true,
"license": "MIT"
},
"node_modules/@types/estree-jsx": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/@types/estree-jsx/-/estree-jsx-1.0.5.tgz",
"integrity": "sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==",
"license": "MIT",
"dependencies": {
"@types/estree": "*"
}
},
"node_modules/@types/hast": {
"version": "3.0.5",
"resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.5.tgz",
"integrity": "sha512-rp/ezSWaD1m44dPKICGhiskI13nVr7qTloFwDa/IYkhhf5nzwP+zIQcIJh3WIFSBOy/H1PzB40jPjMDksN4F+g==",
"license": "MIT",
"dependencies": {
"@types/unist": "*"
}
},
"node_modules/@types/jsdom": {
"version": "28.0.3",
"resolved": "https://registry.npmjs.org/@types/jsdom/-/jsdom-28.0.3.tgz",
@@ -5518,6 +5537,12 @@
"url": "https://opencollective.com/eslint"
}
},
"node_modules/@ungap/structured-clone": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.3.tgz",
"integrity": "sha512-60YRaenCQcVjYEKOcG824+DRGGIQ3VKErcBoAEDJZz5bKIs2ZG+X/H9Nk+Q6EVkwJk5QNApxbrc5QtBSwtrXAg==",
"license": "ISC"
},
"node_modules/@unrs/resolver-binding-android-arm-eabi": {
"version": "1.11.1",
"resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm-eabi/-/resolver-binding-android-arm-eabi-1.11.1.tgz",
@@ -6662,6 +6687,16 @@
"node": ">=12"
}
},
"node_modules/ccount": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz",
"integrity": "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/chai": {
"version": "6.2.2",
"resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz",
@@ -6699,6 +6734,36 @@
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/character-entities-html4": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/character-entities-html4/-/character-entities-html4-2.1.0.tgz",
"integrity": "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/character-entities-legacy": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/character-entities-legacy/-/character-entities-legacy-3.0.0.tgz",
"integrity": "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/character-reference-invalid": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/character-reference-invalid/-/character-reference-invalid-2.0.1.tgz",
"integrity": "sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/chart.js": {
"version": "4.5.1",
"resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.1.tgz",
@@ -6785,6 +6850,16 @@
"dev": true,
"license": "MIT"
},
"node_modules/comma-separated-tokens": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz",
"integrity": "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/commander": {
"version": "12.1.0",
"resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz",
@@ -8167,6 +8242,16 @@
"node": ">=4.0"
}
},
"node_modules/estree-util-is-identifier-name": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/estree-util-is-identifier-name/-/estree-util-is-identifier-name-3.0.0.tgz",
"integrity": "sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg==",
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/estree-walker": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
@@ -8898,6 +8983,61 @@
"node": ">= 0.4"
}
},
"node_modules/hast-util-sanitize": {
"version": "5.0.2",
"resolved": "https://registry.npmjs.org/hast-util-sanitize/-/hast-util-sanitize-5.0.2.tgz",
"integrity": "sha512-3yTWghByc50aGS7JlGhk61SPenfE/p1oaFeNwkOOyrscaOkMGrcW9+Cy/QAIOBpZxP1yqDIzFMR0+Np0i0+usg==",
"license": "MIT",
"dependencies": {
"@types/hast": "^3.0.0",
"@ungap/structured-clone": "^1.0.0",
"unist-util-position": "^5.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/hast-util-to-jsx-runtime": {
"version": "2.3.6",
"resolved": "https://registry.npmjs.org/hast-util-to-jsx-runtime/-/hast-util-to-jsx-runtime-2.3.6.tgz",
"integrity": "sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg==",
"license": "MIT",
"dependencies": {
"@types/estree": "^1.0.0",
"@types/hast": "^3.0.0",
"@types/unist": "^3.0.0",
"comma-separated-tokens": "^2.0.0",
"devlop": "^1.0.0",
"estree-util-is-identifier-name": "^3.0.0",
"hast-util-whitespace": "^3.0.0",
"mdast-util-mdx-expression": "^2.0.0",
"mdast-util-mdx-jsx": "^3.0.0",
"mdast-util-mdxjs-esm": "^2.0.0",
"property-information": "^7.0.0",
"space-separated-tokens": "^2.0.0",
"style-to-js": "^1.0.0",
"unist-util-position": "^5.0.0",
"vfile-message": "^4.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/hast-util-whitespace": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz",
"integrity": "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==",
"license": "MIT",
"dependencies": {
"@types/hast": "^3.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/hermes-estree": {
"version": "0.25.1",
"resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz",
@@ -8944,6 +9084,16 @@
"license": "MIT",
"optional": true
},
"node_modules/html-url-attributes": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.1.tgz",
"integrity": "sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==",
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/htmlparser2": {
"version": "10.1.0",
"resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-10.1.0.tgz",
@@ -9046,6 +9196,12 @@
"node": ">=12"
}
},
"node_modules/inline-style-parser": {
"version": "0.2.7",
"resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.7.tgz",
"integrity": "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==",
"license": "MIT"
},
"node_modules/internal-slot": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz",
@@ -9070,6 +9226,30 @@
"node": ">=12"
}
},
"node_modules/is-alphabetical": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-2.0.1.tgz",
"integrity": "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/is-alphanumerical": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/is-alphanumerical/-/is-alphanumerical-2.0.1.tgz",
"integrity": "sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw==",
"license": "MIT",
"dependencies": {
"is-alphabetical": "^2.0.0",
"is-decimal": "^2.0.0"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/is-array-buffer": {
"version": "3.0.5",
"resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz",
@@ -9228,6 +9408,16 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/is-decimal": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/is-decimal/-/is-decimal-2.0.1.tgz",
"integrity": "sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/is-extendable": {
"version": "0.1.1",
"resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-0.1.1.tgz",
@@ -9296,6 +9486,16 @@
"node": ">=0.10.0"
}
},
"node_modules/is-hexadecimal": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/is-hexadecimal/-/is-hexadecimal-2.0.1.tgz",
"integrity": "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/is-map": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.3.tgz",
@@ -10114,6 +10314,16 @@
"devOptional": true,
"license": "Apache-2.0"
},
"node_modules/longest-streak": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/longest-streak/-/longest-streak-3.1.0.tgz",
"integrity": "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/loose-envify": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
@@ -10240,6 +10450,122 @@
"url": "https://opencollective.com/unified"
}
},
"node_modules/mdast-util-mdx-expression": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/mdast-util-mdx-expression/-/mdast-util-mdx-expression-2.0.1.tgz",
"integrity": "sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ==",
"license": "MIT",
"dependencies": {
"@types/estree-jsx": "^1.0.0",
"@types/hast": "^3.0.0",
"@types/mdast": "^4.0.0",
"devlop": "^1.0.0",
"mdast-util-from-markdown": "^2.0.0",
"mdast-util-to-markdown": "^2.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/mdast-util-mdx-jsx": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/mdast-util-mdx-jsx/-/mdast-util-mdx-jsx-3.2.0.tgz",
"integrity": "sha512-lj/z8v0r6ZtsN/cGNNtemmmfoLAFZnjMbNyLzBafjzikOM+glrjNHPlf6lQDOTccj9n5b0PPihEBbhneMyGs1Q==",
"license": "MIT",
"dependencies": {
"@types/estree-jsx": "^1.0.0",
"@types/hast": "^3.0.0",
"@types/mdast": "^4.0.0",
"@types/unist": "^3.0.0",
"ccount": "^2.0.0",
"devlop": "^1.1.0",
"mdast-util-from-markdown": "^2.0.0",
"mdast-util-to-markdown": "^2.0.0",
"parse-entities": "^4.0.0",
"stringify-entities": "^4.0.0",
"unist-util-stringify-position": "^4.0.0",
"vfile-message": "^4.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/mdast-util-mdxjs-esm": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/mdast-util-mdxjs-esm/-/mdast-util-mdxjs-esm-2.0.1.tgz",
"integrity": "sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg==",
"license": "MIT",
"dependencies": {
"@types/estree-jsx": "^1.0.0",
"@types/hast": "^3.0.0",
"@types/mdast": "^4.0.0",
"devlop": "^1.0.0",
"mdast-util-from-markdown": "^2.0.0",
"mdast-util-to-markdown": "^2.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/mdast-util-phrasing": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/mdast-util-phrasing/-/mdast-util-phrasing-4.1.0.tgz",
"integrity": "sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==",
"license": "MIT",
"dependencies": {
"@types/mdast": "^4.0.0",
"unist-util-is": "^6.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/mdast-util-to-hast": {
"version": "13.2.1",
"resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-13.2.1.tgz",
"integrity": "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==",
"license": "MIT",
"dependencies": {
"@types/hast": "^3.0.0",
"@types/mdast": "^4.0.0",
"@ungap/structured-clone": "^1.0.0",
"devlop": "^1.0.0",
"micromark-util-sanitize-uri": "^2.0.0",
"trim-lines": "^3.0.0",
"unist-util-position": "^5.0.0",
"unist-util-visit": "^5.0.0",
"vfile": "^6.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/mdast-util-to-markdown": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/mdast-util-to-markdown/-/mdast-util-to-markdown-2.1.2.tgz",
"integrity": "sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA==",
"license": "MIT",
"dependencies": {
"@types/mdast": "^4.0.0",
"@types/unist": "^3.0.0",
"longest-streak": "^3.0.0",
"mdast-util-phrasing": "^4.0.0",
"mdast-util-to-string": "^4.0.0",
"micromark-util-classify-character": "^2.0.0",
"micromark-util-decode-string": "^2.0.0",
"unist-util-visit": "^5.0.0",
"zwitch": "^2.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/mdast-util-to-string": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/mdast-util-to-string/-/mdast-util-to-string-4.0.0.tgz",
@@ -11243,6 +11569,31 @@
"node": ">=6"
}
},
"node_modules/parse-entities": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/parse-entities/-/parse-entities-4.0.2.tgz",
"integrity": "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==",
"license": "MIT",
"dependencies": {
"@types/unist": "^2.0.0",
"character-entities-legacy": "^3.0.0",
"character-reference-invalid": "^2.0.0",
"decode-named-character-reference": "^1.0.0",
"is-alphanumerical": "^2.0.0",
"is-decimal": "^2.0.0",
"is-hexadecimal": "^2.0.0"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/parse-entities/node_modules/@types/unist": {
"version": "2.0.11",
"resolved": "https://registry.npmjs.org/@types/unist/-/unist-2.0.11.tgz",
"integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==",
"license": "MIT"
},
"node_modules/parse5": {
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz",
@@ -11647,6 +11998,16 @@
"devOptional": true,
"license": "ISC"
},
"node_modules/property-information": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/property-information/-/property-information-7.2.0.tgz",
"integrity": "sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/prosemirror-changeset": {
"version": "2.4.0",
"resolved": "https://registry.npmjs.org/prosemirror-changeset/-/prosemirror-changeset-2.4.0.tgz",
@@ -11937,6 +12298,33 @@
"dev": true,
"license": "MIT"
},
"node_modules/react-markdown": {
"version": "10.1.0",
"resolved": "https://registry.npmjs.org/react-markdown/-/react-markdown-10.1.0.tgz",
"integrity": "sha512-qKxVopLT/TyA6BX3Ue5NwabOsAzm0Q7kAPwq6L+wWDwisYs7R8vZ0nRXqq6rkueboxpkjvLGU9fWifiX/ZZFxQ==",
"license": "MIT",
"dependencies": {
"@types/hast": "^3.0.0",
"@types/mdast": "^4.0.0",
"devlop": "^1.0.0",
"hast-util-to-jsx-runtime": "^2.0.0",
"html-url-attributes": "^3.0.0",
"mdast-util-to-hast": "^13.0.0",
"remark-parse": "^11.0.0",
"remark-rehype": "^11.0.0",
"unified": "^11.0.0",
"unist-util-visit": "^5.0.0",
"vfile": "^6.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
},
"peerDependencies": {
"@types/react": ">=18",
"react": ">=18"
}
},
"node_modules/readdirp": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz",
@@ -11995,6 +12383,20 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/rehype-sanitize": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/rehype-sanitize/-/rehype-sanitize-6.0.0.tgz",
"integrity": "sha512-CsnhKNsyI8Tub6L4sm5ZFsme4puGfc6pYylvXo1AeqaGbjOYyzNv3qZPwvs0oMJ39eryyeOdmxwUIo94IpEhqg==",
"license": "MIT",
"dependencies": {
"@types/hast": "^3.0.0",
"hast-util-sanitize": "^5.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/remark-parse": {
"version": "11.0.0",
"resolved": "https://registry.npmjs.org/remark-parse/-/remark-parse-11.0.0.tgz",
@@ -12011,6 +12413,23 @@
"url": "https://opencollective.com/unified"
}
},
"node_modules/remark-rehype": {
"version": "11.1.2",
"resolved": "https://registry.npmjs.org/remark-rehype/-/remark-rehype-11.1.2.tgz",
"integrity": "sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw==",
"license": "MIT",
"dependencies": {
"@types/hast": "^3.0.0",
"@types/mdast": "^4.0.0",
"mdast-util-to-hast": "^13.0.0",
"unified": "^11.0.0",
"vfile": "^6.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/remeda": {
"version": "2.33.4",
"resolved": "https://registry.npmjs.org/remeda/-/remeda-2.33.4.tgz",
@@ -12551,6 +12970,16 @@
"node": ">=0.10.0"
}
},
"node_modules/space-separated-tokens": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/space-separated-tokens/-/space-separated-tokens-2.0.2.tgz",
"integrity": "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/split2": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
@@ -12734,6 +13163,20 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/stringify-entities": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz",
"integrity": "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==",
"license": "MIT",
"dependencies": {
"character-entities-html4": "^2.0.0",
"character-entities-legacy": "^3.0.0"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/strip-bom": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz",
@@ -12778,6 +13221,24 @@
],
"license": "MIT"
},
"node_modules/style-to-js": {
"version": "1.1.21",
"resolved": "https://registry.npmjs.org/style-to-js/-/style-to-js-1.1.21.tgz",
"integrity": "sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ==",
"license": "MIT",
"dependencies": {
"style-to-object": "1.0.14"
}
},
"node_modules/style-to-object": {
"version": "1.0.14",
"resolved": "https://registry.npmjs.org/style-to-object/-/style-to-object-1.0.14.tgz",
"integrity": "sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw==",
"license": "MIT",
"dependencies": {
"inline-style-parser": "0.2.7"
}
},
"node_modules/styled-jsx": {
"version": "5.1.6",
"resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.6.tgz",
@@ -13026,6 +13487,16 @@
"node": ">=20"
}
},
"node_modules/trim-lines": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz",
"integrity": "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/trough": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/trough/-/trough-2.2.0.tgz",
@@ -13334,6 +13805,32 @@
"url": "https://opencollective.com/unified"
}
},
"node_modules/unist-util-is": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-6.0.1.tgz",
"integrity": "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==",
"license": "MIT",
"dependencies": {
"@types/unist": "^3.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/unist-util-position": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/unist-util-position/-/unist-util-position-5.0.0.tgz",
"integrity": "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==",
"license": "MIT",
"dependencies": {
"@types/unist": "^3.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/unist-util-stringify-position": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz",
@@ -13347,6 +13844,35 @@
"url": "https://opencollective.com/unified"
}
},
"node_modules/unist-util-visit": {
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/unist-util-visit/-/unist-util-visit-5.1.0.tgz",
"integrity": "sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==",
"license": "MIT",
"dependencies": {
"@types/unist": "^3.0.0",
"unist-util-is": "^6.0.0",
"unist-util-visit-parents": "^6.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/unist-util-visit-parents": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/unist-util-visit-parents/-/unist-util-visit-parents-6.0.2.tgz",
"integrity": "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==",
"license": "MIT",
"dependencies": {
"@types/unist": "^3.0.0",
"unist-util-is": "^6.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/unrs-resolver": {
"version": "1.11.1",
"resolved": "https://registry.npmjs.org/unrs-resolver/-/unrs-resolver-1.11.1.tgz",
@@ -13964,6 +14490,16 @@
"peerDependencies": {
"zod": "^3.25.0 || ^4.0.0"
}
},
"node_modules/zwitch": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz",
"integrity": "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/wooorm"
}
}
}
}
+2
View File
@@ -47,6 +47,8 @@
"playwright-core": "^1.61.1",
"react": "19.2.4",
"react-dom": "19.2.4",
"react-markdown": "^10.1.0",
"rehype-sanitize": "^6.0.0",
"remark-parse": "^11.0.0",
"resend": "^6.10.0",
"sonner": "^2.0.7",
@@ -0,0 +1,2 @@
-- Комментарии под уроками: метка «изменено» (ставится только при редактировании текста)
ALTER TABLE "LessonComment" ADD COLUMN "editedAt" TIMESTAMP(3);
+4 -3
View File
@@ -353,12 +353,13 @@ model HomeworkFeedback {
// ─────────────────────────────────────────────
model LessonComment {
id String @id @default(cuid())
id String @id @default(cuid())
lessonId String
userId String
text String
deleted Boolean @default(false)
createdAt DateTime @default(now())
deleted Boolean @default(false)
createdAt DateTime @default(now())
editedAt DateTime?
parentId String?
lesson Lesson @relation(fields: [lessonId], references: [id], onDelete: Cascade)
+32
View File
@@ -241,3 +241,35 @@
border-left-color: #E8F0D8;
background-color: var(--sidebar-surface);
}
/* Markdown-разметка в комментариях под уроками (react-markdown) */
.comment-md > :first-child { margin-top: 0; }
.comment-md > :last-child { margin-bottom: 0; }
.comment-md p { margin: 0 0 0.5rem; }
.comment-md strong { font-weight: 700; }
.comment-md em { font-style: italic; }
.comment-md a { color: var(--foreground); text-decoration: underline; text-underline-offset: 2px; }
.comment-md ul { list-style: disc; padding-left: 1.4rem; margin: 0 0 0.5rem; }
.comment-md ol { list-style: decimal; padding-left: 1.4rem; margin: 0 0 0.5rem; }
.comment-md li { margin: 0.15rem 0; }
.comment-md blockquote {
border-left: 2px solid var(--border);
padding-left: 0.75rem;
margin: 0 0 0.5rem;
color: var(--muted-foreground);
}
.comment-md code {
font-family: var(--font-mono, monospace);
font-size: 0.9em;
background: var(--color-surface);
padding: 0.05rem 0.3rem;
border-radius: 2px;
}
.comment-md pre {
background: var(--color-surface);
padding: 0.6rem 0.75rem;
border-radius: 2px;
overflow-x: auto;
margin: 0 0 0.5rem;
}
.comment-md pre code { background: none; padding: 0; }
+260 -129
View File
@@ -1,13 +1,18 @@
"use client";
import { useState, useTransition } from "react";
import { addComment, deleteComment } from "@/lib/actions/student-actions";
import { useRef, useState, useTransition, type ReactNode } from "react";
import ReactMarkdown from "react-markdown";
import rehypeSanitize from "rehype-sanitize";
import { addComment, deleteComment, editComment } from "@/lib/actions/student-actions";
const MAX_LEN = 10000;
type Reply = {
id: string;
text: string;
deleted: boolean;
createdAt: Date;
editedAt: Date | null;
user: { id: string; name: string };
};
@@ -16,6 +21,7 @@ type Comment = {
text: string;
deleted: boolean;
createdAt: Date;
editedAt: Date | null;
user: { id: string; name: string };
replies: Reply[];
};
@@ -38,10 +44,146 @@ function formatDate(date: Date) {
});
}
// Renders comment text as sanitized Markdown (no raw HTML, safe links).
function CommentBody({ text }: { text: string }) {
return (
<div className="comment-md text-sm leading-relaxed break-words">
<ReactMarkdown
rehypePlugins={[rehypeSanitize]}
components={{
a: ({ ...props }) => <a {...props} target="_blank" rel="noopener noreferrer" />,
}}
>
{text}
</ReactMarkdown>
</div>
);
}
const toolbarBtnStyle = {
border: "2px solid var(--border)",
color: "var(--foreground)",
background: "var(--background)",
minWidth: "30px",
} as const;
function ToolbarButton({
title,
onClick,
disabled,
children,
}: {
title: string;
onClick: () => void;
disabled?: boolean;
children: ReactNode;
}) {
return (
<button
type="button"
title={title}
disabled={disabled}
onClick={onClick}
className="text-xs px-2 py-1 leading-none transition-colors"
style={toolbarBtnStyle}
onMouseEnter={(e) => (e.currentTarget.style.background = "var(--accent)")}
onMouseLeave={(e) => (e.currentTarget.style.background = "var(--background)")}
>
{children}
</button>
);
}
interface EditorProps {
value: string;
onChange: (v: string) => void;
placeholder: string;
disabled?: boolean;
rows?: number;
}
// Textarea + Markdown formatting toolbar + character counter. Reused for the
// new comment, replies, and inline editing.
function CommentEditor({ value, onChange, placeholder, disabled, rows = 3 }: EditorProps) {
const ref = useRef<HTMLTextAreaElement>(null);
// Wrap the current selection with before/after markers.
function wrap(before: string, after: string, placeholderText: string) {
const el = ref.current;
if (!el) return;
const start = el.selectionStart;
const end = el.selectionEnd;
const selected = value.slice(start, end) || placeholderText;
const next = value.slice(0, start) + before + selected + after + value.slice(end);
onChange(next);
requestAnimationFrame(() => {
el.focus();
el.setSelectionRange(start + before.length, start + before.length + selected.length);
});
}
// Prefix each selected line (for lists and quotes).
function prefixLines(prefix: string) {
const el = ref.current;
if (!el) return;
const start = el.selectionStart;
const end = el.selectionEnd;
const lineStart = value.lastIndexOf("\n", start - 1) + 1;
const block = value.slice(lineStart, end);
const prefixed = block
.split("\n")
.map((l) => prefix + l)
.join("\n");
const next = value.slice(0, lineStart) + prefixed + value.slice(end);
onChange(next);
requestAnimationFrame(() => {
el.focus();
el.setSelectionRange(lineStart, lineStart + prefixed.length);
});
}
return (
<div>
<div className="flex flex-wrap gap-1 mb-2">
<ToolbarButton title="Жирный" disabled={disabled} onClick={() => wrap("**", "**", "текст")}><b>Ж</b></ToolbarButton>
<ToolbarButton title="Курсив" disabled={disabled} onClick={() => wrap("*", "*", "текст")}><i>К</i></ToolbarButton>
<ToolbarButton title="Список" disabled={disabled} onClick={() => prefixLines("- ")}></ToolbarButton>
<ToolbarButton title="Цитата" disabled={disabled} onClick={() => prefixLines("> ")}>»</ToolbarButton>
<ToolbarButton title="Код" disabled={disabled} onClick={() => wrap("`", "`", "код")}>{"</>"}</ToolbarButton>
<ToolbarButton title="Ссылка" disabled={disabled} onClick={() => wrap("[", "](https://)", "текст")}>🔗</ToolbarButton>
</div>
<textarea
ref={ref}
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder}
rows={rows}
maxLength={MAX_LEN}
disabled={disabled}
className="w-full text-sm p-3 resize-y"
style={{
border: "2px solid var(--border)",
backgroundColor: "var(--background)",
color: "var(--foreground)",
outline: "none",
fontFamily: "inherit",
}}
onFocus={(e) => (e.target.style.borderColor = "var(--foreground)")}
onBlur={(e) => (e.target.style.borderColor = "var(--border)")}
/>
<div className="text-xs mt-1" style={{ color: "var(--muted-foreground)" }}>
Поддерживается форматирование Markdown{value.length > 0 ? ` · ${value.length}/${MAX_LEN}` : ""}
</div>
</div>
);
}
export function LessonComments({ lessonId, slug, comments, currentUserId, currentUserRole }: Props) {
const [text, setText] = useState("");
const [replyToId, setReplyToId] = useState<string | null>(null);
const [replyText, setReplyText] = useState("");
const [editingId, setEditingId] = useState<string | null>(null);
const [editText, setEditText] = useState("");
const [isPending, startTransition] = useTransition();
const [error, setError] = useState<string | null>(null);
@@ -74,6 +216,19 @@ export function LessonComments({ lessonId, slug, comments, currentUserId, curren
});
}
function handleEdit(commentId: string) {
if (!editText.trim()) return;
startTransition(async () => {
try {
await editComment(commentId, lessonId, slug, editText.trim());
setEditingId(null);
setEditText("");
} catch {
// ignore
}
});
}
function handleDelete(commentId: string) {
startTransition(async () => {
try {
@@ -84,6 +239,89 @@ export function LessonComments({ lessonId, slug, comments, currentUserId, curren
});
}
function renderNode(node: Comment | Reply, isReply: boolean) {
const isOwn = node.user.id === currentUserId;
const editing = editingId === node.id;
return (
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1 flex-wrap">
<span className="text-sm font-bold">{node.user.name}</span>
<span className="text-xs" style={{ color: "var(--muted-foreground)" }}>
{formatDate(node.createdAt)}
{node.editedAt && !node.deleted ? " · изменено" : ""}
</span>
</div>
{node.deleted ? (
<p className="text-sm italic" style={{ color: "var(--muted-foreground)" }}>
[Комментарий удалён]
</p>
) : editing ? (
<div>
<CommentEditor value={editText} onChange={setEditText} placeholder="Измените комментарий..." disabled={isPending} rows={3} />
<div className="flex items-center gap-2 mt-2">
<button
onClick={() => handleEdit(node.id)}
disabled={isPending || !editText.trim()}
className="btn-aubade btn-aubade-accent text-sm"
>
{isPending ? "Сохранение..." : "Сохранить"}
</button>
<button
onClick={() => { setEditingId(null); setEditText(""); }}
disabled={isPending}
className="text-xs underline"
style={{ color: "var(--muted-foreground)" }}
>
Отмена
</button>
</div>
</div>
) : (
<CommentBody text={node.text} />
)}
{!node.deleted && !editing && (
<div className="flex gap-3 mt-1">
{isOwn && (
<button
onClick={() => { setEditingId(node.id); setEditText(node.text); setReplyToId(null); }}
disabled={isPending}
className="text-xs underline"
style={{ color: "var(--muted-foreground)" }}
>
Изменить
</button>
)}
{(isOwn || canModerate) && (
<button
onClick={() => handleDelete(node.id)}
disabled={isPending}
className="text-xs underline"
style={{ color: "var(--muted-foreground)" }}
>
Удалить
</button>
)}
{!isReply && canModerate && (
<button
onClick={() => {
setReplyToId(replyToId === node.id ? null : node.id);
setReplyText("");
}}
disabled={isPending}
className="text-xs underline"
style={{ color: "var(--muted-foreground)" }}
>
{replyToId === node.id ? "Отмена" : "Ответить"}
</button>
)}
</div>
)}
</div>
);
}
return (
<div>
<div className="space-y-5 mb-6">
@@ -107,86 +345,25 @@ export function LessonComments({ lessonId, slug, comments, currentUserId, curren
>
{comment.user.name[0]?.toUpperCase() ?? "?"}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1 flex-wrap">
<span className="text-sm font-bold">{comment.user.name}</span>
<span className="text-xs" style={{ color: "var(--muted-foreground)" }}>
{formatDate(comment.createdAt)}
</span>
</div>
{comment.deleted ? (
<p className="text-sm italic" style={{ color: "var(--muted-foreground)" }}>
[Комментарий удалён]
</p>
) : (
<p className="text-sm whitespace-pre-wrap break-words leading-relaxed">
{comment.text}
</p>
)}
<div className="flex gap-3 mt-1">
{!comment.deleted && (comment.user.id === currentUserId || canModerate) && (
<button
onClick={() => handleDelete(comment.id)}
disabled={isPending}
className="text-xs underline"
style={{ color: "var(--muted-foreground)" }}
>
Удалить
</button>
)}
{!comment.deleted && canModerate && (
<button
onClick={() => {
setReplyToId(replyToId === comment.id ? null : comment.id);
setReplyText("");
}}
disabled={isPending}
className="text-xs underline"
style={{ color: "var(--muted-foreground)" }}
>
{replyToId === comment.id ? "Отмена" : "Ответить"}
</button>
)}
</div>
{/* Inline reply form */}
{replyToId === comment.id && (
<div className="mt-3">
<textarea
value={replyText}
onChange={(e) => setReplyText(e.target.value)}
placeholder="Напишите ответ..."
rows={2}
maxLength={2000}
disabled={isPending}
className="w-full text-sm p-3 resize-y"
style={{
border: "2px solid var(--border)",
backgroundColor: "var(--background)",
color: "var(--foreground)",
outline: "none",
fontFamily: "inherit",
}}
onFocus={(e) => (e.target.style.borderColor = "var(--foreground)")}
onBlur={(e) => (e.target.style.borderColor = "var(--border)")}
/>
<div className="flex justify-end mt-2">
<button
onClick={() => handleReply(comment.id)}
disabled={isPending || !replyText.trim()}
className="btn-aubade btn-aubade-accent text-sm"
>
{isPending ? "Отправка..." : "Отправить ответ"}
</button>
</div>
</div>
)}
</div>
{renderNode(comment, false)}
</div>
{/* Inline reply form (under root) */}
{replyToId === comment.id && (
<div className="mt-3 ml-11">
<CommentEditor value={replyText} onChange={setReplyText} placeholder="Напишите ответ..." disabled={isPending} rows={2} />
<div className="flex justify-end mt-2">
<button
onClick={() => handleReply(comment.id)}
disabled={isPending || !replyText.trim()}
className="btn-aubade btn-aubade-accent text-sm"
>
{isPending ? "Отправка..." : "Отправить ответ"}
</button>
</div>
</div>
)}
{/* Replies */}
{comment.replies.length > 0 && (
<div
@@ -205,33 +382,7 @@ export function LessonComments({ lessonId, slug, comments, currentUserId, curren
>
{reply.user.name[0]?.toUpperCase() ?? "?"}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1 flex-wrap">
<span className="text-sm font-bold">{reply.user.name}</span>
<span className="text-xs" style={{ color: "var(--muted-foreground)" }}>
{formatDate(reply.createdAt)}
</span>
</div>
{reply.deleted ? (
<p className="text-sm italic" style={{ color: "var(--muted-foreground)" }}>
[Комментарий удалён]
</p>
) : (
<p className="text-sm whitespace-pre-wrap break-words leading-relaxed">
{reply.text}
</p>
)}
{!reply.deleted && (reply.user.id === currentUserId || canModerate) && (
<button
onClick={() => handleDelete(reply.id)}
disabled={isPending}
className="text-xs mt-1 underline"
style={{ color: "var(--muted-foreground)" }}
>
Удалить
</button>
)}
</div>
{renderNode(reply, true)}
</div>
))}
</div>
@@ -242,33 +393,13 @@ export function LessonComments({ lessonId, slug, comments, currentUserId, curren
{/* New comment form */}
<form onSubmit={handleAdd}>
<textarea
value={text}
onChange={(e) => setText(e.target.value)}
placeholder="Напишите комментарий..."
rows={3}
maxLength={2000}
disabled={isPending}
className="w-full text-sm p-3 resize-y"
style={{
border: "2px solid var(--border)",
backgroundColor: "var(--background)",
color: "var(--foreground)",
outline: "none",
fontFamily: "inherit",
}}
onFocus={(e) => (e.target.style.borderColor = "var(--foreground)")}
onBlur={(e) => (e.target.style.borderColor = "var(--border)")}
/>
<CommentEditor value={text} onChange={setText} placeholder="Напишите комментарий..." disabled={isPending} rows={3} />
{error && (
<p className="text-xs mt-1" style={{ color: "oklch(0.577 0.245 27.325)" }}>
{error}
</p>
)}
<div className="flex items-center justify-between mt-2">
<span className="text-xs" style={{ color: "var(--muted-foreground)" }}>
{text.length > 0 ? `${text.length}/2000` : ""}
</span>
<div className="flex items-center justify-end mt-2">
<button
type="submit"
disabled={isPending || !text.trim()}
+22 -1
View File
@@ -152,7 +152,7 @@ export async function addComment(lessonId: string, slug: string, text: string, p
if (!session) throw new Error("Unauthorized");
const trimmed = text.trim();
if (!trimmed || trimmed.length > 2000) throw new Error("Invalid text");
if (!trimmed || trimmed.length > 10000) throw new Error("Invalid text");
const lesson = await prisma.lesson.findUnique({
where: { id: lessonId },
@@ -228,3 +228,24 @@ export async function deleteComment(commentId: string, lessonId: string, slug: s
revalidatePath(`/courses/${slug}/lessons/${lessonId}`);
}
export async function editComment(commentId: string, lessonId: string, slug: string, text: string) {
const session = await auth.api.getSession({ headers: await headers() });
if (!session) throw new Error("Unauthorized");
const trimmed = text.trim();
if (!trimmed || trimmed.length > 10000) throw new Error("Invalid text");
const comment = await prisma.lessonComment.findUnique({ where: { id: commentId } });
if (!comment) throw new Error("Not found");
if (comment.deleted) throw new Error("Comment deleted");
// Только автор может редактировать текст своего комментария (модераторы — только удаление).
if (comment.userId !== session.user.id) throw new Error("Forbidden");
await prisma.lessonComment.update({
where: { id: commentId },
data: { text: trimmed, editedAt: new Date() },
});
revalidatePath(`/courses/${slug}/lessons/${lessonId}`);
}