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>
328 lines
9.3 KiB
TypeScript
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")}
|
|
>
|
|
✓
|
|
</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")}
|
|
>
|
|
↻
|
|
</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")}
|
|
>
|
|
✕
|
|
</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>
|
|
);
|
|
}
|