"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 здесь не подходит
);
},
}}
>
{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\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 (
);
}
export function LessonComments({ lessonId, slug, comments, currentUserId, currentUserRole, imageHost }: Props) {
const [text, setText] = useState("");
const [replyToId, setReplyToId] = useState(null);
const [replyText, setReplyText] = useState("");
const [editingId, setEditingId] = useState(null);
const [editText, setEditText] = useState("");
const [isPending, startTransition] = useTransition();
const [error, setError] = useState(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 (
{node.user.name}
{formatDate(node.createdAt)}
{node.editedAt && !node.deleted ? " · изменено" : ""}
{node.deleted ? (
[Комментарий удалён]
) : editing ? (
) : (
)}
{!node.deleted && !editing && (
{isOwn && (
)}
{(isOwn || canModerate) && (
)}
{!isReply && canModerate && (
)}
)}
);
}
return (
{comments.length === 0 && (
Пока нет комментариев. Будьте первым!
)}
{comments.map((comment) => (
{/* Root comment */}
{comment.user.name[0]?.toUpperCase() ?? "?"}
{renderNode(comment, false)}
{/* Inline reply form (under root) */}
{replyToId === comment.id && (
)}
{/* Replies */}
{comment.replies.length > 0 && (
{comment.replies.map((reply) => (
{reply.user.name[0]?.toUpperCase() ?? "?"}
{renderNode(reply, true)}
))}
)}
))}
{/* New comment form */}
);
}