"use client"; import { useRef, useState, useTransition, type ReactNode } from "react"; import ReactMarkdown from "react-markdown"; import remarkGfm from "remark-gfm"; import rehypeSanitize, { defaultSchema } 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 }; }; type Comment = { id: string; text: string; deleted: boolean; createdAt: Date; editedAt: Date | null; user: { id: string; name: string }; replies: Reply[]; }; interface Props { lessonId: string; slug: string; comments: Comment[]; currentUserId: string; currentUserRole: string; /** Public host of our own file storage (S3_CDN_URL) — see CommentBody. */ imageHost: string; } function formatDate(date: Date) { return new Date(date).toLocaleDateString("ru-RU", { day: "numeric", month: "long", year: "numeric", hour: "2-digit", minute: "2-digit", }); } // The default schema drops `alt`, so uploaded images would lose their caption. const sanitizeSchema = { ...defaultSchema, attributes: { ...defaultSchema.attributes, img: [...(defaultSchema.attributes?.img ?? []), "alt"], }, }; // Renders comment text as sanitized Markdown (no raw HTML, safe links). // `imageHost` is our own file storage: images from anywhere else are shown as a // plain link instead of being loaded, since a third-party image would fetch — // and thus expose the address — for every reader of the lesson. function CommentBody({ text, imageHost }: { text: string; imageHost: string }) { return (
, // eslint-disable-next-line @typescript-eslint/no-unused-vars img: ({ node, src, alt, ...props }) => { const url = typeof src === "string" ? src : ""; if (!imageHost || !url.startsWith(`${imageHost}/`)) { return ( {alt || url} ); } return ( // eslint-disable-next-line @next/next/no-img-element -- пользовательская картинка произвольного размера, next/image здесь не подходит {alt ); }, }} > {text}
); } 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 ( ); } 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(null); const fileRef = useRef(null); const [uploading, setUploading] = useState(false); const [uploadError, setUploadError] = useState(null); // Insert text at the caret (used for uploaded images). function insertAtCursor(snippet: string) { const el = ref.current; const at = el ? el.selectionStart : value.length; const next = value.slice(0, at) + snippet + value.slice(at); onChange(next); requestAnimationFrame(() => { el?.focus(); el?.setSelectionRange(at + snippet.length, at + snippet.length); }); } async function handleFile(e: React.ChangeEvent) { const file = e.target.files?.[0]; e.target.value = ""; // allow picking the same file twice in a row if (!file) return; setUploading(true); setUploadError(null); try { const fd = new FormData(); fd.append("file", file); const res = await fetch("/api/student/comment-upload", { method: "POST", body: fd }); const data = await res.json(); if (!res.ok) throw new Error(data.error ?? "Не удалось загрузить изображение"); // Square brackets in the filename would break the Markdown link syntax. const caption = file.name.replace(/[[\]]/g, ""); insertAtCursor(`\n![${caption}](${data.url})\n`); } catch (err) { setUploadError(err instanceof Error ? err.message : "Не удалось загрузить изображение"); } finally { setUploading(false); } } // 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 (
wrap("**", "**", "текст")}>Ж wrap("*", "*", "текст")}>К prefixLines("- ")}>• prefixLines("> ")}>» wrap("`", "`", "код")}>{""} wrap("[", "](https://)", "текст")}>🔗 fileRef.current?.click()} > {uploading ? "…" : "🖼"}