Comments render CommonMark, which leaves a pasted https://… as dead text — students share links constantly and nobody writes [text](url) by hand. Add remark-gfm so plain URLs, www hosts and emails become links (and tables and strikethrough come along), plus table styling so a wide table scrolls inside itself instead of stretching the page. Sanitizing is unchanged: javascript: is neither linked nor autolinked, raw HTML is still dropped, and third-party images are still rendered as plain links by the img component.
506 lines
18 KiB
TypeScript
506 lines
18 KiB
TypeScript
"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 (
|
||
<div className="comment-md text-sm leading-relaxed break-words">
|
||
<ReactMarkdown
|
||
// GFM: голый адрес сам становится ссылкой. Без него студент пишет
|
||
// https://… и получает мёртвый текст — разметку никто не проставляет.
|
||
remarkPlugins={[remarkGfm]}
|
||
rehypePlugins={[[rehypeSanitize, sanitizeSchema]]}
|
||
components={{
|
||
// eslint-disable-next-line @typescript-eslint/no-unused-vars -- отбрасываем служебный node от react-markdown, чтобы он не протекал в DOM
|
||
a: ({ node, ...props }) => <a {...props} target="_blank" rel="noopener noreferrer" />,
|
||
// 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 (
|
||
<a href={url} target="_blank" rel="noopener noreferrer">
|
||
{alt || url}
|
||
</a>
|
||
);
|
||
}
|
||
return (
|
||
// eslint-disable-next-line @next/next/no-img-element -- пользовательская картинка произвольного размера, next/image здесь не подходит
|
||
<img {...props} src={url} alt={alt ?? ""} loading="lazy" />
|
||
);
|
||
},
|
||
}}
|
||
>
|
||
{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);
|
||
const fileRef = useRef<HTMLInputElement>(null);
|
||
const [uploading, setUploading] = useState(false);
|
||
const [uploadError, setUploadError] = useState<string | null>(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<HTMLInputElement>) {
|
||
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\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 (
|
||
<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>
|
||
<ToolbarButton
|
||
title="Изображение (jpg, png, gif, webp — до 5 МБ)"
|
||
disabled={disabled || uploading}
|
||
onClick={() => fileRef.current?.click()}
|
||
>
|
||
{uploading ? "…" : "🖼"}
|
||
</ToolbarButton>
|
||
<input
|
||
ref={fileRef}
|
||
type="file"
|
||
accept="image/jpeg,image/png,image/gif,image/webp"
|
||
className="hidden"
|
||
onChange={handleFile}
|
||
/>
|
||
</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)")}
|
||
/>
|
||
{uploadError && (
|
||
<div className="text-xs mt-1" style={{ color: "var(--destructive, #b00)" }}>
|
||
{uploadError}
|
||
</div>
|
||
)}
|
||
<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, imageHost }: 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);
|
||
|
||
const canModerate = currentUserRole === "curator" || currentUserRole === "admin";
|
||
|
||
function handleAdd(e: React.FormEvent) {
|
||
e.preventDefault();
|
||
if (!text.trim()) return;
|
||
setError(null);
|
||
startTransition(async () => {
|
||
try {
|
||
await addComment(lessonId, slug, text.trim());
|
||
setText("");
|
||
} catch {
|
||
setError("Не удалось отправить комментарий. Попробуйте ещё раз.");
|
||
}
|
||
});
|
||
}
|
||
|
||
function handleReply(parentId: string) {
|
||
if (!replyText.trim()) return;
|
||
startTransition(async () => {
|
||
try {
|
||
await addComment(lessonId, slug, replyText.trim(), parentId);
|
||
setReplyText("");
|
||
setReplyToId(null);
|
||
} catch {
|
||
// ignore
|
||
}
|
||
});
|
||
}
|
||
|
||
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 {
|
||
await deleteComment(commentId, lessonId, slug);
|
||
} catch {
|
||
// ignore
|
||
}
|
||
});
|
||
}
|
||
|
||
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} imageHost={imageHost} />
|
||
)}
|
||
|
||
{!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">
|
||
{comments.length === 0 && (
|
||
<p className="text-sm" style={{ color: "var(--muted-foreground)" }}>
|
||
Пока нет комментариев. Будьте первым!
|
||
</p>
|
||
)}
|
||
|
||
{comments.map((comment) => (
|
||
<div key={comment.id}>
|
||
{/* Root comment */}
|
||
<div className="flex gap-3">
|
||
<div
|
||
className="w-8 h-8 shrink-0 flex items-center justify-center text-xs font-bold"
|
||
style={{
|
||
backgroundColor: "var(--accent)",
|
||
color: "var(--foreground)",
|
||
border: "2px solid var(--border)",
|
||
}}
|
||
>
|
||
{comment.user.name[0]?.toUpperCase() ?? "?"}
|
||
</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
|
||
className="mt-3 ml-11 space-y-3 pl-4"
|
||
style={{ borderLeft: "2px solid var(--border)" }}
|
||
>
|
||
{comment.replies.map((reply) => (
|
||
<div key={reply.id} className="flex gap-3">
|
||
<div
|
||
className="w-7 h-7 shrink-0 flex items-center justify-center text-xs font-bold"
|
||
style={{
|
||
backgroundColor: "var(--accent)",
|
||
color: "var(--foreground)",
|
||
border: "2px solid var(--border)",
|
||
}}
|
||
>
|
||
{reply.user.name[0]?.toUpperCase() ?? "?"}
|
||
</div>
|
||
{renderNode(reply, true)}
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
))}
|
||
</div>
|
||
|
||
{/* New comment form */}
|
||
<form onSubmit={handleAdd}>
|
||
<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-end mt-2">
|
||
<button
|
||
type="submit"
|
||
disabled={isPending || !text.trim()}
|
||
className="btn-aubade btn-aubade-accent text-sm"
|
||
>
|
||
{isPending ? "Отправка..." : "Отправить"}
|
||
</button>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
);
|
||
}
|