AcadeDoc/apps/client/src/features/acadenice/comments/components/row-comments-panel.tsx
Corentin be951a22ac feat(acadenice): add inline comments threads for R3.8 (30 tests, Patch 016)
Page comments: REST resolve/unresolve facade over native comments table
(PageCommentResolveService + CollaborationGateway yjs mark sync).
Row comments: new acadenice_row_comment table + full CRUD + resolve
(RowCommentService, RowCommentsController) + React panel in RowDetailModal.
4 new permissions: comments:read/write/resolve/moderate (30 total).
i18n FR+EN. R3 ENTIEREMENT TERMINE.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 02:47:15 +02:00

328 lines
9.3 KiB
TypeScript

import {
Stack,
Text,
Group,
Button,
Textarea,
Badge,
ActionIcon,
Loader,
Paper,
Divider,
Tooltip,
} from "@mantine/core";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import {
useRowComments,
useCreateRowComment,
useResolveRowComment,
useDeleteRowComment,
} from "../hooks/use-row-comments";
import type { RowComment } from "../services/row-comments-client";
interface RowCommentsPanelProps {
tableId: string;
rowId: string;
/** Current user id — used to show own-comment controls */
currentUserId: string;
}
/**
* RowCommentsPanel — threaded comment panel for a Baserow row (R3.8).
*
* Shows unresolved threads by default; a "Resolved" tab toggles to archived.
* Compose area creates root comments; "Reply" button creates a child.
*
* Architecture note: content is stored as Tiptap JSON on the server but
* displayed as plain text here. A future enhancement could plug in a
* read-only Tiptap renderer — out of scope for R3.8 (Notion-like phase 1).
*/
export function RowCommentsPanel({
tableId,
rowId,
currentUserId,
}: RowCommentsPanelProps) {
const { t } = useTranslation();
const [showResolved, setShowResolved] = useState(false);
const [draft, setDraft] = useState("");
const [replyTo, setReplyTo] = useState<string | null>(null);
const { data: comments = [], isLoading } = useRowComments(
tableId,
rowId,
showResolved ? true : false,
);
const createMutation = useCreateRowComment();
const resolveMutation = useResolveRowComment();
const deleteMutation = useDeleteRowComment();
function tiptapDocFromText(text: string) {
return JSON.stringify({
type: "doc",
content: [{ type: "paragraph", content: [{ type: "text", text }] }],
});
}
function extractText(content: Record<string, unknown> | string): string {
if (!content) return "";
const doc =
typeof content === "string" ? JSON.parse(content) : content;
if (!doc?.content) return "";
const texts: string[] = [];
function walk(node: Record<string, unknown>) {
if (node.type === "text" && typeof node.text === "string") {
texts.push(node.text);
}
if (Array.isArray(node.content)) {
(node.content as Record<string, unknown>[]).forEach(walk);
}
}
(doc.content as Record<string, unknown>[]).forEach(walk);
return texts.join(" ");
}
async function handleSubmit() {
const text = draft.trim();
if (!text) return;
await createMutation.mutateAsync({
tableId,
rowId,
content: tiptapDocFromText(text),
parentCommentId: replyTo ?? undefined,
});
setDraft("");
setReplyTo(null);
}
const rootComments = comments.filter((c) => c.parentCommentId === null);
return (
<Stack gap="xs">
{/* Tab toggle */}
<Group gap="xs">
<Button
size="xs"
variant={!showResolved ? "filled" : "subtle"}
onClick={() => setShowResolved(false)}
>
{t("acadenice.comments.open")}
</Button>
<Button
size="xs"
variant={showResolved ? "filled" : "subtle"}
onClick={() => setShowResolved(true)}
>
{t("acadenice.comments.resolved")}
</Button>
</Group>
{isLoading && <Loader size="sm" />}
{!isLoading && rootComments.length === 0 && (
<Text size="sm" c="dimmed">
{t("acadenice.comments.empty")}
</Text>
)}
{/* Thread list */}
{rootComments.map((root) => {
const replies = comments.filter((c) => c.parentCommentId === root.id);
return (
<Paper key={root.id} p="xs" withBorder radius="sm">
<CommentItem
comment={root}
currentUserId={currentUserId}
tableId={tableId}
rowId={rowId}
extractText={extractText}
onReply={() => setReplyTo(root.id)}
onResolve={(resolved) =>
resolveMutation.mutate({
commentId: root.id,
resolved,
tableId,
rowId,
})
}
onDelete={() =>
deleteMutation.mutate({ commentId: root.id, tableId, rowId })
}
/>
{replies.map((reply) => (
<Paper key={reply.id} ml="md" mt="xs" p="xs" withBorder radius="sm">
<CommentItem
comment={reply}
currentUserId={currentUserId}
tableId={tableId}
rowId={rowId}
extractText={extractText}
onDelete={() =>
deleteMutation.mutate({
commentId: reply.id,
tableId,
rowId,
})
}
/>
</Paper>
))}
{replyTo === root.id && (
<Stack gap="xs" mt="xs">
<Textarea
placeholder={t("acadenice.comments.reply_placeholder")}
value={draft}
onChange={(e) => setDraft(e.currentTarget.value)}
minRows={2}
autosize
/>
<Group gap="xs">
<Button
size="xs"
loading={createMutation.isPending}
onClick={handleSubmit}
>
{t("acadenice.comments.send_reply")}
</Button>
<Button
size="xs"
variant="subtle"
onClick={() => {
setReplyTo(null);
setDraft("");
}}
>
{t("acadenice.comments.cancel")}
</Button>
</Group>
</Stack>
)}
</Paper>
);
})}
{/* Compose area — new root comment */}
{!replyTo && !showResolved && (
<Stack gap="xs" mt="xs">
<Textarea
placeholder={t("acadenice.comments.new_placeholder")}
value={draft}
onChange={(e) => setDraft(e.currentTarget.value)}
minRows={2}
autosize
/>
<Button
size="xs"
loading={createMutation.isPending}
onClick={handleSubmit}
disabled={!draft.trim()}
>
{t("acadenice.comments.send")}
</Button>
</Stack>
)}
</Stack>
);
}
// ---------------------------------------------------------------------------
// Internal: single comment display
// ---------------------------------------------------------------------------
interface CommentItemProps {
comment: RowComment;
currentUserId: string;
tableId: string;
rowId: string;
extractText: (content: Record<string, unknown> | string) => string;
onReply?: () => void;
onResolve?: (resolved: boolean) => void;
onDelete?: () => void;
}
function CommentItem({
comment,
currentUserId,
extractText,
onReply,
onResolve,
onDelete,
}: CommentItemProps) {
const { t } = useTranslation();
const isOwn = comment.authorUserId === currentUserId;
const text = extractText(comment.content as Record<string, unknown>);
return (
<Stack gap={4}>
<Group justify="space-between" align="flex-start">
<Text size="xs" fw={600}>
{comment.author?.name ?? t("acadenice.comments.unknown_user")}
</Text>
<Group gap={4}>
{comment.isResolved && (
<Badge size="xs" color="green" variant="light">
{t("acadenice.comments.resolved_badge")}
</Badge>
)}
{onResolve && !comment.isResolved && (
<Tooltip label={t("acadenice.comments.resolve_action")}>
<ActionIcon
size="xs"
variant="subtle"
color="green"
onClick={() => onResolve(true)}
aria-label={t("acadenice.comments.resolve_action")}
>
&#10003;
</ActionIcon>
</Tooltip>
)}
{onResolve && comment.isResolved && (
<Tooltip label={t("acadenice.comments.reopen_action")}>
<ActionIcon
size="xs"
variant="subtle"
color="gray"
onClick={() => onResolve(false)}
aria-label={t("acadenice.comments.reopen_action")}
>
&#8635;
</ActionIcon>
</Tooltip>
)}
{isOwn && onDelete && (
<Tooltip label={t("acadenice.comments.delete_action")}>
<ActionIcon
size="xs"
variant="subtle"
color="red"
onClick={onDelete}
aria-label={t("acadenice.comments.delete_action")}
>
&#10005;
</ActionIcon>
</Tooltip>
)}
</Group>
</Group>
<Text size="sm">{text}</Text>
{onReply && (
<Button
size="xs"
variant="subtle"
onClick={onReply}
mt={2}
>
{t("acadenice.comments.reply")}
</Button>
)}
</Stack>
);
}