Files
lms-sb/src/components/student/lesson-comments.tsx
T
admins a92592ec63 Autolink bare URLs in lesson comments
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.
2026-08-16 11:55:48 +05:00

506 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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![${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 (
<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>
);
}