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
+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()}