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:
@@ -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()}
|
||||
|
||||
Reference in New Issue
Block a user