From ed8659a5dd7bbfa092700a595e7e5040231f56c0 Mon Sep 17 00:00:00 2001 From: olliethedev <5933733+olliethedev@users.noreply.github.com> Date: Wed, 19 Aug 2026 15:03:29 +0000 Subject: [PATCH] feat(comments): phase-2 sweep onto core primitives MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Migrate the comments plugin to the v3 core primitives, mirroring the form-builder sweep: - Declare commentsResources and generate the query-key factory via the server-safe createResourceQueryKeys; key shapes are unchanged (shared discriminators from api/query-key-defs.ts) - Route all mutation HTTP calls through runResourceMutation (new optional headers param in core, matching createResourceQueryKeys); the optimistic onMutate/onSuccess/onError cache logic for post/like stays hand-written because the public hooks take an explicit client config — required by the embeddable CommentThread - Replace sonner with useNotify and route all UI strings through useTranslate with override-wins localization precedence - Add a permission prop to the moderation route and CanAccess around approve/spam/delete controls (moderation page, resource pending queue) - Move moderation tab/page and my-comments page state to useListState (URL-synced, back-button friendly, clamped against mangled URLs) - Surface StackError body field errors inline in CommentForm - Delete plugin-local error-utils.ts re-export; update build-registry - Tests: query-key parity guard, client-sweep jsdom suite; registry regenerated Co-authored-by: Cursor --- packages/stack/registry/btst-comments.json | 26 +- packages/stack/scripts/build-registry.ts | 2 +- .../src/__tests__/comments-query-keys.test.ts | 108 ++++ .../src/plugins/client/resource/queries.ts | 7 + .../comments/__tests__/client-sweep.test.tsx | 501 ++++++++++++++++++ .../client/components/comment-form.tsx | 53 +- .../client/components/comment-thread.tsx | 117 ++-- .../pages/moderation-page.internal.tsx | 436 ++++++++++----- .../components/pages/moderation-page.tsx | 5 +- .../pages/my-comments-page.internal.tsx | 145 +++-- .../components/pages/my-comments-page.tsx | 4 +- .../pages/resource-comments-page.internal.tsx | 146 +++-- .../pages/resource-comments-page.tsx | 4 +- .../client/components/shared/pagination.tsx | 22 +- .../comments/client/hooks/use-comments.tsx | 126 ++--- .../src/plugins/comments/client/utils.ts | 13 - .../stack/src/plugins/comments/error-utils.ts | 17 - .../stack/src/plugins/comments/query-keys.ts | 314 +++++------ 18 files changed, 1487 insertions(+), 559 deletions(-) create mode 100644 packages/stack/src/__tests__/comments-query-keys.test.ts create mode 100644 packages/stack/src/plugins/comments/__tests__/client-sweep.test.tsx delete mode 100644 packages/stack/src/plugins/comments/error-utils.ts diff --git a/packages/stack/registry/btst-comments.json b/packages/stack/registry/btst-comments.json index 214200f8..3533cbcc 100644 --- a/packages/stack/registry/btst-comments.json +++ b/packages/stack/registry/btst-comments.json @@ -33,12 +33,6 @@ "content": "import { z } from \"zod\";\n\nexport const CommentStatusSchema = z.enum([\"pending\", \"approved\", \"spam\"]);\n\n// ============ Comment Schemas ============\n\n/**\n * Schema for the POST /comments request body.\n * authorId is intentionally absent — the server resolves identity from the\n * session inside onBeforePost and injects it. Never trust authorId from the\n * client body.\n */\nexport const createCommentSchema = z.object({\n\tresourceId: z.string().min(1, \"Resource ID is required\"),\n\tresourceType: z.string().min(1, \"Resource type is required\"),\n\tparentId: z.string().optional().nullable(),\n\tbody: z.string().min(1, \"Body is required\").max(10000, \"Comment too long\"),\n});\n\n/**\n * Internal schema used after the authorId has been resolved server-side.\n * This is what gets passed to createComment() in mutations.ts.\n */\nexport const createCommentInternalSchema = createCommentSchema.extend({\n\tauthorId: z.string().min(1, \"Author ID is required\"),\n});\n\nexport const updateCommentSchema = z.object({\n\tbody: z.string().min(1, \"Body is required\").max(10000, \"Comment too long\"),\n});\n\nexport const updateCommentStatusSchema = z.object({\n\tstatus: CommentStatusSchema,\n});\n\n// ============ Query Schemas ============\n\n/**\n * Schema for GET /comments query parameters.\n *\n * `currentUserId` is intentionally absent — it is never accepted from the client.\n * The server always resolves the caller's identity via the `resolveCurrentUserId`\n * hook and injects it internally. Accepting it from the client would allow any\n * anonymous caller to supply an arbitrary user ID and read that user's pending\n * (pre-moderation) comments.\n */\nexport const CommentListQuerySchema = z.object({\n\tresourceId: z.string().optional(),\n\tresourceType: z.string().optional(),\n\tparentId: z.string().optional().nullable(),\n\tstatus: CommentStatusSchema.optional(),\n\tauthorId: z.string().optional(),\n\tsort: z.enum([\"asc\", \"desc\"]).optional(),\n\tlimit: z.coerce.number().int().min(1).max(100).optional(),\n\toffset: z.coerce.number().int().min(0).optional(),\n});\n\n/**\n * Internal params schema used by `listComments()` and the `api` factory.\n * Extends the HTTP query schema with `currentUserId`, which is always injected\n * server-side (either by the HTTP handler via `resolveCurrentUserId`, or by a\n * trusted server-side caller such as a Server Component or cron job).\n */\nexport const CommentListParamsSchema = CommentListQuerySchema.extend({\n\tcurrentUserId: z.string().optional(),\n});\n\nexport const CommentCountQuerySchema = z.object({\n\tresourceId: z.string().min(1),\n\tresourceType: z.string().min(1),\n\tstatus: CommentStatusSchema.optional(),\n});\n", "target": "src/components/btst/comments/schemas.ts" }, - { - "path": "btst/comments/error-utils.ts", - "type": "registry:lib", - "content": "/**\n * Normalize any thrown value into an Error.\n */\nexport function toError(error: unknown): Error {\n\tif (error instanceof Error) return error;\n\tif (typeof error === \"object\" && error !== null) {\n\t\tconst obj = error as Record;\n\t\tconst message =\n\t\t\t(typeof obj.message === \"string\" ? obj.message : null) ||\n\t\t\t(typeof obj.error === \"string\" ? obj.error : null) ||\n\t\t\tJSON.stringify(error);\n\t\tconst err = new Error(message);\n\t\tObject.assign(err, error);\n\t\treturn err;\n\t}\n\treturn new Error(String(error));\n}\n", - "target": "src/components/btst/comments/error-utils.ts" - }, { "path": "btst/comments/client/components/comment-count.tsx", "type": "registry:component", @@ -48,49 +42,49 @@ { "path": "btst/comments/client/components/comment-form.tsx", "type": "registry:component", - "content": "\"use client\";\n\nimport { useState, type ComponentType } from \"react\";\nimport { Button } from \"@/components/ui/button\";\nimport { Textarea } from \"@/components/ui/textarea\";\nimport {\n\tCOMMENTS_LOCALIZATION,\n\ttype CommentsLocalization,\n} from \"../localization\";\n\nexport interface CommentFormProps {\n\t/** Current user's ID — required to post */\n\tauthorId: string;\n\t/** Optional parent comment ID for replies */\n\tparentId?: string | null;\n\t/** Initial body value (for editing) */\n\tinitialBody?: string;\n\t/** Label for the submit button */\n\tsubmitLabel?: string;\n\t/** Called when form is submitted */\n\tonSubmit: (body: string) => Promise;\n\t/** Called when cancel is clicked (shows Cancel button when provided) */\n\tonCancel?: () => void;\n\t/** Custom input component — defaults to a plain Textarea */\n\tInputComponent?: ComponentType<{\n\t\tvalue: string;\n\t\tonChange: (value: string) => void;\n\t\tdisabled?: boolean;\n\t\tplaceholder?: string;\n\t}>;\n\t/** Localization strings */\n\tlocalization?: Partial;\n}\n\nexport function CommentForm({\n\tauthorId: _authorId,\n\tinitialBody = \"\",\n\tsubmitLabel,\n\tonSubmit,\n\tonCancel,\n\tInputComponent,\n\tlocalization: localizationProp,\n}: CommentFormProps) {\n\tconst loc = { ...COMMENTS_LOCALIZATION, ...localizationProp };\n\tconst [body, setBody] = useState(initialBody);\n\tconst [isPending, setIsPending] = useState(false);\n\tconst [error, setError] = useState(null);\n\n\tconst resolvedSubmitLabel = submitLabel ?? loc.COMMENTS_FORM_POST_COMMENT;\n\n\tconst handleSubmit = async (e: React.FormEvent) => {\n\t\te.preventDefault();\n\t\tif (!body.trim()) return;\n\t\tsetError(null);\n\t\tsetIsPending(true);\n\t\ttry {\n\t\t\tawait onSubmit(body.trim());\n\t\t\tsetBody(\"\");\n\t\t} catch (err) {\n\t\t\tsetError(\n\t\t\t\terr instanceof Error ? err.message : loc.COMMENTS_FORM_SUBMIT_ERROR,\n\t\t\t);\n\t\t} finally {\n\t\t\tsetIsPending(false);\n\t\t}\n\t};\n\n\treturn (\n\t\t\n\t\t\t{InputComponent ? (\n\t\t\t\t\n\t\t\t) : (\n\t\t\t\t setBody(e.target.value)}\n\t\t\t\t\tplaceholder={loc.COMMENTS_FORM_PLACEHOLDER}\n\t\t\t\t\tdisabled={isPending}\n\t\t\t\t\trows={3}\n\t\t\t\t\tclassName=\"resize-none\"\n\t\t\t\t/>\n\t\t\t)}\n\n\t\t\t{error &&

{error}

}\n\n\t\t\t
\n\t\t\t\t{onCancel && (\n\t\t\t\t\t\n\t\t\t\t\t\t{loc.COMMENTS_FORM_CANCEL}\n\t\t\t\t\t\n\t\t\t\t)}\n\t\t\t\t\n\t\t\t
\n\t\t\n\t);\n}\n", + "content": "\"use client\";\n\nimport { useState, type ComponentType } from \"react\";\nimport { Button } from \"@/components/ui/button\";\nimport { Textarea } from \"@/components/ui/textarea\";\nimport { useTranslate } from \"@btst/stack/context\";\nimport type { StackError } from \"@btst/stack/plugins/client\";\nimport type { CommentsLocalization } from \"../localization\";\n\nexport interface CommentFormProps {\n\t/** Current user's ID — required to post */\n\tauthorId: string;\n\t/** Optional parent comment ID for replies */\n\tparentId?: string | null;\n\t/** Initial body value (for editing) */\n\tinitialBody?: string;\n\t/** Label for the submit button */\n\tsubmitLabel?: string;\n\t/** Called when form is submitted */\n\tonSubmit: (body: string) => Promise;\n\t/** Called when cancel is clicked (shows Cancel button when provided) */\n\tonCancel?: () => void;\n\t/** Custom input component — defaults to a plain Textarea */\n\tInputComponent?: ComponentType<{\n\t\tvalue: string;\n\t\tonChange: (value: string) => void;\n\t\tdisabled?: boolean;\n\t\tplaceholder?: string;\n\t}>;\n\t/** Localization strings */\n\tlocalization?: Partial;\n}\n\nexport function CommentForm({\n\tauthorId: _authorId,\n\tinitialBody = \"\",\n\tsubmitLabel,\n\tonSubmit,\n\tonCancel,\n\tInputComponent,\n\tlocalization,\n}: CommentFormProps) {\n\tconst t = useTranslate();\n\tconst [body, setBody] = useState(initialBody);\n\tconst [isPending, setIsPending] = useState(false);\n\tconst [error, setError] = useState(null);\n\n\tconst resolvedSubmitLabel =\n\t\tsubmitLabel ??\n\t\tlocalization?.COMMENTS_FORM_POST_COMMENT ??\n\t\tt(\"comments.form.postComment\", \"Post comment\");\n\n\tconst handleSubmit = async (e: React.FormEvent) => {\n\t\te.preventDefault();\n\t\tif (!body.trim()) return;\n\t\tsetError(null);\n\t\tsetIsPending(true);\n\t\ttry {\n\t\t\tawait onSubmit(body.trim());\n\t\t\tsetBody(\"\");\n\t\t} catch (err) {\n\t\t\t// Server-side Zod failures arrive as a StackError with a field-error\n\t\t\t// map — surface the `body` message inline instead of the generic one.\n\t\t\tconst bodyError = (err as StackError)?.errors?.body;\n\t\t\tconst bodyMessage = Array.isArray(bodyError) ? bodyError[0] : bodyError;\n\t\t\tsetError(\n\t\t\t\tbodyMessage ??\n\t\t\t\t\t(err instanceof Error && err.message\n\t\t\t\t\t\t? err.message\n\t\t\t\t\t\t: (localization?.COMMENTS_FORM_SUBMIT_ERROR ??\n\t\t\t\t\t\t\tt(\"comments.form.submitError\", \"Failed to submit comment\"))),\n\t\t\t);\n\t\t} finally {\n\t\t\tsetIsPending(false);\n\t\t}\n\t};\n\n\treturn (\n\t\t\n\t\t\t{InputComponent ? (\n\t\t\t\t\n\t\t\t) : (\n\t\t\t\t setBody(e.target.value)}\n\t\t\t\t\tplaceholder={\n\t\t\t\t\t\tlocalization?.COMMENTS_FORM_PLACEHOLDER ??\n\t\t\t\t\t\tt(\"comments.form.placeholder\", \"Write a comment…\")\n\t\t\t\t\t}\n\t\t\t\t\tdisabled={isPending}\n\t\t\t\t\trows={3}\n\t\t\t\t\tclassName=\"resize-none\"\n\t\t\t\t/>\n\t\t\t)}\n\n\t\t\t{error && (\n\t\t\t\t\n\t\t\t\t\t{error}\n\t\t\t\t

\n\t\t\t)}\n\n\t\t\t
\n\t\t\t\t{onCancel && (\n\t\t\t\t\t\n\t\t\t\t\t\t{localization?.COMMENTS_FORM_CANCEL ??\n\t\t\t\t\t\t\tt(\"comments.form.cancel\", \"Cancel\")}\n\t\t\t\t\t\n\t\t\t\t)}\n\t\t\t\t\n\t\t\t
\n\t\t\n\t);\n}\n", "target": "src/components/btst/comments/client/components/comment-form.tsx" }, { "path": "btst/comments/client/components/comment-thread.tsx", "type": "registry:component", - "content": "\"use client\";\n\nimport { useEffect, useState, type ComponentType } from \"react\";\nimport { WhenVisible } from \"@/components/ui/when-visible\";\nimport {\n\tAvatar,\n\tAvatarFallback,\n\tAvatarImage,\n} from \"@/components/ui/avatar\";\nimport { Badge } from \"@/components/ui/badge\";\nimport { Button } from \"@/components/ui/button\";\nimport { Separator } from \"@/components/ui/separator\";\nimport {\n\tHeart,\n\tMessageSquare,\n\tPencil,\n\tX,\n\tLogIn,\n\tChevronDown,\n\tChevronUp,\n} from \"lucide-react\";\nimport { formatDistanceToNow } from \"date-fns\";\nimport type { SerializedComment } from \"../../types\";\nimport { getInitials } from \"../utils\";\nimport { CommentForm } from \"./comment-form\";\nimport {\n\tuseComments,\n\tuseInfiniteComments,\n\tusePostComment,\n\tuseUpdateComment,\n\tuseDeleteComment,\n\tuseToggleLike,\n} from \"@btst/stack/plugins/comments/client/hooks\";\nimport {\n\tCOMMENTS_LOCALIZATION,\n\ttype CommentsLocalization,\n} from \"../localization\";\nimport { usePluginOverrides } from \"@btst/stack/context\";\nimport type { CommentsPluginOverrides } from \"../overrides\";\n\n/** Custom input component props */\nexport interface CommentInputProps {\n\tvalue: string;\n\tonChange: (value: string) => void;\n\tdisabled?: boolean;\n\tplaceholder?: string;\n}\n\n/** Custom renderer component props */\nexport interface CommentRendererProps {\n\tbody: string;\n}\n\n/** Override slot for custom input + renderer */\nexport interface CommentComponents {\n\tInput?: ComponentType;\n\tRenderer?: ComponentType;\n}\n\nexport interface CommentThreadProps {\n\t/** The resource this thread is attached to (e.g. post slug, task ID) */\n\tresourceId: string;\n\t/** Discriminates resources across plugins (e.g. \"blog-post\", \"kanban-task\") */\n\tresourceType: string;\n\t/** Base URL for API calls */\n\tapiBaseURL: string;\n\t/** Path where the API is mounted */\n\tapiBasePath: string;\n\t/** Currently authenticated user ID. Omit for read-only / unauthenticated. */\n\tcurrentUserId?: string;\n\t/**\n\t * URL to redirect unauthenticated users to.\n\t * When provided and currentUserId is absent, shows a \"Please login to comment\" prompt.\n\t */\n\tloginHref?: string;\n\t/** Optional HTTP headers for API calls (e.g. forwarding cookies) */\n\theaders?: HeadersInit;\n\t/** Swap in custom Input / Renderer components */\n\tcomponents?: CommentComponents;\n\t/** Optional className applied to the root wrapper */\n\tclassName?: string;\n\t/** Localization strings — defaults to English */\n\tlocalization?: Partial;\n\t/**\n\t * Number of top-level comments to load per page.\n\t * Clicking \"Load more\" fetches the next page. Default: 10.\n\t */\n\tpageSize?: number;\n\t/**\n\t * When false, the comment form and reply buttons are hidden.\n\t * Overrides the global `allowPosting` from `CommentsPluginOverrides`.\n\t * Defaults to true.\n\t */\n\tallowPosting?: boolean;\n\t/**\n\t * When false, the edit button is hidden on comment cards.\n\t * Overrides the global `allowEditing` from `CommentsPluginOverrides`.\n\t * Defaults to true.\n\t */\n\tallowEditing?: boolean;\n\t/**\n\t * Sort direction for top-level comments by `createdAt`.\n\t * - `\"desc\"` (default): newest first.\n\t * - `\"asc\"`: oldest first.\n\t *\n\t * Replies inside each thread always render chronologically (oldest → newest)\n\t * and are unaffected by this prop.\n\t *\n\t * Overrides the global `defaultCommentSort` from `CommentsPluginOverrides`.\n\t */\n\tsort?: \"asc\" | \"desc\";\n}\n\nconst DEFAULT_RENDERER: ComponentType = ({ body }) => (\n\t

{body}

\n);\n\n// ─── Comment Card ─────────────────────────────────────────────────────────────\n\nfunction CommentCard({\n\tcomment,\n\tcurrentUserId,\n\tapiBaseURL,\n\tapiBasePath,\n\tresourceId,\n\tresourceType,\n\theaders,\n\tcomponents,\n\tloc,\n\tinfiniteKey,\n\tonReplyClick,\n\tallowPosting,\n\tallowEditing,\n}: {\n\tcomment: SerializedComment;\n\tcurrentUserId?: string;\n\tapiBaseURL: string;\n\tapiBasePath: string;\n\tresourceId: string;\n\tresourceType: string;\n\theaders?: HeadersInit;\n\tcomponents?: CommentComponents;\n\tloc: CommentsLocalization;\n\t/** Infinite thread query key — pass for top-level comments so like optimistic\n\t * updates target the correct InfiniteData cache entry. */\n\tinfiniteKey?: readonly unknown[];\n\tonReplyClick: (parentId: string) => void;\n\tallowPosting: boolean;\n\tallowEditing: boolean;\n}) {\n\tconst [isEditing, setIsEditing] = useState(false);\n\tconst Renderer = components?.Renderer ?? DEFAULT_RENDERER;\n\n\tconst config = { apiBaseURL, apiBasePath, headers };\n\n\tconst updateMutation = useUpdateComment(config);\n\tconst deleteMutation = useDeleteComment(config);\n\tconst toggleLikeMutation = useToggleLike(config, {\n\t\tresourceId,\n\t\tresourceType,\n\t\tparentId: comment.parentId,\n\t\tcurrentUserId,\n\t\tinfiniteKey,\n\t});\n\n\tconst isOwn = currentUserId && comment.authorId === currentUserId;\n\tconst isPending = comment.status === \"pending\";\n\tconst isApproved = comment.status === \"approved\";\n\n\tconst handleEdit = async (body: string) => {\n\t\tawait updateMutation.mutateAsync({ id: comment.id, body });\n\t\tsetIsEditing(false);\n\t};\n\n\tconst handleDelete = async () => {\n\t\tif (!window.confirm(loc.COMMENTS_DELETE_CONFIRM)) return;\n\t\tawait deleteMutation.mutateAsync(comment.id);\n\t};\n\n\tconst handleLike = () => {\n\t\tif (!currentUserId) return;\n\t\ttoggleLikeMutation.mutate({\n\t\t\tcommentId: comment.id,\n\t\t\tauthorId: currentUserId,\n\t\t});\n\t};\n\n\treturn (\n\t\t\n\t\t\t\n\t\t\t\t{comment.resolvedAvatarUrl && (\n\t\t\t\t\t\n\t\t\t\t)}\n\t\t\t\t\n\t\t\t\t\t{getInitials(comment.resolvedAuthorName)}\n\t\t\t\t\n\t\t\t\n\n\t\t\t
\n\t\t\t\t
\n\t\t\t\t\t\n\t\t\t\t\t\t{comment.resolvedAuthorName}\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\t{formatDistanceToNow(new Date(comment.createdAt), {\n\t\t\t\t\t\t\taddSuffix: true,\n\t\t\t\t\t\t})}\n\t\t\t\t\t\n\t\t\t\t\t{comment.editedAt && (\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t{loc.COMMENTS_EDITED_BADGE}\n\t\t\t\t\t\t\n\t\t\t\t\t)}\n\t\t\t\t\t{isPending && isOwn && (\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t{loc.COMMENTS_PENDING_BADGE}\n\t\t\t\t\t\t\n\t\t\t\t\t)}\n\t\t\t\t
\n\n\t\t\t\t{isEditing ? (\n\t\t\t\t\t setIsEditing(false)}\n\t\t\t\t\t/>\n\t\t\t\t) : (\n\t\t\t\t\t\n\t\t\t\t)}\n\n\t\t\t\t{!isEditing && (\n\t\t\t\t\t
\n\t\t\t\t\t\t{currentUserId && isApproved && (\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t{comment.likes > 0 && (\n\t\t\t\t\t\t\t\t\t{comment.likes}\n\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t)}\n\n\t\t\t\t\t\t{allowPosting &&\n\t\t\t\t\t\t\tcurrentUserId &&\n\t\t\t\t\t\t\t!comment.parentId &&\n\t\t\t\t\t\t\tisApproved && (\n\t\t\t\t\t\t\t\t onReplyClick(comment.id)}\n\t\t\t\t\t\t\t\t\tdata-testid=\"reply-button\"\n\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t{loc.COMMENTS_REPLY_BUTTON}\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t)}\n\n\t\t\t\t\t\t{isOwn && (\n\t\t\t\t\t\t\t<>\n\t\t\t\t\t\t\t\t{allowEditing && isApproved && (\n\t\t\t\t\t\t\t\t\t setIsEditing(true)}\n\t\t\t\t\t\t\t\t\t\tdata-testid=\"edit-button\"\n\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t{loc.COMMENTS_EDIT_BUTTON}\n\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t{loc.COMMENTS_DELETE_BUTTON}\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t)}\n\t\t\t\t\t
\n\t\t\t\t)}\n\t\t\t
\n\t\t\n\t);\n}\n\n// ─── Thread Inner (handles data) ──────────────────────────────────────────────\n\nconst DEFAULT_PAGE_SIZE = 100;\nconst REPLIES_PAGE_SIZE = 20;\nconst OPTIMISTIC_ID_PREFIX = \"optimistic-\";\n\nfunction CommentThreadInner({\n\tresourceId,\n\tresourceType,\n\tapiBaseURL,\n\tapiBasePath,\n\tcurrentUserId,\n\tloginHref,\n\theaders,\n\tcomponents,\n\tlocalization: localizationProp,\n\tpageSize: pageSizeProp,\n\tallowPosting: allowPostingProp,\n\tallowEditing: allowEditingProp,\n\tsort: sortProp,\n}: CommentThreadProps) {\n\tconst overrides = usePluginOverrides<\n\t\tCommentsPluginOverrides,\n\t\tPartial\n\t>(\"comments\", {});\n\tconst pageSize =\n\t\tpageSizeProp ?? overrides.defaultCommentPageSize ?? DEFAULT_PAGE_SIZE;\n\tconst allowPosting = allowPostingProp ?? overrides.allowPosting ?? true;\n\tconst allowEditing = allowEditingProp ?? overrides.allowEditing ?? true;\n\tconst sort = sortProp ?? overrides.defaultCommentSort ?? \"desc\";\n\tconst loc = { ...COMMENTS_LOCALIZATION, ...localizationProp };\n\tconst [replyingTo, setReplyingTo] = useState(null);\n\tconst [expandedReplies, setExpandedReplies] = useState>(\n\t\tnew Set(),\n\t);\n\tconst [replyOffsets, setReplyOffsets] = useState>({});\n\n\tconst config = { apiBaseURL, apiBasePath, headers };\n\n\tconst {\n\t\tcomments,\n\t\ttotal,\n\t\tisLoading,\n\t\tloadMore,\n\t\thasMore,\n\t\tisLoadingMore,\n\t\tqueryKey: threadQueryKey,\n\t} = useInfiniteComments(config, {\n\t\tresourceId,\n\t\tresourceType,\n\t\tstatus: \"approved\",\n\t\tparentId: null,\n\t\tcurrentUserId,\n\t\tsort,\n\t\tpageSize,\n\t});\n\n\tconst postMutation = usePostComment(config, {\n\t\tresourceId,\n\t\tresourceType,\n\t\tcurrentUserId,\n\t\tinfiniteKey: threadQueryKey,\n\t\tpageSize,\n\t\tsort,\n\t});\n\n\tconst handlePost = async (body: string) => {\n\t\tif (!currentUserId) return;\n\t\tawait postMutation.mutateAsync({\n\t\t\tbody,\n\t\t\tparentId: null,\n\t\t});\n\t};\n\n\tconst handleReply = async (body: string, parentId: string) => {\n\t\tif (!currentUserId) return;\n\t\tawait postMutation.mutateAsync({\n\t\t\tbody,\n\t\t\tparentId,\n\t\t\tlimit: REPLIES_PAGE_SIZE,\n\t\t\toffset: replyOffsets[parentId] ?? 0,\n\t\t});\n\t\tsetReplyingTo(null);\n\t\tsetExpandedReplies((prev) => new Set(prev).add(parentId));\n\t};\n\n\treturn (\n\t\t
\n\t\t\t
\n\t\t\t\t\n\t\t\t\t

\n\t\t\t\t\t{total === 0 ? loc.COMMENTS_TITLE : `${total} ${loc.COMMENTS_TITLE}`}\n\t\t\t\t

\n\t\t\t
\n\n\t\t\t{isLoading && (\n\t\t\t\t
\n\t\t\t\t\t{[1, 2].map((i) => (\n\t\t\t\t\t\t
\n\t\t\t\t\t\t\t
\n\t\t\t\t\t\t\t
\n\t\t\t\t\t\t\t\t
\n\t\t\t\t\t\t\t\t
\n\t\t\t\t\t\t\t\t
\n\t\t\t\t\t\t\t
\n\t\t\t\t\t\t
\n\t\t\t\t\t))}\n\t\t\t\t
\n\t\t\t)}\n\n\t\t\t{!isLoading && comments.length > 0 && (\n\t\t\t\t
\n\t\t\t\t\t{comments.map((comment) => (\n\t\t\t\t\t\t
\n\t\t\t\t\t\t\t {\n\t\t\t\t\t\t\t\t\tsetReplyingTo(replyingTo === parentId ? null : parentId);\n\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\tallowPosting={allowPosting}\n\t\t\t\t\t\t\t\tallowEditing={allowEditing}\n\t\t\t\t\t\t\t/>\n\n\t\t\t\t\t\t\t{/* Replies */}\n\t\t\t\t\t\t\t {\n\t\t\t\t\t\t\t\t\tconst isExpanded = expandedReplies.has(comment.id);\n\t\t\t\t\t\t\t\t\tif (!isExpanded) {\n\t\t\t\t\t\t\t\t\t\tsetReplyOffsets((prev) => {\n\t\t\t\t\t\t\t\t\t\t\tif ((prev[comment.id] ?? 0) === 0) return prev;\n\t\t\t\t\t\t\t\t\t\t\treturn { ...prev, [comment.id]: 0 };\n\t\t\t\t\t\t\t\t\t\t});\n\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\tsetExpandedReplies((prev) => {\n\t\t\t\t\t\t\t\t\t\tconst next = new Set(prev);\n\t\t\t\t\t\t\t\t\t\tnext.has(comment.id)\n\t\t\t\t\t\t\t\t\t\t\t? next.delete(comment.id)\n\t\t\t\t\t\t\t\t\t\t\t: next.add(comment.id);\n\t\t\t\t\t\t\t\t\t\treturn next;\n\t\t\t\t\t\t\t\t\t});\n\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\tonOffsetChange={(offset) => {\n\t\t\t\t\t\t\t\t\tsetReplyOffsets((prev) => {\n\t\t\t\t\t\t\t\t\t\tif (prev[comment.id] === offset) return prev;\n\t\t\t\t\t\t\t\t\t\treturn { ...prev, [comment.id]: offset };\n\t\t\t\t\t\t\t\t\t});\n\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\tallowEditing={allowEditing}\n\t\t\t\t\t\t\t/>\n\n\t\t\t\t\t\t\t{allowPosting && replyingTo === comment.id && currentUserId && (\n\t\t\t\t\t\t\t\t
\n\t\t\t\t\t\t\t\t\t handleReply(body, comment.id)}\n\t\t\t\t\t\t\t\t\t\tonCancel={() => setReplyingTo(null)}\n\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t
\n\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t
\n\t\t\t\t\t))}\n\t\t\t\t
\n\t\t\t)}\n\n\t\t\t{!isLoading && comments.length === 0 && (\n\t\t\t\t

\n\t\t\t\t\t{loc.COMMENTS_EMPTY}\n\t\t\t\t

\n\t\t\t)}\n\n\t\t\t{hasMore && (\n\t\t\t\t
\n\t\t\t\t\t loadMore()}\n\t\t\t\t\t\tdisabled={isLoadingMore}\n\t\t\t\t\t\tdata-testid=\"load-more-comments\"\n\t\t\t\t\t>\n\t\t\t\t\t\t{isLoadingMore ? loc.COMMENTS_LOADING_MORE : loc.COMMENTS_LOAD_MORE}\n\t\t\t\t\t\n\t\t\t\t
\n\t\t\t)}\n\n\t\t\t{allowPosting && (\n\t\t\t\t<>\n\t\t\t\t\t\n\n\t\t\t\t\t{currentUserId ? (\n\t\t\t\t\t\t
\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t
\n\t\t\t\t\t) : (\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t

\n\t\t\t\t\t\t\t\t{loc.COMMENTS_LOGIN_PROMPT}\n\t\t\t\t\t\t\t

\n\t\t\t\t\t\t\t{loginHref && (\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t{loc.COMMENTS_LOGIN_LINK}\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t
\n\t\t\t\t\t)}\n\t\t\t\t\n\t\t\t)}\n\t\t
\n\t);\n}\n\n// ─── Replies Section ───────────────────────────────────────────────────────────\n\nfunction RepliesSection({\n\tparentId,\n\tresourceId,\n\tresourceType,\n\tapiBaseURL,\n\tapiBasePath,\n\tcurrentUserId,\n\theaders,\n\tcomponents,\n\tloc,\n\texpanded,\n\treplyCount,\n\tonToggle,\n\tonOffsetChange,\n\tallowEditing,\n}: {\n\tparentId: string;\n\tresourceId: string;\n\tresourceType: string;\n\tapiBaseURL: string;\n\tapiBasePath: string;\n\tcurrentUserId?: string;\n\theaders?: HeadersInit;\n\tcomponents?: CommentComponents;\n\tloc: CommentsLocalization;\n\texpanded: boolean;\n\t/** Pre-computed from the parent comment — avoids an extra fetch on mount. */\n\treplyCount: number;\n\tonToggle: () => void;\n\tonOffsetChange: (offset: number) => void;\n\tallowEditing: boolean;\n}) {\n\tconst config = { apiBaseURL, apiBasePath, headers };\n\tconst [replyOffset, setReplyOffset] = useState(0);\n\tconst [loadedReplies, setLoadedReplies] = useState([]);\n\t// Only fetch reply bodies once the section is expanded.\n\tconst {\n\t\tcomments: repliesPage,\n\t\ttotal: repliesTotal,\n\t\tisFetching: isFetchingReplies,\n\t} = useComments(\n\t\tconfig,\n\t\t{\n\t\t\tresourceId,\n\t\t\tresourceType,\n\t\t\tparentId,\n\t\t\tstatus: \"approved\",\n\t\t\tcurrentUserId,\n\t\t\tlimit: REPLIES_PAGE_SIZE,\n\t\t\toffset: replyOffset,\n\t\t},\n\t\t{ enabled: expanded },\n\t);\n\n\tuseEffect(() => {\n\t\tif (expanded) {\n\t\t\tsetReplyOffset(0);\n\t\t\tsetLoadedReplies([]);\n\t\t}\n\t}, [expanded, parentId]);\n\n\tuseEffect(() => {\n\t\tonOffsetChange(replyOffset);\n\t}, [onOffsetChange, replyOffset]);\n\n\tuseEffect(() => {\n\t\tif (!expanded) return;\n\t\tsetLoadedReplies((prev) => {\n\t\t\tconst byId = new Map(prev.map((item) => [item.id, item]));\n\t\t\tfor (const reply of repliesPage) {\n\t\t\t\tbyId.set(reply.id, reply);\n\t\t\t}\n\n\t\t\t// Reconcile optimistic replies once the real server reply arrives with\n\t\t\t// a different id. Without this, both entries can persist in local state\n\t\t\t// until the section is collapsed and re-opened.\n\t\t\tconst currentPageIds = new Set(repliesPage.map((reply) => reply.id));\n\t\t\tconst currentPageRealReplies = repliesPage.filter(\n\t\t\t\t(reply) => !reply.id.startsWith(OPTIMISTIC_ID_PREFIX),\n\t\t\t);\n\n\t\t\treturn Array.from(byId.values()).filter((reply) => {\n\t\t\t\tif (!reply.id.startsWith(OPTIMISTIC_ID_PREFIX)) return true;\n\t\t\t\t// Keep optimistic items still present in the current cache page.\n\t\t\t\tif (currentPageIds.has(reply.id)) return true;\n\t\t\t\t// Drop stale optimistic rows that have been replaced by a real reply.\n\t\t\t\treturn !currentPageRealReplies.some(\n\t\t\t\t\t(realReply) =>\n\t\t\t\t\t\trealReply.parentId === reply.parentId &&\n\t\t\t\t\t\trealReply.authorId === reply.authorId &&\n\t\t\t\t\t\trealReply.body === reply.body,\n\t\t\t\t);\n\t\t\t});\n\t\t});\n\t}, [expanded, repliesPage]);\n\n\t// Hide when there are no known replies — but keep rendered when already\n\t// expanded so a freshly-posted first reply (which increments replyCount\n\t// only after the server responds) stays visible in the same session.\n\tif (replyCount === 0 && !expanded) return null;\n\n\t// Prefer the fetched count (accurate after optimistic inserts); fall back to\n\t// the server-provided replyCount before the fetch completes.\n\tconst displayCount = expanded\n\t\t? loadedReplies.length || replyCount\n\t\t: replyCount;\n\tconst effectiveReplyTotal = repliesTotal || replyCount;\n\tconst hasMoreReplies = loadedReplies.length < effectiveReplyTotal;\n\n\treturn (\n\t\t
\n\t\t\t{/* Toggle button — always at the top so collapse is reachable without scrolling */}\n\t\t\t\n\t\t\t\t{expanded ? (\n\t\t\t\t\t\n\t\t\t\t) : (\n\t\t\t\t\t\n\t\t\t\t)}\n\t\t\t\t{expanded\n\t\t\t\t\t? loc.COMMENTS_HIDE_REPLIES\n\t\t\t\t\t: `${displayCount} ${displayCount === 1 ? loc.COMMENTS_REPLIES_SINGULAR : loc.COMMENTS_REPLIES_PLURAL}`}\n\t\t\t\n\t\t\t{expanded && (\n\t\t\t\t\n\t\t\t\t\t{loadedReplies.map((reply) => (\n\t\t\t\t\t\t {}} // No nested replies in v1\n\t\t\t\t\t\t\tallowPosting={false}\n\t\t\t\t\t\t\tallowEditing={allowEditing}\n\t\t\t\t\t\t/>\n\t\t\t\t\t))}\n\t\t\t\t\t{hasMoreReplies && (\n\t\t\t\t\t\t
\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\tsetReplyOffset((prev) => prev + REPLIES_PAGE_SIZE)\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\tdisabled={isFetchingReplies}\n\t\t\t\t\t\t\t\tdata-testid=\"load-more-replies\"\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t{isFetchingReplies\n\t\t\t\t\t\t\t\t\t? loc.COMMENTS_LOADING_MORE\n\t\t\t\t\t\t\t\t\t: loc.COMMENTS_LOAD_MORE}\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t
\n\t\t\t\t\t)}\n\t\t\t\t
\n\t\t\t)}\n\t\t
\n\t);\n}\n\n// ─── Public export: lazy-mounts on scroll into view ───────────────────────────\n\n/**\n * Embeddable threaded comment section.\n *\n * Lazy-mounts when the component scrolls into the viewport (via WhenVisible).\n * Requires `currentUserId` to allow posting; shows a \"Please login\" prompt otherwise.\n *\n * @example\n * ```tsx\n * \n * ```\n */\nfunction CommentThreadSkeleton() {\n\treturn (\n\t\t
\n\t\t\t{/* Header */}\n\t\t\t
\n\t\t\t\t
\n\t\t\t\t
\n\t\t\t
\n\n\t\t\t{/* Comment rows */}\n\t\t\t{[1, 2, 3].map((i) => (\n\t\t\t\t
\n\t\t\t\t\t
\n\t\t\t\t\t
\n\t\t\t\t\t\t
\n\t\t\t\t\t\t\t
\n\t\t\t\t\t\t\t
\n\t\t\t\t\t\t
\n\t\t\t\t\t\t
\n\t\t\t\t\t\t
\n\t\t\t\t\t\t
\n\t\t\t\t\t\t\t
\n\t\t\t\t\t\t\t
\n\t\t\t\t\t\t
\n\t\t\t\t\t
\n\t\t\t\t
\n\t\t\t))}\n\n\t\t\t{/* Separator */}\n\t\t\t
\n\n\t\t\t{/* Textarea placeholder */}\n\t\t\t
\n\t\t\t\t
\n\t\t\t\t
\n\t\t\t\t\t
\n\t\t\t\t
\n\t\t\t
\n\t\t
\n\t);\n}\n\nexport function CommentThread(props: CommentThreadProps) {\n\treturn (\n\t\t
\n\t\t\t} rootMargin=\"300px\">\n\t\t\t\t\n\t\t\t\n\t\t
\n\t);\n}\n", + "content": "\"use client\";\n\nimport { useEffect, useState, type ComponentType } from \"react\";\nimport { WhenVisible } from \"@/components/ui/when-visible\";\nimport {\n\tAvatar,\n\tAvatarFallback,\n\tAvatarImage,\n} from \"@/components/ui/avatar\";\nimport { Badge } from \"@/components/ui/badge\";\nimport { Button } from \"@/components/ui/button\";\nimport { Separator } from \"@/components/ui/separator\";\nimport {\n\tHeart,\n\tMessageSquare,\n\tPencil,\n\tX,\n\tLogIn,\n\tChevronDown,\n\tChevronUp,\n} from \"lucide-react\";\nimport { formatDistanceToNow } from \"date-fns\";\nimport type { SerializedComment } from \"../../types\";\nimport { getInitials } from \"../utils\";\nimport { CommentForm } from \"./comment-form\";\nimport {\n\tuseComments,\n\tuseInfiniteComments,\n\tusePostComment,\n\tuseUpdateComment,\n\tuseDeleteComment,\n\tuseToggleLike,\n} from \"@btst/stack/plugins/comments/client/hooks\";\nimport type { CommentsLocalization } from \"../localization\";\nimport { usePluginOverrides, useTranslate } from \"@btst/stack/context\";\nimport type { CommentsPluginOverrides } from \"../overrides\";\n\n/** Custom input component props */\nexport interface CommentInputProps {\n\tvalue: string;\n\tonChange: (value: string) => void;\n\tdisabled?: boolean;\n\tplaceholder?: string;\n}\n\n/** Custom renderer component props */\nexport interface CommentRendererProps {\n\tbody: string;\n}\n\n/** Override slot for custom input + renderer */\nexport interface CommentComponents {\n\tInput?: ComponentType;\n\tRenderer?: ComponentType;\n}\n\nexport interface CommentThreadProps {\n\t/** The resource this thread is attached to (e.g. post slug, task ID) */\n\tresourceId: string;\n\t/** Discriminates resources across plugins (e.g. \"blog-post\", \"kanban-task\") */\n\tresourceType: string;\n\t/** Base URL for API calls */\n\tapiBaseURL: string;\n\t/** Path where the API is mounted */\n\tapiBasePath: string;\n\t/** Currently authenticated user ID. Omit for read-only / unauthenticated. */\n\tcurrentUserId?: string;\n\t/**\n\t * URL to redirect unauthenticated users to.\n\t * When provided and currentUserId is absent, shows a \"Please login to comment\" prompt.\n\t */\n\tloginHref?: string;\n\t/** Optional HTTP headers for API calls (e.g. forwarding cookies) */\n\theaders?: HeadersInit;\n\t/** Swap in custom Input / Renderer components */\n\tcomponents?: CommentComponents;\n\t/** Optional className applied to the root wrapper */\n\tclassName?: string;\n\t/** Localization strings — defaults to English */\n\tlocalization?: Partial;\n\t/**\n\t * Number of top-level comments to load per page.\n\t * Clicking \"Load more\" fetches the next page. Default: 10.\n\t */\n\tpageSize?: number;\n\t/**\n\t * When false, the comment form and reply buttons are hidden.\n\t * Overrides the global `allowPosting` from `CommentsPluginOverrides`.\n\t * Defaults to true.\n\t */\n\tallowPosting?: boolean;\n\t/**\n\t * When false, the edit button is hidden on comment cards.\n\t * Overrides the global `allowEditing` from `CommentsPluginOverrides`.\n\t * Defaults to true.\n\t */\n\tallowEditing?: boolean;\n\t/**\n\t * Sort direction for top-level comments by `createdAt`.\n\t * - `\"desc\"` (default): newest first.\n\t * - `\"asc\"`: oldest first.\n\t *\n\t * Replies inside each thread always render chronologically (oldest → newest)\n\t * and are unaffected by this prop.\n\t *\n\t * Overrides the global `defaultCommentSort` from `CommentsPluginOverrides`.\n\t */\n\tsort?: \"asc\" | \"desc\";\n}\n\nconst DEFAULT_RENDERER: ComponentType = ({ body }) => (\n\t

{body}

\n);\n\n// ─── Comment Card ─────────────────────────────────────────────────────────────\n\nfunction CommentCard({\n\tcomment,\n\tcurrentUserId,\n\tapiBaseURL,\n\tapiBasePath,\n\tresourceId,\n\tresourceType,\n\theaders,\n\tcomponents,\n\tlocalization,\n\tinfiniteKey,\n\tonReplyClick,\n\tallowPosting,\n\tallowEditing,\n}: {\n\tcomment: SerializedComment;\n\tcurrentUserId?: string;\n\tapiBaseURL: string;\n\tapiBasePath: string;\n\tresourceId: string;\n\tresourceType: string;\n\theaders?: HeadersInit;\n\tcomponents?: CommentComponents;\n\tlocalization?: Partial;\n\t/** Infinite thread query key — pass for top-level comments so like optimistic\n\t * updates target the correct InfiniteData cache entry. */\n\tinfiniteKey?: readonly unknown[];\n\tonReplyClick: (parentId: string) => void;\n\tallowPosting: boolean;\n\tallowEditing: boolean;\n}) {\n\tconst t = useTranslate();\n\tconst [isEditing, setIsEditing] = useState(false);\n\tconst Renderer = components?.Renderer ?? DEFAULT_RENDERER;\n\n\tconst config = { apiBaseURL, apiBasePath, headers };\n\n\tconst updateMutation = useUpdateComment(config);\n\tconst deleteMutation = useDeleteComment(config);\n\tconst toggleLikeMutation = useToggleLike(config, {\n\t\tresourceId,\n\t\tresourceType,\n\t\tparentId: comment.parentId,\n\t\tcurrentUserId,\n\t\tinfiniteKey,\n\t});\n\n\tconst isOwn = currentUserId && comment.authorId === currentUserId;\n\tconst isPending = comment.status === \"pending\";\n\tconst isApproved = comment.status === \"approved\";\n\n\tconst handleEdit = async (body: string) => {\n\t\tawait updateMutation.mutateAsync({ id: comment.id, body });\n\t\tsetIsEditing(false);\n\t};\n\n\tconst handleDelete = async () => {\n\t\tconst confirmMessage =\n\t\t\tlocalization?.COMMENTS_DELETE_CONFIRM ??\n\t\t\tt(\"comments.thread.deleteConfirm\", \"Delete this comment?\");\n\t\tif (!window.confirm(confirmMessage)) return;\n\t\tawait deleteMutation.mutateAsync(comment.id);\n\t};\n\n\tconst handleLike = () => {\n\t\tif (!currentUserId) return;\n\t\ttoggleLikeMutation.mutate({\n\t\t\tcommentId: comment.id,\n\t\t\tauthorId: currentUserId,\n\t\t});\n\t};\n\n\treturn (\n\t\t\n\t\t\t\n\t\t\t\t{comment.resolvedAvatarUrl && (\n\t\t\t\t\t\n\t\t\t\t)}\n\t\t\t\t\n\t\t\t\t\t{getInitials(comment.resolvedAuthorName)}\n\t\t\t\t\n\t\t\t\n\n\t\t\t
\n\t\t\t\t
\n\t\t\t\t\t\n\t\t\t\t\t\t{comment.resolvedAuthorName}\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\t{formatDistanceToNow(new Date(comment.createdAt), {\n\t\t\t\t\t\t\taddSuffix: true,\n\t\t\t\t\t\t})}\n\t\t\t\t\t\n\t\t\t\t\t{comment.editedAt && (\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t{localization?.COMMENTS_EDITED_BADGE ??\n\t\t\t\t\t\t\t\tt(\"comments.thread.editedBadge\", \"(edited)\")}\n\t\t\t\t\t\t\n\t\t\t\t\t)}\n\t\t\t\t\t{isPending && isOwn && (\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t{localization?.COMMENTS_PENDING_BADGE ??\n\t\t\t\t\t\t\t\tt(\"comments.thread.pendingBadge\", \"Pending approval\")}\n\t\t\t\t\t\t\n\t\t\t\t\t)}\n\t\t\t\t
\n\n\t\t\t\t{isEditing ? (\n\t\t\t\t\t setIsEditing(false)}\n\t\t\t\t\t/>\n\t\t\t\t) : (\n\t\t\t\t\t\n\t\t\t\t)}\n\n\t\t\t\t{!isEditing && (\n\t\t\t\t\t
\n\t\t\t\t\t\t{currentUserId && isApproved && (\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t{comment.likes > 0 && (\n\t\t\t\t\t\t\t\t\t{comment.likes}\n\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t)}\n\n\t\t\t\t\t\t{allowPosting &&\n\t\t\t\t\t\t\tcurrentUserId &&\n\t\t\t\t\t\t\t!comment.parentId &&\n\t\t\t\t\t\t\tisApproved && (\n\t\t\t\t\t\t\t\t onReplyClick(comment.id)}\n\t\t\t\t\t\t\t\t\tdata-testid=\"reply-button\"\n\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t{localization?.COMMENTS_REPLY_BUTTON ??\n\t\t\t\t\t\t\t\t\t\tt(\"comments.thread.replyButton\", \"Reply\")}\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t)}\n\n\t\t\t\t\t\t{isOwn && (\n\t\t\t\t\t\t\t<>\n\t\t\t\t\t\t\t\t{allowEditing && isApproved && (\n\t\t\t\t\t\t\t\t\t setIsEditing(true)}\n\t\t\t\t\t\t\t\t\t\tdata-testid=\"edit-button\"\n\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t{localization?.COMMENTS_EDIT_BUTTON ??\n\t\t\t\t\t\t\t\t\t\t\tt(\"comments.thread.editButton\", \"Edit\")}\n\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t{localization?.COMMENTS_DELETE_BUTTON ??\n\t\t\t\t\t\t\t\t\t\tt(\"comments.thread.deleteButton\", \"Delete\")}\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t)}\n\t\t\t\t\t
\n\t\t\t\t)}\n\t\t\t
\n\t\t
\n\t);\n}\n\n// ─── Thread Inner (handles data) ──────────────────────────────────────────────\n\nconst DEFAULT_PAGE_SIZE = 100;\nconst REPLIES_PAGE_SIZE = 20;\nconst OPTIMISTIC_ID_PREFIX = \"optimistic-\";\n\nfunction CommentThreadInner({\n\tresourceId,\n\tresourceType,\n\tapiBaseURL,\n\tapiBasePath,\n\tcurrentUserId,\n\tloginHref,\n\theaders,\n\tcomponents,\n\tlocalization: localizationProp,\n\tpageSize: pageSizeProp,\n\tallowPosting: allowPostingProp,\n\tallowEditing: allowEditingProp,\n\tsort: sortProp,\n}: CommentThreadProps) {\n\tconst t = useTranslate();\n\tconst overrides = usePluginOverrides<\n\t\tCommentsPluginOverrides,\n\t\tPartial\n\t>(\"comments\", {});\n\tconst pageSize =\n\t\tpageSizeProp ?? overrides.defaultCommentPageSize ?? DEFAULT_PAGE_SIZE;\n\tconst allowPosting = allowPostingProp ?? overrides.allowPosting ?? true;\n\tconst allowEditing = allowEditingProp ?? overrides.allowEditing ?? true;\n\tconst sort = sortProp ?? overrides.defaultCommentSort ?? \"desc\";\n\t// Per-instance prop wins over the plugin-level override strings; missing\n\t// keys fall through to `t()` inside each child component.\n\tconst localization = { ...overrides.localization, ...localizationProp };\n\tconst [replyingTo, setReplyingTo] = useState(null);\n\tconst [expandedReplies, setExpandedReplies] = useState>(\n\t\tnew Set(),\n\t);\n\tconst [replyOffsets, setReplyOffsets] = useState>({});\n\n\tconst config = { apiBaseURL, apiBasePath, headers };\n\n\tconst {\n\t\tcomments,\n\t\ttotal,\n\t\tisLoading,\n\t\tloadMore,\n\t\thasMore,\n\t\tisLoadingMore,\n\t\tqueryKey: threadQueryKey,\n\t} = useInfiniteComments(config, {\n\t\tresourceId,\n\t\tresourceType,\n\t\tstatus: \"approved\",\n\t\tparentId: null,\n\t\tcurrentUserId,\n\t\tsort,\n\t\tpageSize,\n\t});\n\n\tconst postMutation = usePostComment(config, {\n\t\tresourceId,\n\t\tresourceType,\n\t\tcurrentUserId,\n\t\tinfiniteKey: threadQueryKey,\n\t\tpageSize,\n\t\tsort,\n\t});\n\n\tconst handlePost = async (body: string) => {\n\t\tif (!currentUserId) return;\n\t\tawait postMutation.mutateAsync({\n\t\t\tbody,\n\t\t\tparentId: null,\n\t\t});\n\t};\n\n\tconst handleReply = async (body: string, parentId: string) => {\n\t\tif (!currentUserId) return;\n\t\tawait postMutation.mutateAsync({\n\t\t\tbody,\n\t\t\tparentId,\n\t\t\tlimit: REPLIES_PAGE_SIZE,\n\t\t\toffset: replyOffsets[parentId] ?? 0,\n\t\t});\n\t\tsetReplyingTo(null);\n\t\tsetExpandedReplies((prev) => new Set(prev).add(parentId));\n\t};\n\n\treturn (\n\t\t
\n\t\t\t
\n\t\t\t\t\n\t\t\t\t

\n\t\t\t\t\t{(() => {\n\t\t\t\t\t\tconst title =\n\t\t\t\t\t\t\tlocalization?.COMMENTS_TITLE ??\n\t\t\t\t\t\t\tt(\"comments.thread.title\", \"Comments\");\n\t\t\t\t\t\treturn total === 0 ? title : `${total} ${title}`;\n\t\t\t\t\t})()}\n\t\t\t\t

\n\t\t\t
\n\n\t\t\t{isLoading && (\n\t\t\t\t
\n\t\t\t\t\t{[1, 2].map((i) => (\n\t\t\t\t\t\t
\n\t\t\t\t\t\t\t
\n\t\t\t\t\t\t\t
\n\t\t\t\t\t\t\t\t
\n\t\t\t\t\t\t\t\t
\n\t\t\t\t\t\t\t\t
\n\t\t\t\t\t\t\t
\n\t\t\t\t\t\t
\n\t\t\t\t\t))}\n\t\t\t\t
\n\t\t\t)}\n\n\t\t\t{!isLoading && comments.length > 0 && (\n\t\t\t\t
\n\t\t\t\t\t{comments.map((comment) => (\n\t\t\t\t\t\t
\n\t\t\t\t\t\t\t {\n\t\t\t\t\t\t\t\t\tsetReplyingTo(replyingTo === parentId ? null : parentId);\n\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\tallowPosting={allowPosting}\n\t\t\t\t\t\t\t\tallowEditing={allowEditing}\n\t\t\t\t\t\t\t/>\n\n\t\t\t\t\t\t\t{/* Replies */}\n\t\t\t\t\t\t\t {\n\t\t\t\t\t\t\t\t\tconst isExpanded = expandedReplies.has(comment.id);\n\t\t\t\t\t\t\t\t\tif (!isExpanded) {\n\t\t\t\t\t\t\t\t\t\tsetReplyOffsets((prev) => {\n\t\t\t\t\t\t\t\t\t\t\tif ((prev[comment.id] ?? 0) === 0) return prev;\n\t\t\t\t\t\t\t\t\t\t\treturn { ...prev, [comment.id]: 0 };\n\t\t\t\t\t\t\t\t\t\t});\n\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\tsetExpandedReplies((prev) => {\n\t\t\t\t\t\t\t\t\t\tconst next = new Set(prev);\n\t\t\t\t\t\t\t\t\t\tnext.has(comment.id)\n\t\t\t\t\t\t\t\t\t\t\t? next.delete(comment.id)\n\t\t\t\t\t\t\t\t\t\t\t: next.add(comment.id);\n\t\t\t\t\t\t\t\t\t\treturn next;\n\t\t\t\t\t\t\t\t\t});\n\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\tonOffsetChange={(offset) => {\n\t\t\t\t\t\t\t\t\tsetReplyOffsets((prev) => {\n\t\t\t\t\t\t\t\t\t\tif (prev[comment.id] === offset) return prev;\n\t\t\t\t\t\t\t\t\t\treturn { ...prev, [comment.id]: offset };\n\t\t\t\t\t\t\t\t\t});\n\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\tallowEditing={allowEditing}\n\t\t\t\t\t\t\t/>\n\n\t\t\t\t\t\t\t{allowPosting && replyingTo === comment.id && currentUserId && (\n\t\t\t\t\t\t\t\t
\n\t\t\t\t\t\t\t\t\t handleReply(body, comment.id)}\n\t\t\t\t\t\t\t\t\t\tonCancel={() => setReplyingTo(null)}\n\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t
\n\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t
\n\t\t\t\t\t))}\n\t\t\t\t
\n\t\t\t)}\n\n\t\t\t{!isLoading && comments.length === 0 && (\n\t\t\t\t

\n\t\t\t\t\t{localization?.COMMENTS_EMPTY ??\n\t\t\t\t\t\tt(\"comments.thread.empty\", \"Be the first to comment.\")}\n\t\t\t\t

\n\t\t\t)}\n\n\t\t\t{hasMore && (\n\t\t\t\t
\n\t\t\t\t\t loadMore()}\n\t\t\t\t\t\tdisabled={isLoadingMore}\n\t\t\t\t\t\tdata-testid=\"load-more-comments\"\n\t\t\t\t\t>\n\t\t\t\t\t\t{isLoadingMore\n\t\t\t\t\t\t\t? (localization?.COMMENTS_LOADING_MORE ??\n\t\t\t\t\t\t\t\tt(\"comments.thread.loadingMore\", \"Loading…\"))\n\t\t\t\t\t\t\t: (localization?.COMMENTS_LOAD_MORE ??\n\t\t\t\t\t\t\t\tt(\"comments.thread.loadMore\", \"Load more comments\"))}\n\t\t\t\t\t\n\t\t\t\t
\n\t\t\t)}\n\n\t\t\t{allowPosting && (\n\t\t\t\t<>\n\t\t\t\t\t\n\n\t\t\t\t\t{currentUserId ? (\n\t\t\t\t\t\t
\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t
\n\t\t\t\t\t) : (\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t

\n\t\t\t\t\t\t\t\t{localization?.COMMENTS_LOGIN_PROMPT ??\n\t\t\t\t\t\t\t\t\tt(\n\t\t\t\t\t\t\t\t\t\t\"comments.thread.loginPrompt\",\n\t\t\t\t\t\t\t\t\t\t\"Please sign in to leave a comment.\",\n\t\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t

\n\t\t\t\t\t\t\t{loginHref && (\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t{localization?.COMMENTS_LOGIN_LINK ??\n\t\t\t\t\t\t\t\t\t\tt(\"comments.thread.loginLink\", \"Sign in\")}\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t
\n\t\t\t\t\t)}\n\t\t\t\t\n\t\t\t)}\n\t\t
\n\t);\n}\n\n// ─── Replies Section ───────────────────────────────────────────────────────────\n\nfunction RepliesSection({\n\tparentId,\n\tresourceId,\n\tresourceType,\n\tapiBaseURL,\n\tapiBasePath,\n\tcurrentUserId,\n\theaders,\n\tcomponents,\n\tlocalization,\n\texpanded,\n\treplyCount,\n\tonToggle,\n\tonOffsetChange,\n\tallowEditing,\n}: {\n\tparentId: string;\n\tresourceId: string;\n\tresourceType: string;\n\tapiBaseURL: string;\n\tapiBasePath: string;\n\tcurrentUserId?: string;\n\theaders?: HeadersInit;\n\tcomponents?: CommentComponents;\n\tlocalization?: Partial;\n\texpanded: boolean;\n\t/** Pre-computed from the parent comment — avoids an extra fetch on mount. */\n\treplyCount: number;\n\tonToggle: () => void;\n\tonOffsetChange: (offset: number) => void;\n\tallowEditing: boolean;\n}) {\n\tconst t = useTranslate();\n\tconst config = { apiBaseURL, apiBasePath, headers };\n\tconst [replyOffset, setReplyOffset] = useState(0);\n\tconst [loadedReplies, setLoadedReplies] = useState([]);\n\t// Only fetch reply bodies once the section is expanded.\n\tconst {\n\t\tcomments: repliesPage,\n\t\ttotal: repliesTotal,\n\t\tisFetching: isFetchingReplies,\n\t} = useComments(\n\t\tconfig,\n\t\t{\n\t\t\tresourceId,\n\t\t\tresourceType,\n\t\t\tparentId,\n\t\t\tstatus: \"approved\",\n\t\t\tcurrentUserId,\n\t\t\tlimit: REPLIES_PAGE_SIZE,\n\t\t\toffset: replyOffset,\n\t\t},\n\t\t{ enabled: expanded },\n\t);\n\n\tuseEffect(() => {\n\t\tif (expanded) {\n\t\t\tsetReplyOffset(0);\n\t\t\tsetLoadedReplies([]);\n\t\t}\n\t}, [expanded, parentId]);\n\n\tuseEffect(() => {\n\t\tonOffsetChange(replyOffset);\n\t}, [onOffsetChange, replyOffset]);\n\n\tuseEffect(() => {\n\t\tif (!expanded) return;\n\t\tsetLoadedReplies((prev) => {\n\t\t\tconst byId = new Map(prev.map((item) => [item.id, item]));\n\t\t\tfor (const reply of repliesPage) {\n\t\t\t\tbyId.set(reply.id, reply);\n\t\t\t}\n\n\t\t\t// Reconcile optimistic replies once the real server reply arrives with\n\t\t\t// a different id. Without this, both entries can persist in local state\n\t\t\t// until the section is collapsed and re-opened.\n\t\t\tconst currentPageIds = new Set(repliesPage.map((reply) => reply.id));\n\t\t\tconst currentPageRealReplies = repliesPage.filter(\n\t\t\t\t(reply) => !reply.id.startsWith(OPTIMISTIC_ID_PREFIX),\n\t\t\t);\n\n\t\t\treturn Array.from(byId.values()).filter((reply) => {\n\t\t\t\tif (!reply.id.startsWith(OPTIMISTIC_ID_PREFIX)) return true;\n\t\t\t\t// Keep optimistic items still present in the current cache page.\n\t\t\t\tif (currentPageIds.has(reply.id)) return true;\n\t\t\t\t// Drop stale optimistic rows that have been replaced by a real reply.\n\t\t\t\treturn !currentPageRealReplies.some(\n\t\t\t\t\t(realReply) =>\n\t\t\t\t\t\trealReply.parentId === reply.parentId &&\n\t\t\t\t\t\trealReply.authorId === reply.authorId &&\n\t\t\t\t\t\trealReply.body === reply.body,\n\t\t\t\t);\n\t\t\t});\n\t\t});\n\t}, [expanded, repliesPage]);\n\n\t// Hide when there are no known replies — but keep rendered when already\n\t// expanded so a freshly-posted first reply (which increments replyCount\n\t// only after the server responds) stays visible in the same session.\n\tif (replyCount === 0 && !expanded) return null;\n\n\t// Prefer the fetched count (accurate after optimistic inserts); fall back to\n\t// the server-provided replyCount before the fetch completes.\n\tconst displayCount = expanded\n\t\t? loadedReplies.length || replyCount\n\t\t: replyCount;\n\tconst effectiveReplyTotal = repliesTotal || replyCount;\n\tconst hasMoreReplies = loadedReplies.length < effectiveReplyTotal;\n\n\treturn (\n\t\t
\n\t\t\t{/* Toggle button — always at the top so collapse is reachable without scrolling */}\n\t\t\t\n\t\t\t\t{expanded ? (\n\t\t\t\t\t\n\t\t\t\t) : (\n\t\t\t\t\t\n\t\t\t\t)}\n\t\t\t\t{expanded\n\t\t\t\t\t? (localization?.COMMENTS_HIDE_REPLIES ??\n\t\t\t\t\t\tt(\"comments.thread.hideReplies\", \"Hide replies\"))\n\t\t\t\t\t: `${displayCount} ${\n\t\t\t\t\t\t\tdisplayCount === 1\n\t\t\t\t\t\t\t\t? (localization?.COMMENTS_REPLIES_SINGULAR ??\n\t\t\t\t\t\t\t\t\tt(\"comments.thread.repliesSingular\", \"reply\"))\n\t\t\t\t\t\t\t\t: (localization?.COMMENTS_REPLIES_PLURAL ??\n\t\t\t\t\t\t\t\t\tt(\"comments.thread.repliesPlural\", \"replies\"))\n\t\t\t\t\t\t}`}\n\t\t\t\n\t\t\t{expanded && (\n\t\t\t\t\n\t\t\t\t\t{loadedReplies.map((reply) => (\n\t\t\t\t\t\t {}} // No nested replies in v1\n\t\t\t\t\t\t\tallowPosting={false}\n\t\t\t\t\t\t\tallowEditing={allowEditing}\n\t\t\t\t\t\t/>\n\t\t\t\t\t))}\n\t\t\t\t\t{hasMoreReplies && (\n\t\t\t\t\t\t
\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\tsetReplyOffset((prev) => prev + REPLIES_PAGE_SIZE)\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\tdisabled={isFetchingReplies}\n\t\t\t\t\t\t\t\tdata-testid=\"load-more-replies\"\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t{isFetchingReplies\n\t\t\t\t\t\t\t\t\t? (localization?.COMMENTS_LOADING_MORE ??\n\t\t\t\t\t\t\t\t\t\tt(\"comments.thread.loadingMore\", \"Loading…\"))\n\t\t\t\t\t\t\t\t\t: (localization?.COMMENTS_LOAD_MORE ??\n\t\t\t\t\t\t\t\t\t\tt(\"comments.thread.loadMore\", \"Load more comments\"))}\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t
\n\t\t\t\t\t)}\n\t\t\t\t
\n\t\t\t)}\n\t\t
\n\t);\n}\n\n// ─── Public export: lazy-mounts on scroll into view ───────────────────────────\n\n/**\n * Embeddable threaded comment section.\n *\n * Lazy-mounts when the component scrolls into the viewport (via WhenVisible).\n * Requires `currentUserId` to allow posting; shows a \"Please login\" prompt otherwise.\n *\n * @example\n * ```tsx\n * \n * ```\n */\nfunction CommentThreadSkeleton() {\n\treturn (\n\t\t
\n\t\t\t{/* Header */}\n\t\t\t
\n\t\t\t\t
\n\t\t\t\t
\n\t\t\t
\n\n\t\t\t{/* Comment rows */}\n\t\t\t{[1, 2, 3].map((i) => (\n\t\t\t\t
\n\t\t\t\t\t
\n\t\t\t\t\t
\n\t\t\t\t\t\t
\n\t\t\t\t\t\t\t
\n\t\t\t\t\t\t\t
\n\t\t\t\t\t\t
\n\t\t\t\t\t\t
\n\t\t\t\t\t\t
\n\t\t\t\t\t\t
\n\t\t\t\t\t\t\t
\n\t\t\t\t\t\t\t
\n\t\t\t\t\t\t
\n\t\t\t\t\t
\n\t\t\t\t
\n\t\t\t))}\n\n\t\t\t{/* Separator */}\n\t\t\t
\n\n\t\t\t{/* Textarea placeholder */}\n\t\t\t
\n\t\t\t\t
\n\t\t\t\t
\n\t\t\t\t\t
\n\t\t\t\t
\n\t\t\t
\n\t\t
\n\t);\n}\n\nexport function CommentThread(props: CommentThreadProps) {\n\treturn (\n\t\t
\n\t\t\t} rootMargin=\"300px\">\n\t\t\t\t\n\t\t\t\n\t\t
\n\t);\n}\n", "target": "src/components/btst/comments/client/components/comment-thread.tsx" }, { "path": "btst/comments/client/components/pages/moderation-page.internal.tsx", "type": "registry:component", - "content": "\"use client\";\n\nimport { useState } from \"react\";\nimport {\n\tTable,\n\tTableBody,\n\tTableCell,\n\tTableHead,\n\tTableHeader,\n\tTableRow,\n} from \"@/components/ui/table\";\nimport {\n\tDialog,\n\tDialogContent,\n\tDialogHeader,\n\tDialogTitle,\n} from \"@/components/ui/dialog\";\nimport {\n\tAlertDialog,\n\tAlertDialogAction,\n\tAlertDialogCancel,\n\tAlertDialogContent,\n\tAlertDialogDescription,\n\tAlertDialogFooter,\n\tAlertDialogHeader,\n\tAlertDialogTitle,\n} from \"@/components/ui/alert-dialog\";\nimport { Button } from \"@/components/ui/button\";\nimport { Badge } from \"@/components/ui/badge\";\nimport { Tabs, TabsList, TabsTrigger } from \"@/components/ui/tabs\";\nimport { Checkbox } from \"@/components/ui/checkbox\";\nimport {\n\tAvatar,\n\tAvatarFallback,\n\tAvatarImage,\n} from \"@/components/ui/avatar\";\nimport { CheckCircle, ShieldOff, Trash2, Eye } from \"lucide-react\";\nimport { toast } from \"sonner\";\nimport { formatDistanceToNow } from \"date-fns\";\nimport { useRegisterPageAIContext } from \"@btst/stack/plugins/ai-chat/client/context\";\nimport type { SerializedComment, CommentStatus } from \"../../../types\";\nimport {\n\tuseSuspenseModerationComments,\n\tuseUpdateCommentStatus,\n\tuseDeleteComment,\n} from \"@btst/stack/plugins/comments/client/hooks\";\nimport {\n\tCOMMENTS_LOCALIZATION,\n\ttype CommentsLocalization,\n} from \"../../localization\";\nimport { getInitials } from \"../../utils\";\nimport { Pagination } from \"../shared/pagination\";\n\ninterface ModerationPageProps {\n\tapiBaseURL: string;\n\tapiBasePath: string;\n\theaders?: HeadersInit;\n\tlocalization?: CommentsLocalization;\n}\n\nfunction StatusBadge({ status }: { status: CommentStatus }) {\n\tconst variants: Record<\n\t\tCommentStatus,\n\t\t\"secondary\" | \"default\" | \"destructive\"\n\t> = {\n\t\tpending: \"secondary\",\n\t\tapproved: \"default\",\n\t\tspam: \"destructive\",\n\t};\n\treturn {status};\n}\n\nexport function ModerationPage({\n\tapiBaseURL,\n\tapiBasePath,\n\theaders,\n\tlocalization: localizationProp,\n}: ModerationPageProps) {\n\tconst loc = { ...COMMENTS_LOCALIZATION, ...localizationProp };\n\tconst [activeTab, setActiveTab] = useState(\"pending\");\n\tconst [currentPage, setCurrentPage] = useState(1);\n\tconst [selected, setSelected] = useState>(new Set());\n\tconst [viewComment, setViewComment] = useState(\n\t\tnull,\n\t);\n\tconst [deleteIds, setDeleteIds] = useState([]);\n\n\tconst config = { apiBaseURL, apiBasePath, headers };\n\n\tconst { comments, total, limit, offset, totalPages, refetch } =\n\t\tuseSuspenseModerationComments(config, {\n\t\t\tstatus: activeTab,\n\t\t\tpage: currentPage,\n\t\t});\n\n\tconst updateStatus = useUpdateCommentStatus(config);\n\tconst deleteMutation = useDeleteComment(config);\n\n\t// Register AI context with pending comment previews\n\tuseRegisterPageAIContext({\n\t\trouteName: \"comments-moderation\",\n\t\tpageDescription: `${total} ${activeTab} comments in the moderation queue.\\n\\nTop ${activeTab} comments:\\n${comments\n\t\t\t.slice(0, 5)\n\t\t\t.map(\n\t\t\t\t(c) =>\n\t\t\t\t\t`- \"${c.body.slice(0, 80)}${c.body.length > 80 ? \"…\" : \"\"}\" by ${c.resolvedAuthorName} on ${c.resourceType}/${c.resourceId}`,\n\t\t\t)\n\t\t\t.join(\"\\n\")}`,\n\t\tsuggestions: [\n\t\t\t\"Approve all safe-looking comments\",\n\t\t\t\"Flag spam comments\",\n\t\t\t\"Summarize today's discussion\",\n\t\t],\n\t});\n\n\tconst toggleSelect = (id: string) => {\n\t\tsetSelected((prev) => {\n\t\t\tconst next = new Set(prev);\n\t\t\tnext.has(id) ? next.delete(id) : next.add(id);\n\t\t\treturn next;\n\t\t});\n\t};\n\n\tconst toggleSelectAll = () => {\n\t\tif (selected.size === comments.length) {\n\t\t\tsetSelected(new Set());\n\t\t} else {\n\t\t\tsetSelected(new Set(comments.map((c) => c.id)));\n\t\t}\n\t};\n\n\tconst handleApprove = async (id: string) => {\n\t\ttry {\n\t\t\tawait updateStatus.mutateAsync({ id, status: \"approved\" });\n\t\t\ttoast.success(loc.COMMENTS_MODERATION_TOAST_APPROVED);\n\t\t\tawait refetch();\n\t\t} catch {\n\t\t\ttoast.error(loc.COMMENTS_MODERATION_TOAST_APPROVE_ERROR);\n\t\t}\n\t};\n\n\tconst handleSpam = async (id: string) => {\n\t\ttry {\n\t\t\tawait updateStatus.mutateAsync({ id, status: \"spam\" });\n\t\t\ttoast.success(loc.COMMENTS_MODERATION_TOAST_SPAM);\n\t\t\tawait refetch();\n\t\t} catch {\n\t\t\ttoast.error(loc.COMMENTS_MODERATION_TOAST_SPAM_ERROR);\n\t\t}\n\t};\n\n\tconst handleDelete = async (ids: string[]) => {\n\t\ttry {\n\t\t\tawait Promise.all(ids.map((id) => deleteMutation.mutateAsync(id)));\n\t\t\ttoast.success(\n\t\t\t\tids.length === 1\n\t\t\t\t\t? loc.COMMENTS_MODERATION_TOAST_DELETED\n\t\t\t\t\t: loc.COMMENTS_MODERATION_TOAST_DELETED_PLURAL.replace(\n\t\t\t\t\t\t\t\"{n}\",\n\t\t\t\t\t\t\tString(ids.length),\n\t\t\t\t\t\t),\n\t\t\t);\n\t\t\tsetSelected(new Set());\n\t\t\tsetDeleteIds([]);\n\t\t\tawait refetch();\n\t\t} catch {\n\t\t\ttoast.error(loc.COMMENTS_MODERATION_TOAST_DELETE_ERROR);\n\t\t}\n\t};\n\n\tconst handleBulkApprove = async () => {\n\t\tconst ids = [...selected];\n\t\ttry {\n\t\t\tawait Promise.all(\n\t\t\t\tids.map((id) => updateStatus.mutateAsync({ id, status: \"approved\" })),\n\t\t\t);\n\t\t\ttoast.success(\n\t\t\t\tloc.COMMENTS_MODERATION_TOAST_BULK_APPROVED.replace(\n\t\t\t\t\t\"{n}\",\n\t\t\t\t\tString(ids.length),\n\t\t\t\t),\n\t\t\t);\n\t\t\tsetSelected(new Set());\n\t\t\tawait refetch();\n\t\t} catch {\n\t\t\ttoast.error(loc.COMMENTS_MODERATION_TOAST_BULK_APPROVE_ERROR);\n\t\t}\n\t};\n\n\treturn (\n\t\t
\n\t\t\t
\n\t\t\t\t

{loc.COMMENTS_MODERATION_TITLE}

\n\t\t\t\t

\n\t\t\t\t\t{loc.COMMENTS_MODERATION_DESCRIPTION}\n\t\t\t\t

\n\t\t\t
\n\n\t\t\t {\n\t\t\t\t\tsetActiveTab(v as CommentStatus);\n\t\t\t\t\tsetCurrentPage(1);\n\t\t\t\t\tsetSelected(new Set());\n\t\t\t\t}}\n\t\t\t>\n\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\t{loc.COMMENTS_MODERATION_TAB_PENDING}\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\t{loc.COMMENTS_MODERATION_TAB_APPROVED}\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\t{loc.COMMENTS_MODERATION_TAB_SPAM}\n\t\t\t\t\t\n\t\t\t\t\n\t\t\t\n\n\t\t\t{/* Bulk actions toolbar */}\n\t\t\t{selected.size > 0 && (\n\t\t\t\t
\n\t\t\t\t\t\n\t\t\t\t\t\t{loc.COMMENTS_MODERATION_SELECTED.replace(\n\t\t\t\t\t\t\t\"{n}\",\n\t\t\t\t\t\t\tString(selected.size),\n\t\t\t\t\t\t)}\n\t\t\t\t\t\n\t\t\t\t\t{activeTab !== \"approved\" && (\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t{loc.COMMENTS_MODERATION_APPROVE_SELECTED}\n\t\t\t\t\t\t\n\t\t\t\t\t)}\n\t\t\t\t\t setDeleteIds([...selected])}\n\t\t\t\t\t>\n\t\t\t\t\t\t\n\t\t\t\t\t\t{loc.COMMENTS_MODERATION_DELETE_SELECTED}\n\t\t\t\t\t\n\t\t\t\t
\n\t\t\t)}\n\n\t\t\t{comments.length === 0 ? (\n\t\t\t\t
\n\t\t\t\t\t\n\t\t\t\t\t

\n\t\t\t\t\t\t{loc.COMMENTS_MODERATION_EMPTY.replace(\"{status}\", activeTab)}\n\t\t\t\t\t

\n\t\t\t\t
\n\t\t\t) : (\n\t\t\t\t<>\n\t\t\t\t\t
\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t 0\n\t\t\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\t\t\tonCheckedChange={toggleSelectAll}\n\t\t\t\t\t\t\t\t\t\t\taria-label={loc.COMMENTS_MODERATION_SELECT_ALL}\n\t\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t{loc.COMMENTS_MODERATION_COL_AUTHOR}\n\t\t\t\t\t\t\t\t\t{loc.COMMENTS_MODERATION_COL_COMMENT}\n\t\t\t\t\t\t\t\t\t{loc.COMMENTS_MODERATION_COL_RESOURCE}\n\t\t\t\t\t\t\t\t\t{loc.COMMENTS_MODERATION_COL_DATE}\n\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t{loc.COMMENTS_MODERATION_COL_ACTIONS}\n\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t{comments.map((comment) => (\n\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t toggleSelect(comment.id)}\n\t\t\t\t\t\t\t\t\t\t\t\taria-label={loc.COMMENTS_MODERATION_SELECT_ONE}\n\t\t\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t
\n\t\t\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t\t\t{comment.resolvedAvatarUrl && (\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t{getInitials(comment.resolvedAuthorName)}\n\t\t\t\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t\t\t{comment.resolvedAuthorName}\n\t\t\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t
\n\t\t\t\t\t\t\t\t\t\t
\n\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t

\n\t\t\t\t\t\t\t\t\t\t\t\t{comment.body}\n\t\t\t\t\t\t\t\t\t\t\t

\n\t\t\t\t\t\t\t\t\t\t
\n\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t\t{comment.resourceType}/{comment.resourceId}\n\t\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t{formatDistanceToNow(new Date(comment.createdAt), {\n\t\t\t\t\t\t\t\t\t\t\t\taddSuffix: true,\n\t\t\t\t\t\t\t\t\t\t\t})}\n\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t
\n\t\t\t\t\t\t\t\t\t\t\t\t setViewComment(comment)}\n\t\t\t\t\t\t\t\t\t\t\t\t\tdata-testid=\"view-button\"\n\t\t\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t\t{activeTab !== \"approved\" && (\n\t\t\t\t\t\t\t\t\t\t\t\t\t handleApprove(comment.id)}\n\t\t\t\t\t\t\t\t\t\t\t\t\t\tdisabled={updateStatus.isPending}\n\t\t\t\t\t\t\t\t\t\t\t\t\t\tdata-testid=\"approve-button\"\n\t\t\t\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t\t\t\t\t{activeTab !== \"spam\" && (\n\t\t\t\t\t\t\t\t\t\t\t\t\t handleSpam(comment.id)}\n\t\t\t\t\t\t\t\t\t\t\t\t\t\tdisabled={updateStatus.isPending}\n\t\t\t\t\t\t\t\t\t\t\t\t\t\tdata-testid=\"spam-button\"\n\t\t\t\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t\t\t\t\t setDeleteIds([comment.id])}\n\t\t\t\t\t\t\t\t\t\t\t\t\tdata-testid=\"delete-button\"\n\t\t\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t
\n\t\t\t\t\t\t\t\t\t\t
\n\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t))}\n\t\t\t\t\t\t\t
\n\t\t\t\t\t\t
\n\t\t\t\t\t
\n\t\t\t\t\t\n\t\t\t\t\n\t\t\t)}\n\n\t\t\t{/* View comment dialog */}\n\t\t\t setViewComment(null)}>\n\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\t{loc.COMMENTS_MODERATION_DIALOG_TITLE}\n\t\t\t\t\t\n\t\t\t\t\t{viewComment && (\n\t\t\t\t\t\t
\n\t\t\t\t\t\t\t
\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t{viewComment.resolvedAvatarUrl && (\n\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t{getInitials(viewComment.resolvedAuthorName)}\n\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t
\n\t\t\t\t\t\t\t\t\t

\n\t\t\t\t\t\t\t\t\t\t{viewComment.resolvedAuthorName}\n\t\t\t\t\t\t\t\t\t

\n\t\t\t\t\t\t\t\t\t

\n\t\t\t\t\t\t\t\t\t\t{new Date(viewComment.createdAt).toLocaleString()}\n\t\t\t\t\t\t\t\t\t

\n\t\t\t\t\t\t\t\t
\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t
\n\n\t\t\t\t\t\t\t
\n\t\t\t\t\t\t\t\t
\n\t\t\t\t\t\t\t\t\t

\n\t\t\t\t\t\t\t\t\t\t{loc.COMMENTS_MODERATION_DIALOG_RESOURCE}\n\t\t\t\t\t\t\t\t\t

\n\t\t\t\t\t\t\t\t\t

\n\t\t\t\t\t\t\t\t\t\t{viewComment.resourceType}/{viewComment.resourceId}\n\t\t\t\t\t\t\t\t\t

\n\t\t\t\t\t\t\t\t
\n\t\t\t\t\t\t\t\t
\n\t\t\t\t\t\t\t\t\t

\n\t\t\t\t\t\t\t\t\t\t{loc.COMMENTS_MODERATION_DIALOG_LIKES}\n\t\t\t\t\t\t\t\t\t

\n\t\t\t\t\t\t\t\t\t

{viewComment.likes}

\n\t\t\t\t\t\t\t\t
\n\t\t\t\t\t\t\t\t{viewComment.parentId && (\n\t\t\t\t\t\t\t\t\t
\n\t\t\t\t\t\t\t\t\t\t

\n\t\t\t\t\t\t\t\t\t\t\t{loc.COMMENTS_MODERATION_DIALOG_REPLY_TO}\n\t\t\t\t\t\t\t\t\t\t

\n\t\t\t\t\t\t\t\t\t\t

{viewComment.parentId}

\n\t\t\t\t\t\t\t\t\t
\n\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t{viewComment.editedAt && (\n\t\t\t\t\t\t\t\t\t
\n\t\t\t\t\t\t\t\t\t\t

\n\t\t\t\t\t\t\t\t\t\t\t{loc.COMMENTS_MODERATION_DIALOG_EDITED}\n\t\t\t\t\t\t\t\t\t\t

\n\t\t\t\t\t\t\t\t\t\t

\n\t\t\t\t\t\t\t\t\t\t\t{new Date(viewComment.editedAt).toLocaleString()}\n\t\t\t\t\t\t\t\t\t\t

\n\t\t\t\t\t\t\t\t\t
\n\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t
\n\n\t\t\t\t\t\t\t
\n\t\t\t\t\t\t\t\t

\n\t\t\t\t\t\t\t\t\t{loc.COMMENTS_MODERATION_DIALOG_BODY}\n\t\t\t\t\t\t\t\t

\n\t\t\t\t\t\t\t\t
\n\t\t\t\t\t\t\t\t\t{viewComment.body}\n\t\t\t\t\t\t\t\t
\n\t\t\t\t\t\t\t
\n\n\t\t\t\t\t\t\t
\n\t\t\t\t\t\t\t\t{viewComment.status !== \"approved\" && (\n\t\t\t\t\t\t\t\t\t {\n\t\t\t\t\t\t\t\t\t\t\tawait handleApprove(viewComment.id);\n\t\t\t\t\t\t\t\t\t\t\tsetViewComment(null);\n\t\t\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t\t\tdisabled={updateStatus.isPending}\n\t\t\t\t\t\t\t\t\t\tdata-testid=\"dialog-approve-button\"\n\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t{loc.COMMENTS_MODERATION_DIALOG_APPROVE}\n\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t{viewComment.status !== \"spam\" && (\n\t\t\t\t\t\t\t\t\t {\n\t\t\t\t\t\t\t\t\t\t\tawait handleSpam(viewComment.id);\n\t\t\t\t\t\t\t\t\t\t\tsetViewComment(null);\n\t\t\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t\t\tdisabled={updateStatus.isPending}\n\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t{loc.COMMENTS_MODERATION_DIALOG_MARK_SPAM}\n\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t {\n\t\t\t\t\t\t\t\t\t\tsetDeleteIds([viewComment.id]);\n\t\t\t\t\t\t\t\t\t\tsetViewComment(null);\n\t\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t{loc.COMMENTS_MODERATION_DIALOG_DELETE}\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t
\n\t\t\t\t\t\t
\n\t\t\t\t\t)}\n\t\t\t\t
\n\t\t\t
\n\n\t\t\t{/* Delete confirmation dialog */}\n\t\t\t 0}\n\t\t\t\tonOpenChange={(open) => !open && setDeleteIds([])}\n\t\t\t>\n\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t{deleteIds.length === 1\n\t\t\t\t\t\t\t\t? loc.COMMENTS_MODERATION_DELETE_TITLE_SINGULAR\n\t\t\t\t\t\t\t\t: loc.COMMENTS_MODERATION_DELETE_TITLE_PLURAL.replace(\n\t\t\t\t\t\t\t\t\t\t\"{n}\",\n\t\t\t\t\t\t\t\t\t\tString(deleteIds.length),\n\t\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t{deleteIds.length === 1\n\t\t\t\t\t\t\t\t? loc.COMMENTS_MODERATION_DELETE_DESCRIPTION_SINGULAR\n\t\t\t\t\t\t\t\t: loc.COMMENTS_MODERATION_DELETE_DESCRIPTION_PLURAL}\n\t\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t{loc.COMMENTS_MODERATION_DELETE_CANCEL}\n\t\t\t\t\t\t\n\t\t\t\t\t\t handleDelete(deleteIds)}\n\t\t\t\t\t\t\tdata-testid=\"confirm-delete-button\"\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t{deleteMutation.isPending\n\t\t\t\t\t\t\t\t? loc.COMMENTS_MODERATION_DELETE_DELETING\n\t\t\t\t\t\t\t\t: loc.COMMENTS_MODERATION_DELETE_CONFIRM}\n\t\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\n\t\t\t\n\t\t
\n\t);\n}\n", + "content": "\"use client\";\n\nimport { useState } from \"react\";\nimport {\n\tTable,\n\tTableBody,\n\tTableCell,\n\tTableHead,\n\tTableHeader,\n\tTableRow,\n} from \"@/components/ui/table\";\nimport {\n\tDialog,\n\tDialogContent,\n\tDialogHeader,\n\tDialogTitle,\n} from \"@/components/ui/dialog\";\nimport {\n\tAlertDialog,\n\tAlertDialogAction,\n\tAlertDialogCancel,\n\tAlertDialogContent,\n\tAlertDialogDescription,\n\tAlertDialogFooter,\n\tAlertDialogHeader,\n\tAlertDialogTitle,\n} from \"@/components/ui/alert-dialog\";\nimport { Button } from \"@/components/ui/button\";\nimport { Badge } from \"@/components/ui/badge\";\nimport { Tabs, TabsList, TabsTrigger } from \"@/components/ui/tabs\";\nimport { Checkbox } from \"@/components/ui/checkbox\";\nimport {\n\tAvatar,\n\tAvatarFallback,\n\tAvatarImage,\n} from \"@/components/ui/avatar\";\nimport { CheckCircle, ShieldOff, Trash2, Eye } from \"lucide-react\";\nimport { formatDistanceToNow } from \"date-fns\";\nimport { CanAccess, useNotify, useTranslate } from \"@btst/stack/context\";\nimport { useListState, type ListStateSchema } from \"@btst/stack/client\";\nimport { useRegisterPageAIContext } from \"@btst/stack/plugins/ai-chat/client/context\";\nimport type { SerializedComment, CommentStatus } from \"../../../types\";\nimport {\n\tuseSuspenseModerationComments,\n\tuseUpdateCommentStatus,\n\tuseDeleteComment,\n} from \"@btst/stack/plugins/comments/client/hooks\";\nimport type { CommentsLocalization } from \"../../localization\";\nimport { getInitials } from \"../../utils\";\nimport { Pagination } from \"../shared/pagination\";\n\ninterface ModerationPageProps {\n\tapiBaseURL: string;\n\tapiBasePath: string;\n\theaders?: HeadersInit;\n\tlocalization?: Partial;\n}\n\n// URL-synced moderation queue state: tab + page survive reloads and are\n// undoable with the back button (discrete changes default to push history).\nconst LIST_STATE_SCHEMA = {\n\ttab: { type: \"string\", default: \"pending\" },\n\tpage: { type: \"number\", default: 1 },\n} as const satisfies ListStateSchema;\n\nfunction StatusBadge({ status }: { status: CommentStatus }) {\n\tconst variants: Record<\n\t\tCommentStatus,\n\t\t\"secondary\" | \"default\" | \"destructive\"\n\t> = {\n\t\tpending: \"secondary\",\n\t\tapproved: \"default\",\n\t\tspam: \"destructive\",\n\t};\n\treturn {status};\n}\n\nexport function ModerationPage({\n\tapiBaseURL,\n\tapiBasePath,\n\theaders,\n\tlocalization,\n}: ModerationPageProps) {\n\tconst t = useTranslate();\n\tconst notify = useNotify();\n\n\tconst [listState, setListState] = useListState(\n\t\t\"comments-moderation\",\n\t\tLIST_STATE_SCHEMA,\n\t);\n\t// Bound the URL-sourced values: unknown tabs fall back to \"pending\",\n\t// pages clamp to >= 1 so a mangled URL cannot produce an invalid query.\n\tconst activeTab: CommentStatus =\n\t\tlistState.tab === \"approved\" || listState.tab === \"spam\"\n\t\t\t? listState.tab\n\t\t\t: \"pending\";\n\tconst currentPage = Math.max(1, Math.floor(listState.page) || 1);\n\n\tconst [selected, setSelected] = useState>(new Set());\n\tconst [viewComment, setViewComment] = useState(\n\t\tnull,\n\t);\n\tconst [deleteIds, setDeleteIds] = useState([]);\n\n\tconst config = { apiBaseURL, apiBasePath, headers };\n\n\tconst { comments, total, limit, offset, totalPages, refetch } =\n\t\tuseSuspenseModerationComments(config, {\n\t\t\tstatus: activeTab,\n\t\t\tpage: currentPage,\n\t\t});\n\n\tconst updateStatus = useUpdateCommentStatus(config);\n\tconst deleteMutation = useDeleteComment(config);\n\n\t// Register AI context with pending comment previews\n\tuseRegisterPageAIContext({\n\t\trouteName: \"comments-moderation\",\n\t\tpageDescription: `${total} ${activeTab} comments in the moderation queue.\\n\\nTop ${activeTab} comments:\\n${comments\n\t\t\t.slice(0, 5)\n\t\t\t.map(\n\t\t\t\t(c) =>\n\t\t\t\t\t`- \"${c.body.slice(0, 80)}${c.body.length > 80 ? \"…\" : \"\"}\" by ${c.resolvedAuthorName} on ${c.resourceType}/${c.resourceId}`,\n\t\t\t)\n\t\t\t.join(\"\\n\")}`,\n\t\tsuggestions: [\n\t\t\t\"Approve all safe-looking comments\",\n\t\t\t\"Flag spam comments\",\n\t\t\t\"Summarize today's discussion\",\n\t\t],\n\t});\n\n\tconst toggleSelect = (id: string) => {\n\t\tsetSelected((prev) => {\n\t\t\tconst next = new Set(prev);\n\t\t\tnext.has(id) ? next.delete(id) : next.add(id);\n\t\t\treturn next;\n\t\t});\n\t};\n\n\tconst toggleSelectAll = () => {\n\t\tif (selected.size === comments.length) {\n\t\t\tsetSelected(new Set());\n\t\t} else {\n\t\t\tsetSelected(new Set(comments.map((c) => c.id)));\n\t\t}\n\t};\n\n\tconst handleApprove = async (id: string) => {\n\t\ttry {\n\t\t\tawait updateStatus.mutateAsync({ id, status: \"approved\" });\n\t\t\tnotify.success(\n\t\t\t\tlocalization?.COMMENTS_MODERATION_TOAST_APPROVED ??\n\t\t\t\t\tt(\"comments.moderation.toastApproved\", \"Comment approved\"),\n\t\t\t);\n\t\t\tawait refetch();\n\t\t} catch {\n\t\t\tnotify.error(\n\t\t\t\tlocalization?.COMMENTS_MODERATION_TOAST_APPROVE_ERROR ??\n\t\t\t\t\tt(\n\t\t\t\t\t\t\"comments.moderation.toastApproveError\",\n\t\t\t\t\t\t\"Failed to approve comment\",\n\t\t\t\t\t),\n\t\t\t);\n\t\t}\n\t};\n\n\tconst handleSpam = async (id: string) => {\n\t\ttry {\n\t\t\tawait updateStatus.mutateAsync({ id, status: \"spam\" });\n\t\t\tnotify.success(\n\t\t\t\tlocalization?.COMMENTS_MODERATION_TOAST_SPAM ??\n\t\t\t\t\tt(\"comments.moderation.toastSpam\", \"Marked as spam\"),\n\t\t\t);\n\t\t\tawait refetch();\n\t\t} catch {\n\t\t\tnotify.error(\n\t\t\t\tlocalization?.COMMENTS_MODERATION_TOAST_SPAM_ERROR ??\n\t\t\t\t\tt(\"comments.moderation.toastSpamError\", \"Failed to update status\"),\n\t\t\t);\n\t\t}\n\t};\n\n\tconst handleDelete = async (ids: string[]) => {\n\t\ttry {\n\t\t\tawait Promise.all(ids.map((id) => deleteMutation.mutateAsync(id)));\n\t\t\tnotify.success(\n\t\t\t\tids.length === 1\n\t\t\t\t\t? (localization?.COMMENTS_MODERATION_TOAST_DELETED ??\n\t\t\t\t\t\t\tt(\"comments.moderation.toastDeleted\", \"Comment deleted\"))\n\t\t\t\t\t: (\n\t\t\t\t\t\t\tlocalization?.COMMENTS_MODERATION_TOAST_DELETED_PLURAL ??\n\t\t\t\t\t\t\tt(\n\t\t\t\t\t\t\t\t\"comments.moderation.toastDeletedPlural\",\n\t\t\t\t\t\t\t\t\"{n} comments deleted\",\n\t\t\t\t\t\t\t)\n\t\t\t\t\t\t).replace(\"{n}\", String(ids.length)),\n\t\t\t);\n\t\t\tsetSelected(new Set());\n\t\t\tsetDeleteIds([]);\n\t\t\tawait refetch();\n\t\t} catch {\n\t\t\tnotify.error(\n\t\t\t\tlocalization?.COMMENTS_MODERATION_TOAST_DELETE_ERROR ??\n\t\t\t\t\tt(\n\t\t\t\t\t\t\"comments.moderation.toastDeleteError\",\n\t\t\t\t\t\t\"Failed to delete comment(s)\",\n\t\t\t\t\t),\n\t\t\t);\n\t\t}\n\t};\n\n\tconst handleBulkApprove = async () => {\n\t\tconst ids = [...selected];\n\t\ttry {\n\t\t\tawait Promise.all(\n\t\t\t\tids.map((id) => updateStatus.mutateAsync({ id, status: \"approved\" })),\n\t\t\t);\n\t\t\tnotify.success(\n\t\t\t\t(\n\t\t\t\t\tlocalization?.COMMENTS_MODERATION_TOAST_BULK_APPROVED ??\n\t\t\t\t\tt(\"comments.moderation.toastBulkApproved\", \"{n} comment(s) approved\")\n\t\t\t\t).replace(\"{n}\", String(ids.length)),\n\t\t\t);\n\t\t\tsetSelected(new Set());\n\t\t\tawait refetch();\n\t\t} catch {\n\t\t\tnotify.error(\n\t\t\t\tlocalization?.COMMENTS_MODERATION_TOAST_BULK_APPROVE_ERROR ??\n\t\t\t\t\tt(\n\t\t\t\t\t\t\"comments.moderation.toastBulkApproveError\",\n\t\t\t\t\t\t\"Failed to approve comments\",\n\t\t\t\t\t),\n\t\t\t);\n\t\t}\n\t};\n\n\treturn (\n\t\t
\n\t\t\t
\n\t\t\t\t

\n\t\t\t\t\t{localization?.COMMENTS_MODERATION_TITLE ??\n\t\t\t\t\t\tt(\"comments.moderation.title\", \"Comment Moderation\")}\n\t\t\t\t

\n\t\t\t\t

\n\t\t\t\t\t{localization?.COMMENTS_MODERATION_DESCRIPTION ??\n\t\t\t\t\t\tt(\n\t\t\t\t\t\t\t\"comments.moderation.description\",\n\t\t\t\t\t\t\t\"Review and manage comments across all resources.\",\n\t\t\t\t\t\t)}\n\t\t\t\t

\n\t\t\t
\n\n\t\t\t {\n\t\t\t\t\tsetListState({ tab: v as CommentStatus, page: 1 });\n\t\t\t\t\tsetSelected(new Set());\n\t\t\t\t}}\n\t\t\t>\n\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\t{localization?.COMMENTS_MODERATION_TAB_PENDING ??\n\t\t\t\t\t\t\tt(\"comments.moderation.tabPending\", \"Pending\")}\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\t{localization?.COMMENTS_MODERATION_TAB_APPROVED ??\n\t\t\t\t\t\t\tt(\"comments.moderation.tabApproved\", \"Approved\")}\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\t{localization?.COMMENTS_MODERATION_TAB_SPAM ??\n\t\t\t\t\t\t\tt(\"comments.moderation.tabSpam\", \"Spam\")}\n\t\t\t\t\t\n\t\t\t\t\n\t\t\t\n\n\t\t\t{/* Bulk actions toolbar */}\n\t\t\t{selected.size > 0 && (\n\t\t\t\t
\n\t\t\t\t\t\n\t\t\t\t\t\t{(\n\t\t\t\t\t\t\tlocalization?.COMMENTS_MODERATION_SELECTED ??\n\t\t\t\t\t\t\tt(\"comments.moderation.selected\", \"{n} selected\")\n\t\t\t\t\t\t).replace(\"{n}\", String(selected.size))}\n\t\t\t\t\t\n\t\t\t\t\t{activeTab !== \"approved\" && (\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t{localization?.COMMENTS_MODERATION_APPROVE_SELECTED ??\n\t\t\t\t\t\t\t\t\tt(\"comments.moderation.approveSelected\", \"Approve selected\")}\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t)}\n\t\t\t\t\t\n\t\t\t\t\t\t setDeleteIds([...selected])}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t{localization?.COMMENTS_MODERATION_DELETE_SELECTED ??\n\t\t\t\t\t\t\t\tt(\"comments.moderation.deleteSelected\", \"Delete selected\")}\n\t\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t
\n\t\t\t)}\n\n\t\t\t{comments.length === 0 ? (\n\t\t\t\t
\n\t\t\t\t\t\n\t\t\t\t\t

\n\t\t\t\t\t\t{(\n\t\t\t\t\t\t\tlocalization?.COMMENTS_MODERATION_EMPTY ??\n\t\t\t\t\t\t\tt(\"comments.moderation.empty\", \"No {status} comments.\")\n\t\t\t\t\t\t).replace(\"{status}\", activeTab)}\n\t\t\t\t\t

\n\t\t\t\t
\n\t\t\t) : (\n\t\t\t\t<>\n\t\t\t\t\t
\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t 0\n\t\t\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\t\t\tonCheckedChange={toggleSelectAll}\n\t\t\t\t\t\t\t\t\t\t\taria-label={\n\t\t\t\t\t\t\t\t\t\t\t\tlocalization?.COMMENTS_MODERATION_SELECT_ALL ??\n\t\t\t\t\t\t\t\t\t\t\t\tt(\"comments.moderation.selectAll\", \"Select all\")\n\t\t\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t{localization?.COMMENTS_MODERATION_COL_AUTHOR ??\n\t\t\t\t\t\t\t\t\t\t\tt(\"comments.moderation.colAuthor\", \"Author\")}\n\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t{localization?.COMMENTS_MODERATION_COL_COMMENT ??\n\t\t\t\t\t\t\t\t\t\t\tt(\"comments.moderation.colComment\", \"Comment\")}\n\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t{localization?.COMMENTS_MODERATION_COL_RESOURCE ??\n\t\t\t\t\t\t\t\t\t\t\tt(\"comments.moderation.colResource\", \"Resource\")}\n\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t{localization?.COMMENTS_MODERATION_COL_DATE ??\n\t\t\t\t\t\t\t\t\t\t\tt(\"comments.moderation.colDate\", \"Date\")}\n\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t{localization?.COMMENTS_MODERATION_COL_ACTIONS ??\n\t\t\t\t\t\t\t\t\t\t\tt(\"comments.moderation.colActions\", \"Actions\")}\n\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t{comments.map((comment) => (\n\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t toggleSelect(comment.id)}\n\t\t\t\t\t\t\t\t\t\t\t\taria-label={\n\t\t\t\t\t\t\t\t\t\t\t\t\tlocalization?.COMMENTS_MODERATION_SELECT_ONE ??\n\t\t\t\t\t\t\t\t\t\t\t\t\tt(\"comments.moderation.selectOne\", \"Select comment\")\n\t\t\t\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t
\n\t\t\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t\t\t{comment.resolvedAvatarUrl && (\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t{getInitials(comment.resolvedAuthorName)}\n\t\t\t\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t\t\t{comment.resolvedAuthorName}\n\t\t\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t
\n\t\t\t\t\t\t\t\t\t\t
\n\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t

\n\t\t\t\t\t\t\t\t\t\t\t\t{comment.body}\n\t\t\t\t\t\t\t\t\t\t\t

\n\t\t\t\t\t\t\t\t\t\t
\n\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t\t{comment.resourceType}/{comment.resourceId}\n\t\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t{formatDistanceToNow(new Date(comment.createdAt), {\n\t\t\t\t\t\t\t\t\t\t\t\taddSuffix: true,\n\t\t\t\t\t\t\t\t\t\t\t})}\n\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t
\n\t\t\t\t\t\t\t\t\t\t\t\t setViewComment(comment)}\n\t\t\t\t\t\t\t\t\t\t\t\t\tdata-testid=\"view-button\"\n\t\t\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t\t{activeTab !== \"approved\" && (\n\t\t\t\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t handleApprove(comment.id)}\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tdisabled={updateStatus.isPending}\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tdata-testid=\"approve-button\"\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t\t\t\t\t{activeTab !== \"spam\" && (\n\t\t\t\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t handleSpam(comment.id)}\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tdisabled={updateStatus.isPending}\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tdata-testid=\"spam-button\"\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t\t\t setDeleteIds([comment.id])}\n\t\t\t\t\t\t\t\t\t\t\t\t\t\tdata-testid=\"delete-button\"\n\t\t\t\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t
\n\t\t\t\t\t\t\t\t\t\t
\n\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t))}\n\t\t\t\t\t\t\t
\n\t\t\t\t\t\t
\n\t\t\t\t\t
\n\t\t\t\t\t setListState({ page: p })}\n\t\t\t\t\t\ttotal={total}\n\t\t\t\t\t\tlimit={limit}\n\t\t\t\t\t\toffset={offset}\n\t\t\t\t\t/>\n\t\t\t\t\n\t\t\t)}\n\n\t\t\t{/* View comment dialog */}\n\t\t\t setViewComment(null)}>\n\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t{localization?.COMMENTS_MODERATION_DIALOG_TITLE ??\n\t\t\t\t\t\t\t\tt(\"comments.moderation.dialogTitle\", \"Comment Details\")}\n\t\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t{viewComment && (\n\t\t\t\t\t\t
\n\t\t\t\t\t\t\t
\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t{viewComment.resolvedAvatarUrl && (\n\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t{getInitials(viewComment.resolvedAuthorName)}\n\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t
\n\t\t\t\t\t\t\t\t\t

\n\t\t\t\t\t\t\t\t\t\t{viewComment.resolvedAuthorName}\n\t\t\t\t\t\t\t\t\t

\n\t\t\t\t\t\t\t\t\t

\n\t\t\t\t\t\t\t\t\t\t{new Date(viewComment.createdAt).toLocaleString()}\n\t\t\t\t\t\t\t\t\t

\n\t\t\t\t\t\t\t\t
\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t
\n\n\t\t\t\t\t\t\t
\n\t\t\t\t\t\t\t\t
\n\t\t\t\t\t\t\t\t\t

\n\t\t\t\t\t\t\t\t\t\t{localization?.COMMENTS_MODERATION_DIALOG_RESOURCE ??\n\t\t\t\t\t\t\t\t\t\t\tt(\"comments.moderation.dialogResource\", \"Resource\")}\n\t\t\t\t\t\t\t\t\t

\n\t\t\t\t\t\t\t\t\t

\n\t\t\t\t\t\t\t\t\t\t{viewComment.resourceType}/{viewComment.resourceId}\n\t\t\t\t\t\t\t\t\t

\n\t\t\t\t\t\t\t\t
\n\t\t\t\t\t\t\t\t
\n\t\t\t\t\t\t\t\t\t

\n\t\t\t\t\t\t\t\t\t\t{localization?.COMMENTS_MODERATION_DIALOG_LIKES ??\n\t\t\t\t\t\t\t\t\t\t\tt(\"comments.moderation.dialogLikes\", \"Likes\")}\n\t\t\t\t\t\t\t\t\t

\n\t\t\t\t\t\t\t\t\t

{viewComment.likes}

\n\t\t\t\t\t\t\t\t
\n\t\t\t\t\t\t\t\t{viewComment.parentId && (\n\t\t\t\t\t\t\t\t\t
\n\t\t\t\t\t\t\t\t\t\t

\n\t\t\t\t\t\t\t\t\t\t\t{localization?.COMMENTS_MODERATION_DIALOG_REPLY_TO ??\n\t\t\t\t\t\t\t\t\t\t\t\tt(\"comments.moderation.dialogReplyTo\", \"Reply to\")}\n\t\t\t\t\t\t\t\t\t\t

\n\t\t\t\t\t\t\t\t\t\t

{viewComment.parentId}

\n\t\t\t\t\t\t\t\t\t
\n\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t{viewComment.editedAt && (\n\t\t\t\t\t\t\t\t\t
\n\t\t\t\t\t\t\t\t\t\t

\n\t\t\t\t\t\t\t\t\t\t\t{localization?.COMMENTS_MODERATION_DIALOG_EDITED ??\n\t\t\t\t\t\t\t\t\t\t\t\tt(\"comments.moderation.dialogEdited\", \"Edited\")}\n\t\t\t\t\t\t\t\t\t\t

\n\t\t\t\t\t\t\t\t\t\t

\n\t\t\t\t\t\t\t\t\t\t\t{new Date(viewComment.editedAt).toLocaleString()}\n\t\t\t\t\t\t\t\t\t\t

\n\t\t\t\t\t\t\t\t\t
\n\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t
\n\n\t\t\t\t\t\t\t
\n\t\t\t\t\t\t\t\t

\n\t\t\t\t\t\t\t\t\t{localization?.COMMENTS_MODERATION_DIALOG_BODY ??\n\t\t\t\t\t\t\t\t\t\tt(\"comments.moderation.dialogBody\", \"Body\")}\n\t\t\t\t\t\t\t\t

\n\t\t\t\t\t\t\t\t
\n\t\t\t\t\t\t\t\t\t{viewComment.body}\n\t\t\t\t\t\t\t\t
\n\t\t\t\t\t\t\t
\n\n\t\t\t\t\t\t\t
\n\t\t\t\t\t\t\t\t{viewComment.status !== \"approved\" && (\n\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t {\n\t\t\t\t\t\t\t\t\t\t\t\tawait handleApprove(viewComment.id);\n\t\t\t\t\t\t\t\t\t\t\t\tsetViewComment(null);\n\t\t\t\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t\t\t\tdisabled={updateStatus.isPending}\n\t\t\t\t\t\t\t\t\t\t\tdata-testid=\"dialog-approve-button\"\n\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t{localization?.COMMENTS_MODERATION_DIALOG_APPROVE ??\n\t\t\t\t\t\t\t\t\t\t\t\tt(\"comments.moderation.dialogApprove\", \"Approve\")}\n\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t{viewComment.status !== \"spam\" && (\n\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t {\n\t\t\t\t\t\t\t\t\t\t\t\tawait handleSpam(viewComment.id);\n\t\t\t\t\t\t\t\t\t\t\t\tsetViewComment(null);\n\t\t\t\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t\t\t\tdisabled={updateStatus.isPending}\n\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t{localization?.COMMENTS_MODERATION_DIALOG_MARK_SPAM ??\n\t\t\t\t\t\t\t\t\t\t\t\tt(\"comments.moderation.dialogMarkSpam\", \"Mark spam\")}\n\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t {\n\t\t\t\t\t\t\t\t\t\t\tsetDeleteIds([viewComment.id]);\n\t\t\t\t\t\t\t\t\t\t\tsetViewComment(null);\n\t\t\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t{localization?.COMMENTS_MODERATION_DIALOG_DELETE ??\n\t\t\t\t\t\t\t\t\t\t\tt(\"comments.moderation.dialogDelete\", \"Delete\")}\n\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t
\n\t\t\t\t\t\t
\n\t\t\t\t\t)}\n\t\t\t\t
\n\t\t\t
\n\n\t\t\t{/* Delete confirmation dialog */}\n\t\t\t 0}\n\t\t\t\tonOpenChange={(open) => !open && setDeleteIds([])}\n\t\t\t>\n\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t{deleteIds.length === 1\n\t\t\t\t\t\t\t\t? (localization?.COMMENTS_MODERATION_DELETE_TITLE_SINGULAR ??\n\t\t\t\t\t\t\t\t\tt(\n\t\t\t\t\t\t\t\t\t\t\"comments.moderation.deleteTitleSingular\",\n\t\t\t\t\t\t\t\t\t\t\"Delete comment?\",\n\t\t\t\t\t\t\t\t\t))\n\t\t\t\t\t\t\t\t: (\n\t\t\t\t\t\t\t\t\t\tlocalization?.COMMENTS_MODERATION_DELETE_TITLE_PLURAL ??\n\t\t\t\t\t\t\t\t\t\tt(\n\t\t\t\t\t\t\t\t\t\t\t\"comments.moderation.deleteTitlePlural\",\n\t\t\t\t\t\t\t\t\t\t\t\"Delete {n} comments?\",\n\t\t\t\t\t\t\t\t\t\t)\n\t\t\t\t\t\t\t\t\t).replace(\"{n}\", String(deleteIds.length))}\n\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t{deleteIds.length === 1\n\t\t\t\t\t\t\t\t? (localization?.COMMENTS_MODERATION_DELETE_DESCRIPTION_SINGULAR ??\n\t\t\t\t\t\t\t\t\tt(\n\t\t\t\t\t\t\t\t\t\t\"comments.moderation.deleteDescriptionSingular\",\n\t\t\t\t\t\t\t\t\t\t\"This action cannot be undone. The comment will be permanently deleted.\",\n\t\t\t\t\t\t\t\t\t))\n\t\t\t\t\t\t\t\t: (localization?.COMMENTS_MODERATION_DELETE_DESCRIPTION_PLURAL ??\n\t\t\t\t\t\t\t\t\tt(\n\t\t\t\t\t\t\t\t\t\t\"comments.moderation.deleteDescriptionPlural\",\n\t\t\t\t\t\t\t\t\t\t\"This action cannot be undone. The comments will be permanently deleted.\",\n\t\t\t\t\t\t\t\t\t))}\n\t\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t{localization?.COMMENTS_MODERATION_DELETE_CANCEL ??\n\t\t\t\t\t\t\t\tt(\"comments.moderation.deleteCancel\", \"Cancel\")}\n\t\t\t\t\t\t\n\t\t\t\t\t\t handleDelete(deleteIds)}\n\t\t\t\t\t\t\tdata-testid=\"confirm-delete-button\"\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t{deleteMutation.isPending\n\t\t\t\t\t\t\t\t? (localization?.COMMENTS_MODERATION_DELETE_DELETING ??\n\t\t\t\t\t\t\t\t\tt(\"comments.moderation.deleteDeleting\", \"Deleting…\"))\n\t\t\t\t\t\t\t\t: (localization?.COMMENTS_MODERATION_DELETE_CONFIRM ??\n\t\t\t\t\t\t\t\t\tt(\"comments.moderation.deleteConfirm\", \"Delete\"))}\n\t\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\n\t\t\t\n\t\t
\n\t);\n}\n", "target": "src/components/btst/comments/client/components/pages/moderation-page.internal.tsx" }, { "path": "btst/comments/client/components/pages/moderation-page.tsx", "type": "registry:page", - "content": "\"use client\";\n\nimport { lazy } from \"react\";\nimport { ComposedRoute } from \"@btst/stack/client/components\";\nimport { usePluginOverrides } from \"@btst/stack/context\";\nimport type { CommentsPluginOverrides } from \"../../overrides\";\nimport { COMMENTS_LOCALIZATION } from \"../../localization\";\nimport { useRouteLifecycle } from \"@/hooks/use-route-lifecycle\";\nimport { PageWrapper } from \"../shared/page-wrapper\";\n\nconst ModerationPageInternal = lazy(() =>\n\timport(\"./moderation-page.internal\").then((m) => ({\n\t\tdefault: m.ModerationPage,\n\t})),\n);\n\nfunction ModerationPageSkeleton() {\n\treturn (\n\t\t
\n\t\t\t
\n\t\t\t
\n\t\t\t
\n\t\t\t
\n\t\t
\n\t);\n}\n\nexport function ModerationPageComponent() {\n\treturn (\n\t\t\n\t\t\t\tconsole.error(\"[btst/comments] Moderation error:\", error)\n\t\t\t}\n\t\t/>\n\t);\n}\n\nfunction ModerationPageWrapper() {\n\tconst overrides = usePluginOverrides(\"comments\");\n\tconst loc = { ...COMMENTS_LOCALIZATION, ...overrides.localization };\n\n\tuseRouteLifecycle({\n\t\trouteName: \"moderation\",\n\t\tcontext: {\n\t\t\tpath: \"/comments/moderation\",\n\t\t\tisSSR: typeof window === \"undefined\",\n\t\t},\n\t\toverrides,\n\t\tbeforeRenderHook: (o, context) => {\n\t\t\tif (o.onBeforeModerationPageRendered) {\n\t\t\t\treturn o.onBeforeModerationPageRendered(context);\n\t\t\t}\n\t\t\treturn true;\n\t\t},\n\t});\n\n\treturn (\n\t\t\n\t\t\t\n\t\t\n\t);\n}\n", + "content": "\"use client\";\n\nimport { lazy } from \"react\";\nimport { ComposedRoute } from \"@btst/stack/client/components\";\nimport { usePluginOverrides } from \"@btst/stack/context\";\nimport type { CommentsPluginOverrides } from \"../../overrides\";\nimport { useRouteLifecycle } from \"@/hooks/use-route-lifecycle\";\nimport { PageWrapper } from \"../shared/page-wrapper\";\n\nconst ModerationPageInternal = lazy(() =>\n\timport(\"./moderation-page.internal\").then((m) => ({\n\t\tdefault: m.ModerationPage,\n\t})),\n);\n\nfunction ModerationPageSkeleton() {\n\treturn (\n\t\t
\n\t\t\t
\n\t\t\t
\n\t\t\t
\n\t\t\t
\n\t\t
\n\t);\n}\n\nexport function ModerationPageComponent() {\n\treturn (\n\t\t\n\t\t\t\tconsole.error(\"[btst/comments] Moderation error:\", error)\n\t\t\t}\n\t\t/>\n\t);\n}\n\nfunction ModerationPageWrapper() {\n\tconst overrides = usePluginOverrides(\"comments\");\n\n\tuseRouteLifecycle({\n\t\trouteName: \"moderation\",\n\t\tcontext: {\n\t\t\tpath: \"/comments/moderation\",\n\t\t\tisSSR: typeof window === \"undefined\",\n\t\t},\n\t\toverrides,\n\t\tbeforeRenderHook: (o, context) => {\n\t\t\tif (o.onBeforeModerationPageRendered) {\n\t\t\t\treturn o.onBeforeModerationPageRendered(context);\n\t\t\t}\n\t\t\treturn true;\n\t\t},\n\t});\n\n\treturn (\n\t\t\n\t\t\t\n\t\t\n\t);\n}\n", "target": "src/components/btst/comments/client/components/pages/moderation-page.tsx" }, { "path": "btst/comments/client/components/pages/my-comments-page.internal.tsx", "type": "registry:component", - "content": "\"use client\";\n\nimport { useState } from \"react\";\nimport {\n\tTable,\n\tTableBody,\n\tTableCell,\n\tTableHead,\n\tTableHeader,\n\tTableRow,\n} from \"@/components/ui/table\";\nimport {\n\tAlertDialog,\n\tAlertDialogAction,\n\tAlertDialogCancel,\n\tAlertDialogContent,\n\tAlertDialogDescription,\n\tAlertDialogFooter,\n\tAlertDialogHeader,\n\tAlertDialogTitle,\n} from \"@/components/ui/alert-dialog\";\nimport { Button } from \"@/components/ui/button\";\nimport { Badge } from \"@/components/ui/badge\";\nimport {\n\tAvatar,\n\tAvatarFallback,\n\tAvatarImage,\n} from \"@/components/ui/avatar\";\nimport { Trash2, ExternalLink, LogIn, MessageSquareOff } from \"lucide-react\";\nimport { toast } from \"sonner\";\nimport { formatDistanceToNow } from \"date-fns\";\nimport type { CommentsPluginOverrides } from \"../../overrides\";\nimport { PaginationControls } from \"@/components/ui/pagination-controls\";\nimport type { SerializedComment, CommentStatus } from \"../../../types\";\nimport {\n\tuseSuspenseComments,\n\tuseDeleteComment,\n} from \"@btst/stack/plugins/comments/client/hooks\";\nimport {\n\tCOMMENTS_LOCALIZATION,\n\ttype CommentsLocalization,\n} from \"../../localization\";\nimport { getInitials, useResolvedCurrentUserId } from \"../../utils\";\n\nconst PAGE_LIMIT = 20;\n\ninterface UserCommentsPageProps {\n\tapiBaseURL: string;\n\tapiBasePath: string;\n\theaders?: HeadersInit;\n\tcurrentUserId?: CommentsPluginOverrides[\"currentUserId\"];\n\tresourceLinks?: CommentsPluginOverrides[\"resourceLinks\"];\n\tlocalization?: CommentsLocalization;\n}\n\nfunction StatusBadge({\n\tstatus,\n\tloc,\n}: {\n\tstatus: CommentStatus;\n\tloc: CommentsLocalization;\n}) {\n\tif (status === \"approved\") {\n\t\treturn (\n\t\t\t\n\t\t\t\t{loc.COMMENTS_MY_STATUS_APPROVED}\n\t\t\t\n\t\t);\n\t}\n\tif (status === \"pending\") {\n\t\treturn (\n\t\t\t\n\t\t\t\t{loc.COMMENTS_MY_STATUS_PENDING}\n\t\t\t\n\t\t);\n\t}\n\treturn (\n\t\t\n\t\t\t{loc.COMMENTS_MY_STATUS_SPAM}\n\t\t\n\t);\n}\n\n// ─── Main export ──────────────────────────────────────────────────────────────\n\nexport function UserCommentsPage({\n\tapiBaseURL,\n\tapiBasePath,\n\theaders,\n\tcurrentUserId: currentUserIdProp,\n\tresourceLinks,\n\tlocalization: localizationProp,\n}: UserCommentsPageProps) {\n\tconst loc = { ...COMMENTS_LOCALIZATION, ...localizationProp };\n\tconst resolvedUserId = useResolvedCurrentUserId(currentUserIdProp);\n\n\tif (!resolvedUserId) {\n\t\treturn (\n\t\t\t\n\t\t\t\t\n\t\t\t\t

{loc.COMMENTS_MY_LOGIN_TITLE}

\n\t\t\t\t

\n\t\t\t\t\t{loc.COMMENTS_MY_LOGIN_DESCRIPTION}\n\t\t\t\t

\n\t\t\t
\n\t\t);\n\t}\n\n\treturn (\n\t\t\n\t);\n}\n\n// ─── List (suspense boundary is in ComposedRoute) ─────────────────────────────\n\nfunction UserCommentsList({\n\tapiBaseURL,\n\tapiBasePath,\n\theaders,\n\tcurrentUserId,\n\tresourceLinks,\n\tloc,\n}: {\n\tapiBaseURL: string;\n\tapiBasePath: string;\n\theaders?: HeadersInit;\n\tcurrentUserId: string;\n\tresourceLinks?: CommentsPluginOverrides[\"resourceLinks\"];\n\tloc: CommentsLocalization;\n}) {\n\tconst [page, setPage] = useState(1);\n\tconst [deleteId, setDeleteId] = useState(null);\n\n\tconst config = { apiBaseURL, apiBasePath, headers };\n\tconst offset = (page - 1) * PAGE_LIMIT;\n\n\tconst { comments, total, refetch } = useSuspenseComments(config, {\n\t\tauthorId: currentUserId,\n\t\tsort: \"desc\",\n\t\tlimit: PAGE_LIMIT,\n\t\toffset,\n\t});\n\n\tconst deleteMutation = useDeleteComment(config);\n\n\tconst totalPages = Math.max(1, Math.ceil(total / PAGE_LIMIT));\n\n\tconst handleDelete = async () => {\n\t\tif (!deleteId) return;\n\t\ttry {\n\t\t\tawait deleteMutation.mutateAsync(deleteId);\n\t\t\ttoast.success(loc.COMMENTS_MY_TOAST_DELETED);\n\t\t\trefetch();\n\t\t} catch {\n\t\t\ttoast.error(loc.COMMENTS_MY_TOAST_DELETE_ERROR);\n\t\t} finally {\n\t\t\tsetDeleteId(null);\n\t\t}\n\t};\n\n\tif (comments.length === 0 && page === 1) {\n\t\treturn (\n\t\t\t\n\t\t\t\t\n\t\t\t\t

{loc.COMMENTS_MY_EMPTY_TITLE}

\n\t\t\t\t

\n\t\t\t\t\t{loc.COMMENTS_MY_EMPTY_DESCRIPTION}\n\t\t\t\t

\n\t\t\t
\n\t\t);\n\t}\n\n\treturn (\n\t\t
\n\t\t\t
\n\t\t\t\t

\n\t\t\t\t\t{loc.COMMENTS_MY_PAGE_TITLE}\n\t\t\t\t

\n\t\t\t\t

\n\t\t\t\t\t{total} {loc.COMMENTS_MY_COL_COMMENT.toLowerCase()}\n\t\t\t\t\t{total !== 1 ? \"s\" : \"\"}\n\t\t\t\t

\n\t\t\t
\n\n\t\t\t\n\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t{loc.COMMENTS_MY_COL_COMMENT}\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t{loc.COMMENTS_MY_COL_RESOURCE}\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t{loc.COMMENTS_MY_COL_STATUS}\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t{loc.COMMENTS_MY_COL_DATE}\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\t{comments.map((comment) => (\n\t\t\t\t\t\t\t setDeleteId(comment.id)}\n\t\t\t\t\t\t\t\tisDeleting={deleteMutation.isPending && deleteId === comment.id}\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t))}\n\t\t\t\t\t\n\t\t\t\t
\n\n\t\t\t\t {\n\t\t\t\t\t\tsetPage(p);\n\t\t\t\t\t\twindow.scrollTo({ top: 0, behavior: \"smooth\" });\n\t\t\t\t\t}}\n\t\t\t\t/>\n\t\t\t
\n\n\t\t\t !open && setDeleteId(null)}\n\t\t\t>\n\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\t{loc.COMMENTS_MY_DELETE_TITLE}\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t{loc.COMMENTS_MY_DELETE_DESCRIPTION}\n\t\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t{loc.COMMENTS_MY_DELETE_CANCEL}\n\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t{loc.COMMENTS_MY_DELETE_CONFIRM}\n\t\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\n\t\t\t\n\t\t
\n\t);\n}\n\n// ─── Row ──────────────────────────────────────────────────────────────────────\n\nfunction CommentRow({\n\tcomment,\n\tresourceLinks,\n\tloc,\n\tonDelete,\n\tisDeleting,\n}: {\n\tcomment: SerializedComment;\n\tresourceLinks?: CommentsPluginOverrides[\"resourceLinks\"];\n\tloc: CommentsLocalization;\n\tonDelete: () => void;\n\tisDeleting: boolean;\n}) {\n\tconst resourceUrlBase = resourceLinks?.[comment.resourceType]?.(\n\t\tcomment.resourceId,\n\t);\n\tconst resourceUrl = resourceUrlBase\n\t\t? `${resourceUrlBase}#comments`\n\t\t: undefined;\n\n\treturn (\n\t\t\n\t\t\t\n\t\t\t\t\n\t\t\t\t\t{comment.resolvedAvatarUrl && (\n\t\t\t\t\t\t\n\t\t\t\t\t)}\n\t\t\t\t\t\n\t\t\t\t\t\t{getInitials(comment.resolvedAuthorName)}\n\t\t\t\t\t\n\t\t\t\t\n\t\t\t\n\n\t\t\t\n\t\t\t\t

{comment.body}

\n\t\t\t\t{comment.parentId && (\n\t\t\t\t\t\n\t\t\t\t\t\t{loc.COMMENTS_MY_REPLY_INDICATOR}\n\t\t\t\t\t\n\t\t\t\t)}\n\t\t\t
\n\n\t\t\t\n\t\t\t\t
\n\t\t\t\t\t\n\t\t\t\t\t\t{comment.resourceType.replace(/-/g, \" \")}\n\t\t\t\t\t\n\t\t\t\t\t{resourceUrl ? (\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t{loc.COMMENTS_MY_VIEW_LINK}\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t) : (\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t{comment.resourceId}\n\t\t\t\t\t\t\n\t\t\t\t\t)}\n\t\t\t\t
\n\t\t\t
\n\n\t\t\t\n\t\t\t\t\n\t\t\t\n\n\t\t\t\n\t\t\t\t{formatDistanceToNow(new Date(comment.createdAt), { addSuffix: true })}\n\t\t\t\n\n\t\t\t\n\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t{loc.COMMENTS_MY_DELETE_BUTTON_SR}\n\t\t\t\t\n\t\t\t\n\t\t
\n\t);\n}\n", + "content": "\"use client\";\n\nimport { useState } from \"react\";\nimport {\n\tTable,\n\tTableBody,\n\tTableCell,\n\tTableHead,\n\tTableHeader,\n\tTableRow,\n} from \"@/components/ui/table\";\nimport {\n\tAlertDialog,\n\tAlertDialogAction,\n\tAlertDialogCancel,\n\tAlertDialogContent,\n\tAlertDialogDescription,\n\tAlertDialogFooter,\n\tAlertDialogHeader,\n\tAlertDialogTitle,\n} from \"@/components/ui/alert-dialog\";\nimport { Button } from \"@/components/ui/button\";\nimport { Badge } from \"@/components/ui/badge\";\nimport {\n\tAvatar,\n\tAvatarFallback,\n\tAvatarImage,\n} from \"@/components/ui/avatar\";\nimport { Trash2, ExternalLink, LogIn, MessageSquareOff } from \"lucide-react\";\nimport { formatDistanceToNow } from \"date-fns\";\nimport { useNotify, useTranslate } from \"@btst/stack/context\";\nimport { useListState, type ListStateSchema } from \"@btst/stack/client\";\nimport type { CommentsPluginOverrides } from \"../../overrides\";\nimport { PaginationControls } from \"@/components/ui/pagination-controls\";\nimport type { SerializedComment, CommentStatus } from \"../../../types\";\nimport {\n\tuseSuspenseComments,\n\tuseDeleteComment,\n} from \"@btst/stack/plugins/comments/client/hooks\";\nimport type { CommentsLocalization } from \"../../localization\";\nimport { getInitials, useResolvedCurrentUserId } from \"../../utils\";\n\nconst PAGE_LIMIT = 20;\n\n// URL-synced pagination: the page number survives reloads and is undoable\n// with the back button (discrete changes default to push history).\nconst LIST_STATE_SCHEMA = {\n\tpage: { type: \"number\", default: 1 },\n} as const satisfies ListStateSchema;\n\ninterface UserCommentsPageProps {\n\tapiBaseURL: string;\n\tapiBasePath: string;\n\theaders?: HeadersInit;\n\tcurrentUserId?: CommentsPluginOverrides[\"currentUserId\"];\n\tresourceLinks?: CommentsPluginOverrides[\"resourceLinks\"];\n\tlocalization?: Partial;\n}\n\nfunction StatusBadge({\n\tstatus,\n\tlocalization,\n}: {\n\tstatus: CommentStatus;\n\tlocalization?: Partial;\n}) {\n\tconst t = useTranslate();\n\tif (status === \"approved\") {\n\t\treturn (\n\t\t\t\n\t\t\t\t{localization?.COMMENTS_MY_STATUS_APPROVED ??\n\t\t\t\t\tt(\"comments.my.statusApproved\", \"Approved\")}\n\t\t\t\n\t\t);\n\t}\n\tif (status === \"pending\") {\n\t\treturn (\n\t\t\t\n\t\t\t\t{localization?.COMMENTS_MY_STATUS_PENDING ??\n\t\t\t\t\tt(\"comments.my.statusPending\", \"Pending\")}\n\t\t\t\n\t\t);\n\t}\n\treturn (\n\t\t\n\t\t\t{localization?.COMMENTS_MY_STATUS_SPAM ??\n\t\t\t\tt(\"comments.my.statusSpam\", \"Spam\")}\n\t\t\n\t);\n}\n\n// ─── Main export ──────────────────────────────────────────────────────────────\n\nexport function UserCommentsPage({\n\tapiBaseURL,\n\tapiBasePath,\n\theaders,\n\tcurrentUserId: currentUserIdProp,\n\tresourceLinks,\n\tlocalization,\n}: UserCommentsPageProps) {\n\tconst t = useTranslate();\n\tconst resolvedUserId = useResolvedCurrentUserId(currentUserIdProp);\n\n\tif (!resolvedUserId) {\n\t\treturn (\n\t\t\t\n\t\t\t\t\n\t\t\t\t

\n\t\t\t\t\t{localization?.COMMENTS_MY_LOGIN_TITLE ??\n\t\t\t\t\t\tt(\"comments.my.loginTitle\", \"Please log in to view your comments\")}\n\t\t\t\t

\n\t\t\t\t

\n\t\t\t\t\t{localization?.COMMENTS_MY_LOGIN_DESCRIPTION ??\n\t\t\t\t\t\tt(\n\t\t\t\t\t\t\t\"comments.my.loginDescription\",\n\t\t\t\t\t\t\t\"You need to be logged in to see your comment history.\",\n\t\t\t\t\t\t)}\n\t\t\t\t

\n\t\t\t
\n\t\t);\n\t}\n\n\treturn (\n\t\t\n\t);\n}\n\n// ─── List (suspense boundary is in ComposedRoute) ─────────────────────────────\n\nfunction UserCommentsList({\n\tapiBaseURL,\n\tapiBasePath,\n\theaders,\n\tcurrentUserId,\n\tresourceLinks,\n\tlocalization,\n}: {\n\tapiBaseURL: string;\n\tapiBasePath: string;\n\theaders?: HeadersInit;\n\tcurrentUserId: string;\n\tresourceLinks?: CommentsPluginOverrides[\"resourceLinks\"];\n\tlocalization?: Partial;\n}) {\n\tconst t = useTranslate();\n\tconst notify = useNotify();\n\n\tconst [listState, setListState] = useListState(\n\t\t\"comments-my\",\n\t\tLIST_STATE_SCHEMA,\n\t);\n\t// Clamp the URL-sourced page so a mangled URL cannot produce an invalid query.\n\tconst page = Math.max(1, Math.floor(listState.page) || 1);\n\n\tconst [deleteId, setDeleteId] = useState(null);\n\n\tconst config = { apiBaseURL, apiBasePath, headers };\n\tconst offset = (page - 1) * PAGE_LIMIT;\n\n\tconst { comments, total, refetch } = useSuspenseComments(config, {\n\t\tauthorId: currentUserId,\n\t\tsort: \"desc\",\n\t\tlimit: PAGE_LIMIT,\n\t\toffset,\n\t});\n\n\tconst deleteMutation = useDeleteComment(config);\n\n\tconst totalPages = Math.max(1, Math.ceil(total / PAGE_LIMIT));\n\n\tconst handleDelete = async () => {\n\t\tif (!deleteId) return;\n\t\ttry {\n\t\t\tawait deleteMutation.mutateAsync(deleteId);\n\t\t\tnotify.success(\n\t\t\t\tlocalization?.COMMENTS_MY_TOAST_DELETED ??\n\t\t\t\t\tt(\"comments.my.toastDeleted\", \"Comment deleted\"),\n\t\t\t);\n\t\t\trefetch();\n\t\t} catch {\n\t\t\tnotify.error(\n\t\t\t\tlocalization?.COMMENTS_MY_TOAST_DELETE_ERROR ??\n\t\t\t\t\tt(\"comments.my.toastDeleteError\", \"Failed to delete comment\"),\n\t\t\t);\n\t\t} finally {\n\t\t\tsetDeleteId(null);\n\t\t}\n\t};\n\n\tif (comments.length === 0 && page === 1) {\n\t\treturn (\n\t\t\t\n\t\t\t\t\n\t\t\t\t

\n\t\t\t\t\t{localization?.COMMENTS_MY_EMPTY_TITLE ??\n\t\t\t\t\t\tt(\"comments.my.emptyTitle\", \"No comments yet\")}\n\t\t\t\t

\n\t\t\t\t

\n\t\t\t\t\t{localization?.COMMENTS_MY_EMPTY_DESCRIPTION ??\n\t\t\t\t\t\tt(\n\t\t\t\t\t\t\t\"comments.my.emptyDescription\",\n\t\t\t\t\t\t\t\"Comments you post will appear here.\",\n\t\t\t\t\t\t)}\n\t\t\t\t

\n\t\t\t
\n\t\t);\n\t}\n\n\treturn (\n\t\t
\n\t\t\t
\n\t\t\t\t

\n\t\t\t\t\t{localization?.COMMENTS_MY_PAGE_TITLE ??\n\t\t\t\t\t\tt(\"comments.my.pageTitle\", \"My Comments\")}\n\t\t\t\t

\n\t\t\t\t

\n\t\t\t\t\t{total}{\" \"}\n\t\t\t\t\t{(\n\t\t\t\t\t\tlocalization?.COMMENTS_MY_COL_COMMENT ??\n\t\t\t\t\t\tt(\"comments.my.colComment\", \"Comment\")\n\t\t\t\t\t).toLowerCase()}\n\t\t\t\t\t{total !== 1 ? \"s\" : \"\"}\n\t\t\t\t

\n\t\t\t
\n\n\t\t\t\n\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t{localization?.COMMENTS_MY_COL_COMMENT ??\n\t\t\t\t\t\t\t\t\tt(\"comments.my.colComment\", \"Comment\")}\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t{localization?.COMMENTS_MY_COL_RESOURCE ??\n\t\t\t\t\t\t\t\t\tt(\"comments.my.colResource\", \"Resource\")}\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t{localization?.COMMENTS_MY_COL_STATUS ??\n\t\t\t\t\t\t\t\t\tt(\"comments.my.colStatus\", \"Status\")}\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t{localization?.COMMENTS_MY_COL_DATE ??\n\t\t\t\t\t\t\t\t\tt(\"comments.my.colDate\", \"Date\")}\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\t{comments.map((comment) => (\n\t\t\t\t\t\t\t setDeleteId(comment.id)}\n\t\t\t\t\t\t\t\tisDeleting={deleteMutation.isPending && deleteId === comment.id}\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t))}\n\t\t\t\t\t\n\t\t\t\t
\n\n\t\t\t\t {\n\t\t\t\t\t\tsetListState({ page: p });\n\t\t\t\t\t\twindow.scrollTo({ top: 0, behavior: \"smooth\" });\n\t\t\t\t\t}}\n\t\t\t\t/>\n\t\t\t
\n\n\t\t\t !open && setDeleteId(null)}\n\t\t\t>\n\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t{localization?.COMMENTS_MY_DELETE_TITLE ??\n\t\t\t\t\t\t\t\tt(\"comments.my.deleteTitle\", \"Delete comment?\")}\n\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t{localization?.COMMENTS_MY_DELETE_DESCRIPTION ??\n\t\t\t\t\t\t\t\tt(\n\t\t\t\t\t\t\t\t\t\"comments.my.deleteDescription\",\n\t\t\t\t\t\t\t\t\t\"This action cannot be undone. The comment will be permanently removed.\",\n\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t{localization?.COMMENTS_MY_DELETE_CANCEL ??\n\t\t\t\t\t\t\t\tt(\"comments.my.deleteCancel\", \"Cancel\")}\n\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t{localization?.COMMENTS_MY_DELETE_CONFIRM ??\n\t\t\t\t\t\t\t\tt(\"comments.my.deleteConfirm\", \"Delete\")}\n\t\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\n\t\t\t\n\t\t
\n\t);\n}\n\n// ─── Row ──────────────────────────────────────────────────────────────────────\n\nfunction CommentRow({\n\tcomment,\n\tresourceLinks,\n\tlocalization,\n\tonDelete,\n\tisDeleting,\n}: {\n\tcomment: SerializedComment;\n\tresourceLinks?: CommentsPluginOverrides[\"resourceLinks\"];\n\tlocalization?: Partial;\n\tonDelete: () => void;\n\tisDeleting: boolean;\n}) {\n\tconst t = useTranslate();\n\tconst resourceUrlBase = resourceLinks?.[comment.resourceType]?.(\n\t\tcomment.resourceId,\n\t);\n\tconst resourceUrl = resourceUrlBase\n\t\t? `${resourceUrlBase}#comments`\n\t\t: undefined;\n\n\treturn (\n\t\t\n\t\t\t\n\t\t\t\t\n\t\t\t\t\t{comment.resolvedAvatarUrl && (\n\t\t\t\t\t\t\n\t\t\t\t\t)}\n\t\t\t\t\t\n\t\t\t\t\t\t{getInitials(comment.resolvedAuthorName)}\n\t\t\t\t\t\n\t\t\t\t\n\t\t\t\n\n\t\t\t\n\t\t\t\t

{comment.body}

\n\t\t\t\t{comment.parentId && (\n\t\t\t\t\t\n\t\t\t\t\t\t{localization?.COMMENTS_MY_REPLY_INDICATOR ??\n\t\t\t\t\t\t\tt(\"comments.my.replyIndicator\", \"↩ Reply\")}\n\t\t\t\t\t\n\t\t\t\t)}\n\t\t\t
\n\n\t\t\t\n\t\t\t\t
\n\t\t\t\t\t\n\t\t\t\t\t\t{comment.resourceType.replace(/-/g, \" \")}\n\t\t\t\t\t\n\t\t\t\t\t{resourceUrl ? (\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t{localization?.COMMENTS_MY_VIEW_LINK ??\n\t\t\t\t\t\t\t\tt(\"comments.my.viewLink\", \"View\")}\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t) : (\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t{comment.resourceId}\n\t\t\t\t\t\t\n\t\t\t\t\t)}\n\t\t\t\t
\n\t\t\t
\n\n\t\t\t\n\t\t\t\t\n\t\t\t\n\n\t\t\t\n\t\t\t\t{formatDistanceToNow(new Date(comment.createdAt), { addSuffix: true })}\n\t\t\t\n\n\t\t\t\n\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\t{localization?.COMMENTS_MY_DELETE_BUTTON_SR ??\n\t\t\t\t\t\t\tt(\"comments.my.deleteButtonSr\", \"Delete comment\")}\n\t\t\t\t\t\n\t\t\t\t\n\t\t\t\n\t\t
\n\t);\n}\n", "target": "src/components/btst/comments/client/components/pages/my-comments-page.internal.tsx" }, { "path": "btst/comments/client/components/pages/my-comments-page.tsx", "type": "registry:page", - "content": "\"use client\";\n\nimport { lazy } from \"react\";\nimport { ComposedRoute } from \"@btst/stack/client/components\";\nimport { usePluginOverrides } from \"@btst/stack/context\";\nimport type { CommentsPluginOverrides } from \"../../overrides\";\nimport { COMMENTS_LOCALIZATION } from \"../../localization\";\nimport { useRouteLifecycle } from \"@/hooks/use-route-lifecycle\";\nimport { PageWrapper } from \"../shared/page-wrapper\";\n\nconst UserCommentsPageInternal = lazy(() =>\n\timport(\"./my-comments-page.internal\").then((m) => ({\n\t\tdefault: m.UserCommentsPage,\n\t})),\n);\n\nfunction UserCommentsPageSkeleton() {\n\treturn (\n\t\t
\n\t\t\t
\n\t\t\t
\n\t\t\t
\n\t\t
\n\t);\n}\n\nexport function UserCommentsPageComponent() {\n\treturn (\n\t\t\n\t\t\t\tconsole.error(\"[btst/comments] User Comments error:\", error)\n\t\t\t}\n\t\t/>\n\t);\n}\n\nfunction UserCommentsPageWrapper() {\n\tconst overrides = usePluginOverrides(\"comments\");\n\tconst loc = { ...COMMENTS_LOCALIZATION, ...overrides.localization };\n\n\tuseRouteLifecycle({\n\t\trouteName: \"userComments\",\n\t\tcontext: {\n\t\t\tpath: \"/comments\",\n\t\t\tisSSR: typeof window === \"undefined\",\n\t\t},\n\t\toverrides,\n\t\tbeforeRenderHook: (o, context) => {\n\t\t\tif (o.onBeforeUserCommentsPageRendered) {\n\t\t\t\tconst result = o.onBeforeUserCommentsPageRendered(context);\n\t\t\t\treturn result === false ? false : true;\n\t\t\t}\n\t\t\treturn true;\n\t\t},\n\t});\n\n\treturn (\n\t\t\n\t\t\t\n\t\t\n\t);\n}\n", + "content": "\"use client\";\n\nimport { lazy } from \"react\";\nimport { ComposedRoute } from \"@btst/stack/client/components\";\nimport { usePluginOverrides } from \"@btst/stack/context\";\nimport type { CommentsPluginOverrides } from \"../../overrides\";\nimport { useRouteLifecycle } from \"@/hooks/use-route-lifecycle\";\nimport { PageWrapper } from \"../shared/page-wrapper\";\n\nconst UserCommentsPageInternal = lazy(() =>\n\timport(\"./my-comments-page.internal\").then((m) => ({\n\t\tdefault: m.UserCommentsPage,\n\t})),\n);\n\nfunction UserCommentsPageSkeleton() {\n\treturn (\n\t\t
\n\t\t\t
\n\t\t\t
\n\t\t\t
\n\t\t
\n\t);\n}\n\nexport function UserCommentsPageComponent() {\n\treturn (\n\t\t\n\t\t\t\tconsole.error(\"[btst/comments] User Comments error:\", error)\n\t\t\t}\n\t\t/>\n\t);\n}\n\nfunction UserCommentsPageWrapper() {\n\tconst overrides = usePluginOverrides(\"comments\");\n\n\tuseRouteLifecycle({\n\t\trouteName: \"userComments\",\n\t\tcontext: {\n\t\t\tpath: \"/comments\",\n\t\t\tisSSR: typeof window === \"undefined\",\n\t\t},\n\t\toverrides,\n\t\tbeforeRenderHook: (o, context) => {\n\t\t\tif (o.onBeforeUserCommentsPageRendered) {\n\t\t\t\tconst result = o.onBeforeUserCommentsPageRendered(context);\n\t\t\t\treturn result === false ? false : true;\n\t\t\t}\n\t\t\treturn true;\n\t\t},\n\t});\n\n\treturn (\n\t\t\n\t\t\t\n\t\t\n\t);\n}\n", "target": "src/components/btst/comments/client/components/pages/my-comments-page.tsx" }, { "path": "btst/comments/client/components/pages/resource-comments-page.internal.tsx", "type": "registry:component", - "content": "\"use client\";\n\nimport type { SerializedComment } from \"../../../types\";\nimport {\n\tuseSuspenseComments,\n\tuseUpdateCommentStatus,\n\tuseDeleteComment,\n} from \"@btst/stack/plugins/comments/client/hooks\";\nimport { CommentThread } from \"../comment-thread\";\nimport { Button } from \"@/components/ui/button\";\nimport { Badge } from \"@/components/ui/badge\";\nimport {\n\tAvatar,\n\tAvatarFallback,\n\tAvatarImage,\n} from \"@/components/ui/avatar\";\nimport { CheckCircle, ShieldOff, Trash2 } from \"lucide-react\";\nimport { formatDistanceToNow } from \"date-fns\";\nimport { toast } from \"sonner\";\nimport {\n\tCOMMENTS_LOCALIZATION,\n\ttype CommentsLocalization,\n} from \"../../localization\";\nimport { getInitials } from \"../../utils\";\n\ninterface ResourceCommentsPageProps {\n\tresourceId: string;\n\tresourceType: string;\n\tapiBaseURL: string;\n\tapiBasePath: string;\n\theaders?: HeadersInit;\n\tcurrentUserId?: string;\n\tloginHref?: string;\n\tlocalization?: CommentsLocalization;\n}\n\nexport function ResourceCommentsPage({\n\tresourceId,\n\tresourceType,\n\tapiBaseURL,\n\tapiBasePath,\n\theaders,\n\tcurrentUserId,\n\tloginHref,\n\tlocalization: localizationProp,\n}: ResourceCommentsPageProps) {\n\tconst loc = { ...COMMENTS_LOCALIZATION, ...localizationProp };\n\tconst config = { apiBaseURL, apiBasePath, headers };\n\n\tconst {\n\t\tcomments: pendingComments,\n\t\ttotal: pendingTotal,\n\t\trefetch,\n\t} = useSuspenseComments(config, {\n\t\tresourceId,\n\t\tresourceType,\n\t\tstatus: \"pending\",\n\t});\n\n\tconst updateStatus = useUpdateCommentStatus(config);\n\tconst deleteMutation = useDeleteComment(config);\n\n\tconst handleApprove = async (id: string) => {\n\t\ttry {\n\t\t\tawait updateStatus.mutateAsync({ id, status: \"approved\" });\n\t\t\ttoast.success(loc.COMMENTS_RESOURCE_TOAST_APPROVED);\n\t\t\trefetch();\n\t\t} catch {\n\t\t\ttoast.error(loc.COMMENTS_RESOURCE_TOAST_APPROVE_ERROR);\n\t\t}\n\t};\n\n\tconst handleSpam = async (id: string) => {\n\t\ttry {\n\t\t\tawait updateStatus.mutateAsync({ id, status: \"spam\" });\n\t\t\ttoast.success(loc.COMMENTS_RESOURCE_TOAST_SPAM);\n\t\t\trefetch();\n\t\t} catch {\n\t\t\ttoast.error(loc.COMMENTS_RESOURCE_TOAST_SPAM_ERROR);\n\t\t}\n\t};\n\n\tconst handleDelete = async (id: string) => {\n\t\tif (!window.confirm(loc.COMMENTS_RESOURCE_DELETE_CONFIRM)) return;\n\t\ttry {\n\t\t\tawait deleteMutation.mutateAsync(id);\n\t\t\ttoast.success(loc.COMMENTS_RESOURCE_TOAST_DELETED);\n\t\t\trefetch();\n\t\t} catch {\n\t\t\ttoast.error(loc.COMMENTS_RESOURCE_TOAST_DELETE_ERROR);\n\t\t}\n\t};\n\n\treturn (\n\t\t\n\t\t\t
\n\t\t\t\t

{loc.COMMENTS_RESOURCE_TITLE}

\n\t\t\t\t

\n\t\t\t\t\t{resourceType}/{resourceId}\n\t\t\t\t

\n\t\t\t
\n\n\t\t\t{pendingTotal > 0 && (\n\t\t\t\t
\n\t\t\t\t\t

\n\t\t\t\t\t\t{loc.COMMENTS_RESOURCE_PENDING_SECTION}\n\t\t\t\t\t\t{pendingTotal}\n\t\t\t\t\t

\n\t\t\t\t\t
\n\t\t\t\t\t\t{pendingComments.map((comment) => (\n\t\t\t\t\t\t\t handleApprove(comment.id)}\n\t\t\t\t\t\t\t\tonSpam={() => handleSpam(comment.id)}\n\t\t\t\t\t\t\t\tonDelete={() => handleDelete(comment.id)}\n\t\t\t\t\t\t\t\tisUpdating={updateStatus.isPending}\n\t\t\t\t\t\t\t\tisDeleting={deleteMutation.isPending}\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t))}\n\t\t\t\t\t
\n\t\t\t\t
\n\t\t\t)}\n\n\t\t\t
\n\t\t\t\t

\n\t\t\t\t\t{loc.COMMENTS_RESOURCE_THREAD_SECTION}\n\t\t\t\t

\n\t\t\t\t\n\t\t\t
\n\t\t
\n\t);\n}\n\nfunction PendingCommentRow({\n\tcomment,\n\tloc,\n\tonApprove,\n\tonSpam,\n\tonDelete,\n\tisUpdating,\n\tisDeleting,\n}: {\n\tcomment: SerializedComment;\n\tloc: CommentsLocalization;\n\tonApprove: () => void;\n\tonSpam: () => void;\n\tonDelete: () => void;\n\tisUpdating: boolean;\n\tisDeleting: boolean;\n}) {\n\treturn (\n\t\t
\n\t\t\t\n\t\t\t\t{comment.resolvedAvatarUrl && (\n\t\t\t\t\t\n\t\t\t\t)}\n\t\t\t\t\n\t\t\t\t\t{getInitials(comment.resolvedAuthorName)}\n\t\t\t\t\n\t\t\t\n\t\t\t
\n\t\t\t\t
\n\t\t\t\t\t\n\t\t\t\t\t\t{comment.resolvedAuthorName}\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\t{formatDistanceToNow(new Date(comment.createdAt), {\n\t\t\t\t\t\t\taddSuffix: true,\n\t\t\t\t\t\t})}\n\t\t\t\t\t\n\t\t\t\t
\n\t\t\t\t

\n\t\t\t\t\t{comment.body}\n\t\t\t\t

\n\t\t\t\t
\n\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t{loc.COMMENTS_RESOURCE_ACTION_APPROVE}\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t{loc.COMMENTS_RESOURCE_ACTION_SPAM}\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t{loc.COMMENTS_RESOURCE_ACTION_DELETE}\n\t\t\t\t\t\n\t\t\t\t
\n\t\t\t
\n\t\t
\n\t);\n}\n", + "content": "\"use client\";\n\nimport type { SerializedComment } from \"../../../types\";\nimport {\n\tuseSuspenseComments,\n\tuseUpdateCommentStatus,\n\tuseDeleteComment,\n} from \"@btst/stack/plugins/comments/client/hooks\";\nimport { CommentThread } from \"../comment-thread\";\nimport { Button } from \"@/components/ui/button\";\nimport { Badge } from \"@/components/ui/badge\";\nimport {\n\tAvatar,\n\tAvatarFallback,\n\tAvatarImage,\n} from \"@/components/ui/avatar\";\nimport { CheckCircle, ShieldOff, Trash2 } from \"lucide-react\";\nimport { formatDistanceToNow } from \"date-fns\";\nimport { CanAccess, useNotify, useTranslate } from \"@btst/stack/context\";\nimport type { CommentsLocalization } from \"../../localization\";\nimport { getInitials } from \"../../utils\";\n\ninterface ResourceCommentsPageProps {\n\tresourceId: string;\n\tresourceType: string;\n\tapiBaseURL: string;\n\tapiBasePath: string;\n\theaders?: HeadersInit;\n\tcurrentUserId?: string;\n\tloginHref?: string;\n\tlocalization?: Partial;\n}\n\nexport function ResourceCommentsPage({\n\tresourceId,\n\tresourceType,\n\tapiBaseURL,\n\tapiBasePath,\n\theaders,\n\tcurrentUserId,\n\tloginHref,\n\tlocalization,\n}: ResourceCommentsPageProps) {\n\tconst t = useTranslate();\n\tconst notify = useNotify();\n\tconst config = { apiBaseURL, apiBasePath, headers };\n\n\tconst {\n\t\tcomments: pendingComments,\n\t\ttotal: pendingTotal,\n\t\trefetch,\n\t} = useSuspenseComments(config, {\n\t\tresourceId,\n\t\tresourceType,\n\t\tstatus: \"pending\",\n\t});\n\n\tconst updateStatus = useUpdateCommentStatus(config);\n\tconst deleteMutation = useDeleteComment(config);\n\n\tconst handleApprove = async (id: string) => {\n\t\ttry {\n\t\t\tawait updateStatus.mutateAsync({ id, status: \"approved\" });\n\t\t\tnotify.success(\n\t\t\t\tlocalization?.COMMENTS_RESOURCE_TOAST_APPROVED ??\n\t\t\t\t\tt(\"comments.resource.toastApproved\", \"Comment approved\"),\n\t\t\t);\n\t\t\trefetch();\n\t\t} catch {\n\t\t\tnotify.error(\n\t\t\t\tlocalization?.COMMENTS_RESOURCE_TOAST_APPROVE_ERROR ??\n\t\t\t\t\tt(\"comments.resource.toastApproveError\", \"Failed to approve\"),\n\t\t\t);\n\t\t}\n\t};\n\n\tconst handleSpam = async (id: string) => {\n\t\ttry {\n\t\t\tawait updateStatus.mutateAsync({ id, status: \"spam\" });\n\t\t\tnotify.success(\n\t\t\t\tlocalization?.COMMENTS_RESOURCE_TOAST_SPAM ??\n\t\t\t\t\tt(\"comments.resource.toastSpam\", \"Marked as spam\"),\n\t\t\t);\n\t\t\trefetch();\n\t\t} catch {\n\t\t\tnotify.error(\n\t\t\t\tlocalization?.COMMENTS_RESOURCE_TOAST_SPAM_ERROR ??\n\t\t\t\t\tt(\"comments.resource.toastSpamError\", \"Failed to update\"),\n\t\t\t);\n\t\t}\n\t};\n\n\tconst handleDelete = async (id: string) => {\n\t\tconst confirmMessage =\n\t\t\tlocalization?.COMMENTS_RESOURCE_DELETE_CONFIRM ??\n\t\t\tt(\"comments.resource.deleteConfirm\", \"Delete this comment?\");\n\t\tif (!window.confirm(confirmMessage)) return;\n\t\ttry {\n\t\t\tawait deleteMutation.mutateAsync(id);\n\t\t\tnotify.success(\n\t\t\t\tlocalization?.COMMENTS_RESOURCE_TOAST_DELETED ??\n\t\t\t\t\tt(\"comments.resource.toastDeleted\", \"Comment deleted\"),\n\t\t\t);\n\t\t\trefetch();\n\t\t} catch {\n\t\t\tnotify.error(\n\t\t\t\tlocalization?.COMMENTS_RESOURCE_TOAST_DELETE_ERROR ??\n\t\t\t\t\tt(\"comments.resource.toastDeleteError\", \"Failed to delete\"),\n\t\t\t);\n\t\t}\n\t};\n\n\treturn (\n\t\t\n\t\t\t
\n\t\t\t\t

\n\t\t\t\t\t{localization?.COMMENTS_RESOURCE_TITLE ??\n\t\t\t\t\t\tt(\"comments.resource.title\", \"Comments\")}\n\t\t\t\t

\n\t\t\t\t

\n\t\t\t\t\t{resourceType}/{resourceId}\n\t\t\t\t

\n\t\t\t
\n\n\t\t\t{pendingTotal > 0 && (\n\t\t\t\t
\n\t\t\t\t\t

\n\t\t\t\t\t\t{localization?.COMMENTS_RESOURCE_PENDING_SECTION ??\n\t\t\t\t\t\t\tt(\"comments.resource.pendingSection\", \"Pending Review\")}\n\t\t\t\t\t\t{pendingTotal}\n\t\t\t\t\t

\n\t\t\t\t\t
\n\t\t\t\t\t\t{pendingComments.map((comment) => (\n\t\t\t\t\t\t\t handleApprove(comment.id)}\n\t\t\t\t\t\t\t\tonSpam={() => handleSpam(comment.id)}\n\t\t\t\t\t\t\t\tonDelete={() => handleDelete(comment.id)}\n\t\t\t\t\t\t\t\tisUpdating={updateStatus.isPending}\n\t\t\t\t\t\t\t\tisDeleting={deleteMutation.isPending}\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t))}\n\t\t\t\t\t
\n\t\t\t\t
\n\t\t\t)}\n\n\t\t\t
\n\t\t\t\t

\n\t\t\t\t\t{localization?.COMMENTS_RESOURCE_THREAD_SECTION ??\n\t\t\t\t\t\tt(\"comments.resource.threadSection\", \"Thread\")}\n\t\t\t\t

\n\t\t\t\t\n\t\t\t
\n\t\t
\n\t);\n}\n\nfunction PendingCommentRow({\n\tcomment,\n\tlocalization,\n\tonApprove,\n\tonSpam,\n\tonDelete,\n\tisUpdating,\n\tisDeleting,\n}: {\n\tcomment: SerializedComment;\n\tlocalization?: Partial;\n\tonApprove: () => void;\n\tonSpam: () => void;\n\tonDelete: () => void;\n\tisUpdating: boolean;\n\tisDeleting: boolean;\n}) {\n\tconst t = useTranslate();\n\treturn (\n\t\t
\n\t\t\t\n\t\t\t\t{comment.resolvedAvatarUrl && (\n\t\t\t\t\t\n\t\t\t\t)}\n\t\t\t\t\n\t\t\t\t\t{getInitials(comment.resolvedAuthorName)}\n\t\t\t\t\n\t\t\t\n\t\t\t
\n\t\t\t\t
\n\t\t\t\t\t\n\t\t\t\t\t\t{comment.resolvedAuthorName}\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\t{formatDistanceToNow(new Date(comment.createdAt), {\n\t\t\t\t\t\t\taddSuffix: true,\n\t\t\t\t\t\t})}\n\t\t\t\t\t\n\t\t\t\t
\n\t\t\t\t

\n\t\t\t\t\t{comment.body}\n\t\t\t\t

\n\t\t\t\t
\n\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t{localization?.COMMENTS_RESOURCE_ACTION_APPROVE ??\n\t\t\t\t\t\t\t\tt(\"comments.resource.actionApprove\", \"Approve\")}\n\t\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t{localization?.COMMENTS_RESOURCE_ACTION_SPAM ??\n\t\t\t\t\t\t\t\tt(\"comments.resource.actionSpam\", \"Spam\")}\n\t\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t{localization?.COMMENTS_RESOURCE_ACTION_DELETE ??\n\t\t\t\t\t\t\t\tt(\"comments.resource.actionDelete\", \"Delete\")}\n\t\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t
\n\t\t\t
\n\t\t
\n\t);\n}\n", "target": "src/components/btst/comments/client/components/pages/resource-comments-page.internal.tsx" }, { "path": "btst/comments/client/components/pages/resource-comments-page.tsx", "type": "registry:page", - "content": "\"use client\";\n\nimport { lazy } from \"react\";\nimport { ComposedRoute } from \"@btst/stack/client/components\";\nimport { usePluginOverrides } from \"@btst/stack/context\";\nimport type { CommentsPluginOverrides } from \"../../overrides\";\nimport { COMMENTS_LOCALIZATION } from \"../../localization\";\nimport { useRouteLifecycle } from \"@/hooks/use-route-lifecycle\";\nimport { PageWrapper } from \"../shared/page-wrapper\";\nimport { useResolvedCurrentUserId } from \"../../utils\";\n\nconst ResourceCommentsPageInternal = lazy(() =>\n\timport(\"./resource-comments-page.internal\").then((m) => ({\n\t\tdefault: m.ResourceCommentsPage,\n\t})),\n);\n\nfunction ResourceCommentsSkeleton() {\n\treturn (\n\t\t
\n\t\t\t
\n\t\t\t
\n\t\t\t
\n\t\t
\n\t);\n}\n\nexport function ResourceCommentsPageComponent({\n\tresourceId,\n\tresourceType,\n}: {\n\tresourceId: string;\n\tresourceType: string;\n}) {\n\treturn (\n\t\t (\n\t\t\t\t\n\t\t\t)}\n\t\t\tLoadingComponent={ResourceCommentsSkeleton}\n\t\t\tonError={(error) =>\n\t\t\t\tconsole.error(\"[btst/comments] Resource comments error:\", error)\n\t\t\t}\n\t\t/>\n\t);\n}\n\nfunction ResourceCommentsPageWrapper({\n\tresourceId,\n\tresourceType,\n}: {\n\tresourceId: string;\n\tresourceType: string;\n}) {\n\tconst overrides = usePluginOverrides(\"comments\");\n\tconst loc = { ...COMMENTS_LOCALIZATION, ...overrides.localization };\n\tconst resolvedUserId = useResolvedCurrentUserId(overrides.currentUserId);\n\n\tuseRouteLifecycle({\n\t\trouteName: \"resourceComments\",\n\t\tcontext: {\n\t\t\tpath: `/comments/${resourceType}/${resourceId}`,\n\t\t\tparams: { resourceId, resourceType },\n\t\t\tisSSR: typeof window === \"undefined\",\n\t\t},\n\t\toverrides,\n\t\tbeforeRenderHook: (o, context) => {\n\t\t\tif (o.onBeforeResourceCommentsRendered) {\n\t\t\t\treturn o.onBeforeResourceCommentsRendered(\n\t\t\t\t\tresourceType,\n\t\t\t\t\tresourceId,\n\t\t\t\t\tcontext,\n\t\t\t\t);\n\t\t\t}\n\t\t\treturn true;\n\t\t},\n\t});\n\n\treturn (\n\t\t\n\t\t\t\n\t\t\n\t);\n}\n", + "content": "\"use client\";\n\nimport { lazy } from \"react\";\nimport { ComposedRoute } from \"@btst/stack/client/components\";\nimport { usePluginOverrides } from \"@btst/stack/context\";\nimport type { CommentsPluginOverrides } from \"../../overrides\";\nimport { useRouteLifecycle } from \"@/hooks/use-route-lifecycle\";\nimport { PageWrapper } from \"../shared/page-wrapper\";\nimport { useResolvedCurrentUserId } from \"../../utils\";\n\nconst ResourceCommentsPageInternal = lazy(() =>\n\timport(\"./resource-comments-page.internal\").then((m) => ({\n\t\tdefault: m.ResourceCommentsPage,\n\t})),\n);\n\nfunction ResourceCommentsSkeleton() {\n\treturn (\n\t\t
\n\t\t\t
\n\t\t\t
\n\t\t\t
\n\t\t
\n\t);\n}\n\nexport function ResourceCommentsPageComponent({\n\tresourceId,\n\tresourceType,\n}: {\n\tresourceId: string;\n\tresourceType: string;\n}) {\n\treturn (\n\t\t (\n\t\t\t\t\n\t\t\t)}\n\t\t\tLoadingComponent={ResourceCommentsSkeleton}\n\t\t\tonError={(error) =>\n\t\t\t\tconsole.error(\"[btst/comments] Resource comments error:\", error)\n\t\t\t}\n\t\t/>\n\t);\n}\n\nfunction ResourceCommentsPageWrapper({\n\tresourceId,\n\tresourceType,\n}: {\n\tresourceId: string;\n\tresourceType: string;\n}) {\n\tconst overrides = usePluginOverrides(\"comments\");\n\tconst resolvedUserId = useResolvedCurrentUserId(overrides.currentUserId);\n\n\tuseRouteLifecycle({\n\t\trouteName: \"resourceComments\",\n\t\tcontext: {\n\t\t\tpath: `/comments/${resourceType}/${resourceId}`,\n\t\t\tparams: { resourceId, resourceType },\n\t\t\tisSSR: typeof window === \"undefined\",\n\t\t},\n\t\toverrides,\n\t\tbeforeRenderHook: (o, context) => {\n\t\t\tif (o.onBeforeResourceCommentsRendered) {\n\t\t\t\treturn o.onBeforeResourceCommentsRendered(\n\t\t\t\t\tresourceType,\n\t\t\t\t\tresourceId,\n\t\t\t\t\tcontext,\n\t\t\t\t);\n\t\t\t}\n\t\t\treturn true;\n\t\t},\n\t});\n\n\treturn (\n\t\t\n\t\t\t\n\t\t\n\t);\n}\n", "target": "src/components/btst/comments/client/components/pages/resource-comments-page.tsx" }, { @@ -102,7 +96,7 @@ { "path": "btst/comments/client/components/shared/pagination.tsx", "type": "registry:component", - "content": "\"use client\";\n\nimport { usePluginOverrides } from \"@btst/stack/context\";\nimport type { CommentsPluginOverrides } from \"../../overrides\";\nimport { COMMENTS_LOCALIZATION } from \"../../localization\";\nimport { PaginationControls } from \"@/components/ui/pagination-controls\";\n\ninterface PaginationProps {\n\tcurrentPage: number;\n\ttotalPages: number;\n\tonPageChange: (page: number) => void;\n\ttotal: number;\n\tlimit: number;\n\toffset: number;\n}\n\nexport function Pagination({\n\tcurrentPage,\n\ttotalPages,\n\tonPageChange,\n\ttotal,\n\tlimit,\n\toffset,\n}: PaginationProps) {\n\tconst { localization: customLocalization } =\n\t\tusePluginOverrides(\"comments\");\n\tconst localization = { ...COMMENTS_LOCALIZATION, ...customLocalization };\n\n\treturn (\n\t\t\n\t);\n}\n", + "content": "\"use client\";\n\nimport { usePluginOverrides, useTranslate } from \"@btst/stack/context\";\nimport type { CommentsPluginOverrides } from \"../../overrides\";\nimport { PaginationControls } from \"@/components/ui/pagination-controls\";\n\ninterface PaginationProps {\n\tcurrentPage: number;\n\ttotalPages: number;\n\tonPageChange: (page: number) => void;\n\ttotal: number;\n\tlimit: number;\n\toffset: number;\n}\n\nexport function Pagination({\n\tcurrentPage,\n\ttotalPages,\n\tonPageChange,\n\ttotal,\n\tlimit,\n\toffset,\n}: PaginationProps) {\n\tconst t = useTranslate();\n\tconst { localization } =\n\t\tusePluginOverrides(\"comments\");\n\n\treturn (\n\t\t\n\t);\n}\n", "target": "src/components/btst/comments/client/components/shared/pagination.tsx" }, { @@ -138,7 +132,7 @@ { "path": "btst/comments/client/utils.ts", "type": "registry:lib", - "content": "import { useState, useEffect } from \"react\";\nimport type { CommentsPluginOverrides } from \"./overrides\";\nimport { toError as toErrorShared } from \"../error-utils\";\n\n/**\n * Resolves `currentUserId` from the plugin overrides, supporting both a static\n * string and a sync/async function. Returns `undefined` until resolution completes.\n */\nexport function useResolvedCurrentUserId(\n\traw: CommentsPluginOverrides[\"currentUserId\"],\n): string | undefined {\n\tconst [resolved, setResolved] = useState(\n\t\ttypeof raw === \"string\" ? raw : undefined,\n\t);\n\n\tuseEffect(() => {\n\t\tif (typeof raw === \"function\") {\n\t\t\tvoid Promise.resolve(raw())\n\t\t\t\t.then((id) => setResolved(id ?? undefined))\n\t\t\t\t.catch((err: unknown) => {\n\t\t\t\t\tconsole.error(\n\t\t\t\t\t\t\"[btst/comments] Failed to resolve currentUserId:\",\n\t\t\t\t\t\terr,\n\t\t\t\t\t);\n\t\t\t\t});\n\t\t} else {\n\t\t\tsetResolved(raw ?? undefined);\n\t\t}\n\t}, [raw]);\n\n\treturn resolved;\n}\n\n/**\n * Normalise any thrown value into an Error.\n *\n * Handles three shapes:\n * 1. Already an Error — returned as-is.\n * 2. A plain object — message is taken from `.message`, then `.error` (API\n * error-response shape), then JSON.stringify. All original properties are\n * copied onto the Error via Object.assign so callers can inspect them.\n * 3. Anything else — converted via String().\n */\nexport const toError = toErrorShared;\n\nexport function getInitials(name: string | null | undefined): string {\n\tif (!name) return \"?\";\n\treturn name\n\t\t.split(\" \")\n\t\t.filter(Boolean)\n\t\t.slice(0, 2)\n\t\t.map((n) => n[0])\n\t\t.join(\"\")\n\t\t.toUpperCase();\n}\n", + "content": "import { useState, useEffect } from \"react\";\nimport type { CommentsPluginOverrides } from \"./overrides\";\n\n/**\n * Resolves `currentUserId` from the plugin overrides, supporting both a static\n * string and a sync/async function. Returns `undefined` until resolution completes.\n */\nexport function useResolvedCurrentUserId(\n\traw: CommentsPluginOverrides[\"currentUserId\"],\n): string | undefined {\n\tconst [resolved, setResolved] = useState(\n\t\ttypeof raw === \"string\" ? raw : undefined,\n\t);\n\n\tuseEffect(() => {\n\t\tif (typeof raw === \"function\") {\n\t\t\tvoid Promise.resolve(raw())\n\t\t\t\t.then((id) => setResolved(id ?? undefined))\n\t\t\t\t.catch((err: unknown) => {\n\t\t\t\t\tconsole.error(\n\t\t\t\t\t\t\"[btst/comments] Failed to resolve currentUserId:\",\n\t\t\t\t\t\terr,\n\t\t\t\t\t);\n\t\t\t\t});\n\t\t} else {\n\t\t\tsetResolved(raw ?? undefined);\n\t\t}\n\t}, [raw]);\n\n\treturn resolved;\n}\n\nexport function getInitials(name: string | null | undefined): string {\n\tif (!name) return \"?\";\n\treturn name\n\t\t.split(\" \")\n\t\t.filter(Boolean)\n\t\t.slice(0, 2)\n\t\t.map((n) => n[0])\n\t\t.join(\"\")\n\t\t.toUpperCase();\n}\n", "target": "src/components/btst/comments/client/utils.ts" }, { diff --git a/packages/stack/scripts/build-registry.ts b/packages/stack/scripts/build-registry.ts index 4d35b276..3926013c 100644 --- a/packages/stack/scripts/build-registry.ts +++ b/packages/stack/scripts/build-registry.ts @@ -280,7 +280,7 @@ const PLUGINS: PluginConfig[] = [ "Customize the UI layer while keeping data-fetching in @btst/stack.", extraNpmDeps: ["date-fns"], extraRegistryDeps: [], - pluginRootFiles: ["types.ts", "schemas.ts", "error-utils.ts"], + pluginRootFiles: ["types.ts", "schemas.ts"], }, { name: "ui-builder", diff --git a/packages/stack/src/__tests__/comments-query-keys.test.ts b/packages/stack/src/__tests__/comments-query-keys.test.ts new file mode 100644 index 00000000..77501ead --- /dev/null +++ b/packages/stack/src/__tests__/comments-query-keys.test.ts @@ -0,0 +1,108 @@ +/** + * SSG guard: the factory-generated comments query keys must stay deep-equal + * to the `COMMENTS_QUERY_KEYS` builders (and their shared discriminators) + * used by loader/SSG prefetch paths. Key drift breaks React Query cache + * hydration silently. + */ +import { describe, expect, it, vi } from "vitest"; +import { + COMMENTS_QUERY_KEYS, + commentCountDiscriminator, +} from "../plugins/comments/api/query-key-defs"; +import { createCommentsQueryKeys } from "../plugins/comments/query-keys"; + +const client = vi.fn() as any; + +describe("comments query keys match SSG prefetch keys", () => { + const queries = createCommentsQueryKeys(client); + + it("comments list keys match for default params", () => { + expect([...queries.comments.list({}).queryKey]).toEqual([ + ...COMMENTS_QUERY_KEYS.commentsList({}), + ]); + }); + + it("comments list keys match the moderation loader prefetch key", () => { + const params = { status: "pending" as const, limit: 20, offset: 0 }; + expect([...queries.comments.list(params).queryKey]).toEqual([ + ...COMMENTS_QUERY_KEYS.commentsList(params), + ]); + }); + + it("comments list keys match the user-comments loader prefetch key", () => { + const params = { + authorId: "user-1", + sort: "desc" as const, + limit: 20, + offset: 0, + }; + expect([...queries.comments.list(params).queryKey]).toEqual([ + ...COMMENTS_QUERY_KEYS.commentsList(params), + ]); + }); + + it("distinguishes parentId null from undefined (separate cache entries)", () => { + const withNull = queries.comments.list({ parentId: null }).queryKey; + const withUndefined = queries.comments.list({}).queryKey; + expect([...withNull]).toEqual([ + ...COMMENTS_QUERY_KEYS.commentsList({ parentId: null }), + ]); + expect(withNull).not.toEqual(withUndefined); + }); + + it("segregates caches per currentUserId without leaking it to the key builders", () => { + const params = { + resourceId: "post-1", + resourceType: "post", + currentUserId: "user-9", + }; + expect([...queries.comments.list(params).queryKey]).toEqual([ + ...COMMENTS_QUERY_KEYS.commentsList(params), + ]); + }); + + it("thread keys match and exclude offset (pageParam-driven)", () => { + const params = { + resourceId: "post-1", + resourceType: "post", + parentId: null, + status: "approved" as const, + sort: "asc" as const, + limit: 10, + }; + expect([...queries.commentsThread.list(params).queryKey]).toEqual([ + ...COMMENTS_QUERY_KEYS.commentsThread(params), + ]); + }); + + it("count keys use the shared discriminator", () => { + // Note: COMMENTS_QUERY_KEYS.commentCount uses the ["comments", "count"] + // prefix while the runtime factory has always used + // ["commentCount", "byResource"] — a pre-existing divergence. The + // discriminator cell (the part that actually varies) must stay shared. + const params = { resourceId: "post-1", resourceType: "post" }; + expect([...queries.commentCount.byResource(params).queryKey]).toEqual([ + "commentCount", + "byResource", + commentCountDiscriminator(params), + ]); + expect(COMMENTS_QUERY_KEYS.commentCount(params)[2]).toEqual( + commentCountDiscriminator(params), + ); + }); + + it("exposes the same _def prefixes as the previous factory", () => { + expect([...queries.comments._def]).toEqual(["comments"]); + expect([...queries.comments.list._def]).toEqual(["comments", "list"]); + expect([...queries.commentCount._def]).toEqual(["commentCount"]); + expect([...queries.commentCount.byResource._def]).toEqual([ + "commentCount", + "byResource", + ]); + expect([...queries.commentsThread._def]).toEqual(["commentsThread"]); + expect([...queries.commentsThread.list._def]).toEqual([ + "commentsThread", + "list", + ]); + }); +}); diff --git a/packages/stack/src/plugins/client/resource/queries.ts b/packages/stack/src/plugins/client/resource/queries.ts index 94d8bd37..54b3a9b7 100644 --- a/packages/stack/src/plugins/client/resource/queries.ts +++ b/packages/stack/src/plugins/client/resource/queries.ts @@ -237,11 +237,17 @@ export async function runResourceQuery( /** * Executes a mutation declaration: fetch → error-check → unwrap. + * + * `headers` supports plugins whose public hooks take an explicit client + * config (e.g. embeddable components) instead of resolving it from + * `usePluginOverrides` — same as the `headers` parameter on + * `createResourceQueryKeys`. */ export async function runResourceMutation( client: ResourceClient, def: ResourceMutationDef, vars: unknown, + headers?: HeadersInit, ): Promise { const { body, params, query } = def.input ? def.input(vars) @@ -252,6 +258,7 @@ export async function runResourceMutation( ...(body !== undefined ? { body } : {}), ...(params !== undefined ? { params } : {}), ...(query !== undefined ? { query } : {}), + ...(headers !== undefined ? { headers } : {}), }); if (isErrorResponse(response)) { diff --git a/packages/stack/src/plugins/comments/__tests__/client-sweep.test.tsx b/packages/stack/src/plugins/comments/__tests__/client-sweep.test.tsx new file mode 100644 index 00000000..f5d33df8 --- /dev/null +++ b/packages/stack/src/plugins/comments/__tests__/client-sweep.test.tsx @@ -0,0 +1,501 @@ +// @vitest-environment jsdom +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +// Core primitives MUST be imported from the package entry (not relative src +// paths) so they share module identity — and React context — with the +// comments components, which resolve `@btst/stack/*` via package +// self-reference. +import { + StackProvider, + type StackAuthProvider, + type StackI18nProvider, +} from "@btst/stack/context"; +import { ModerationPage } from "../client/components/pages/moderation-page.internal"; +import { UserCommentsPage } from "../client/components/pages/my-comments-page.internal"; +import { CommentForm } from "../client/components/comment-form"; +import type { SerializedComment } from "../types"; + +(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; + +// jsdom lacks these APIs used by Radix +(globalThis as any).ResizeObserver ??= class { + observe() {} + unobserve() {} + disconnect() {} +}; +Element.prototype.scrollIntoView ??= () => {}; +window.scrollTo ??= (() => {}) as typeof window.scrollTo; + +const hooks = vi.hoisted(() => ({ + useComments: vi.fn(), + useSuspenseComments: vi.fn(), + useSuspenseModerationComments: vi.fn(), + useInfiniteComments: vi.fn(), + useCommentCount: vi.fn(), + usePostComment: vi.fn(), + useUpdateComment: vi.fn(), + useApproveComment: vi.fn(), + useUpdateCommentStatus: vi.fn(), + useDeleteComment: vi.fn(), + useToggleLike: vi.fn(), +})); + +vi.mock("../client/hooks/use-comments", () => hooks); + +const comment: SerializedComment = { + id: "c1", + resourceId: "post-1", + resourceType: "post", + parentId: null, + authorId: "author-1", + resolvedAuthorName: "Alice", + resolvedAvatarUrl: null, + body: "Nice post!", + status: "pending", + likes: 0, + isLikedByCurrentUser: false, + editedAt: null, + createdAt: new Date("2024-01-01").toISOString(), + updatedAt: new Date("2024-01-01").toISOString(), + replyCount: 0, +} as unknown as SerializedComment; + +let container: HTMLDivElement; +let root: Root; + +beforeEach(() => { + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + + hooks.useSuspenseModerationComments.mockReturnValue({ + comments: [comment], + total: 1, + limit: 20, + offset: 0, + totalPages: 1, + refetch: vi.fn(), + }); + hooks.useSuspenseComments.mockReturnValue({ + comments: [comment], + total: 1, + refetch: vi.fn(), + }); + hooks.useUpdateCommentStatus.mockReturnValue({ + mutateAsync: vi.fn().mockResolvedValue(comment), + isPending: false, + }); + hooks.useDeleteComment.mockReturnValue({ + mutateAsync: vi.fn().mockResolvedValue({ success: true }), + isPending: false, + }); +}); + +afterEach(async () => { + await act(async () => { + root.unmount(); + }); + container.remove(); + document.body.innerHTML = ""; + vi.clearAllMocks(); + vi.restoreAllMocks(); +}); + +async function render(ui: React.ReactElement) { + await act(async () => { + root.render(ui); + }); +} + +function texts(): string { + return document.body.textContent ?? ""; +} + +function createMockRouter(initial = "") { + let params = new URLSearchParams(initial); + const setSearchParams = vi.fn( + (next: URLSearchParams, _opts?: { replace?: boolean }) => { + params = new URLSearchParams(next.toString()); + }, + ); + return { + navigate: vi.fn(), + getSearchParams: () => new URLSearchParams(params.toString()), + setSearchParams, + }; +} + +const commentsOverrides = { + apiBaseURL: "http://test.local", + apiBasePath: "/api/data", +}; + +const pageProps = { + apiBaseURL: "http://test.local", + apiBasePath: "/api/data", +}; + +function typeInto(element: HTMLElement, value: string) { + const proto = + element instanceof HTMLTextAreaElement + ? HTMLTextAreaElement.prototype + : HTMLInputElement.prototype; + const setValue = Object.getOwnPropertyDescriptor(proto, "value")!.set!; + setValue.call(element, value); + element.dispatchEvent(new Event("input", { bubbles: true })); +} + +describe("ModerationPage row actions (CanAccess)", () => { + function renderModerationPage( + auth?: StackAuthProvider, + notify?: { + success: ReturnType; + error: ReturnType; + }, + router = createMockRouter(), + ) { + return render( + + + , + ); + } + + it("shows view, approve, spam and delete buttons without an auth provider", async () => { + await renderModerationPage(); + + const row = container.querySelector('[data-testid="moderation-row"]')!; + expect(row.querySelector('[data-testid="view-button"]')).toBeTruthy(); + expect(row.querySelector('[data-testid="approve-button"]')).toBeTruthy(); + expect(row.querySelector('[data-testid="spam-button"]')).toBeTruthy(); + expect(row.querySelector('[data-testid="delete-button"]')).toBeTruthy(); + }); + + it("hides approve/spam when can() denies comments:comment/moderate", async () => { + const can = vi.fn( + ({ resource, action }: { resource: string; action: string }) => + !(resource === "comments:comment" && action === "moderate"), + ); + const auth: StackAuthProvider = { + getIdentity: () => ({ id: "user-1" }), + can, + }; + + await renderModerationPage(auth); + + const row = container.querySelector('[data-testid="moderation-row"]')!; + expect(row.querySelector('[data-testid="view-button"]')).toBeTruthy(); + expect(row.querySelector('[data-testid="approve-button"]')).toBeNull(); + expect(row.querySelector('[data-testid="spam-button"]')).toBeNull(); + // Delete is a separate action and stays visible + expect(row.querySelector('[data-testid="delete-button"]')).toBeTruthy(); + expect(can).toHaveBeenCalledWith( + expect.objectContaining({ + resource: "comments:comment", + action: "moderate", + params: { id: comment.id }, + }), + ); + }); + + it("hides the delete button when can() denies comments:comment/delete", async () => { + const can = vi.fn( + ({ resource, action }: { resource: string; action: string }) => + !(resource === "comments:comment" && action === "delete"), + ); + const auth: StackAuthProvider = { + getIdentity: () => ({ id: "user-1" }), + can, + }; + + await renderModerationPage(auth); + + const row = container.querySelector('[data-testid="moderation-row"]')!; + expect(row.querySelector('[data-testid="delete-button"]')).toBeNull(); + expect(row.querySelector('[data-testid="approve-button"]')).toBeTruthy(); + }); + + it("notifies success through the notify provider after approving", async () => { + const notify = { success: vi.fn(), error: vi.fn() }; + + await renderModerationPage(undefined, notify); + + const approveButton = container.querySelector( + '[data-testid="approve-button"]', + )!; + await act(async () => { + approveButton.click(); + }); + + expect( + hooks.useUpdateCommentStatus.mock.results[0]!.value.mutateAsync, + ).toHaveBeenCalledWith({ id: comment.id, status: "approved" }); + expect(notify.success).toHaveBeenCalledWith("Comment approved"); + expect(notify.error).not.toHaveBeenCalled(); + }); +}); + +describe("ModerationPage tab/page state (useListState)", () => { + it("seeds tab and page from the URL", async () => { + const router = createMockRouter("tab=spam&page=3"); + + await render( + + + , + ); + + expect(hooks.useSuspenseModerationComments).toHaveBeenLastCalledWith( + expect.anything(), + expect.objectContaining({ status: "spam", page: 3 }), + ); + // Nothing is written back for a read-only render + expect(router.setSearchParams).not.toHaveBeenCalled(); + }); + + it("falls back to the pending tab and page 1 for mangled URL values", async () => { + const router = createMockRouter("tab=banana&page=-4"); + + await render( + + + , + ); + + expect(hooks.useSuspenseModerationComments).toHaveBeenLastCalledWith( + expect.anything(), + expect.objectContaining({ status: "pending", page: 1 }), + ); + }); + + it("writes the tab to the URL and resets the page on tab switch", async () => { + const router = createMockRouter("tab=spam&page=3"); + + await render( + + + , + ); + + const approvedTab = container.querySelector( + '[data-testid="tab-approved"]', + )!; + // Radix Tabs triggers activate on mousedown (not click) — dispatch a + // real MouseEvent so onValueChange fires in jsdom. + await act(async () => { + approvedTab.dispatchEvent( + new MouseEvent("mousedown", { bubbles: true, button: 0 }), + ); + approvedTab.click(); + }); + + expect(router.setSearchParams).toHaveBeenCalled(); + const [written] = router.setSearchParams.mock.calls.at(-1)!; + expect(written.get("tab")).toBe("approved"); + // page resets to the default (1) and defaults are omitted from the URL + expect(written.get("page")).toBeNull(); + expect(hooks.useSuspenseModerationComments).toHaveBeenLastCalledWith( + expect.anything(), + expect.objectContaining({ status: "approved", page: 1 }), + ); + }); +}); + +describe("UserCommentsPage (login gate + useNotify + useListState)", () => { + function renderUserComments( + currentUserId?: string, + notify?: { + success: ReturnType; + error: ReturnType; + }, + router = createMockRouter(), + ) { + return render( + + + , + ); + } + + it("shows the login prompt when no user is resolved", async () => { + await renderUserComments(undefined); + + expect( + container.querySelector('[data-testid="my-comments-login-prompt"]'), + ).toBeTruthy(); + expect(texts()).toContain("Please log in to view your comments"); + expect(hooks.useSuspenseComments).not.toHaveBeenCalled(); + }); + + it("seeds the page from the URL into the query offset", async () => { + const router = createMockRouter("page=2"); + + await renderUserComments("user-1", undefined, router); + + expect(hooks.useSuspenseComments).toHaveBeenLastCalledWith( + expect.anything(), + expect.objectContaining({ authorId: "user-1", offset: 20 }), + ); + }); + + it("notifies success through the notify provider after deleting", async () => { + const notify = { success: vi.fn(), error: vi.fn() }; + + await renderUserComments("user-1", notify); + + const deleteButton = container.querySelector( + '[data-testid="my-comment-delete-button"]', + )!; + await act(async () => { + deleteButton.click(); + }); + + // Confirm in the AlertDialog (rendered in a portal on document.body). + const confirmButton = Array.from( + document.querySelectorAll("button"), + ) + .filter((button) => button.textContent === "Delete") + .at(-1); + expect(confirmButton).toBeTruthy(); + await act(async () => { + confirmButton!.click(); + }); + + expect( + hooks.useDeleteComment.mock.results[0]!.value.mutateAsync, + ).toHaveBeenCalledWith(comment.id); + expect(notify.success).toHaveBeenCalledWith("Comment deleted"); + expect(notify.error).not.toHaveBeenCalled(); + }); +}); + +describe("CommentForm inline field errors (StackError)", () => { + function renderForm(onSubmit: (body: string) => Promise) { + return render( + + + , + ); + } + + async function submitComment(body: string) { + const textarea = container.querySelector("textarea")!; + await act(async () => { + typeInto(textarea, body); + }); + const form = container.querySelector('[data-testid="comment-form"]')!; + await act(async () => { + form.dispatchEvent(new Event("submit", { bubbles: true })); + }); + } + + it("surfaces the server-side body field error inline", async () => { + const stackError = Object.assign(new Error("Validation failed"), { + errors: { body: ["Comment is too short"] }, + }); + await renderForm(vi.fn().mockRejectedValue(stackError)); + + await submitComment("hi"); + + const error = container.querySelector('[data-testid="comment-form-error"]'); + expect(error?.textContent).toBe("Comment is too short"); + }); + + it("falls back to the error message when there is no field error", async () => { + await renderForm(vi.fn().mockRejectedValue(new Error("Server exploded"))); + + await submitComment("hello there"); + + const error = container.querySelector('[data-testid="comment-form-error"]'); + expect(error?.textContent).toBe("Server exploded"); + }); +}); + +describe("comments i18n precedence (useTranslate + localization prop)", () => { + it("renders the English default without providers", async () => { + await render( + + + , + ); + + expect(texts()).toContain("Comment Moderation"); + }); + + it("routes strings through the i18n provider when configured", async () => { + const i18n: StackI18nProvider = { + translate: (key, defaultValue) => + key === "comments.moderation.title" + ? "Kommentar-Moderation" + : defaultValue, + }; + + await render( + + + , + ); + + expect(texts()).toContain("Kommentar-Moderation"); + }); + + it("lets the localization override win over the i18n provider", async () => { + const translate = vi.fn( + (key: string, _defaultValue: string) => `translated:${key}`, + ); + + await render( + + + , + ); + + expect(texts()).toContain("Custom title"); + expect(texts()).not.toContain("translated:comments.moderation.title"); + }); +}); diff --git a/packages/stack/src/plugins/comments/client/components/comment-form.tsx b/packages/stack/src/plugins/comments/client/components/comment-form.tsx index 56df86c6..8d2c4dee 100644 --- a/packages/stack/src/plugins/comments/client/components/comment-form.tsx +++ b/packages/stack/src/plugins/comments/client/components/comment-form.tsx @@ -3,10 +3,9 @@ import { useState, type ComponentType } from "react"; import { Button } from "@workspace/ui/components/button"; import { Textarea } from "@workspace/ui/components/textarea"; -import { - COMMENTS_LOCALIZATION, - type CommentsLocalization, -} from "../localization"; +import { useTranslate } from "@btst/stack/context"; +import type { StackError } from "@btst/stack/plugins/client"; +import type { CommentsLocalization } from "../localization"; export interface CommentFormProps { /** Current user's ID — required to post */ @@ -39,14 +38,17 @@ export function CommentForm({ onSubmit, onCancel, InputComponent, - localization: localizationProp, + localization, }: CommentFormProps) { - const loc = { ...COMMENTS_LOCALIZATION, ...localizationProp }; + const t = useTranslate(); const [body, setBody] = useState(initialBody); const [isPending, setIsPending] = useState(false); const [error, setError] = useState(null); - const resolvedSubmitLabel = submitLabel ?? loc.COMMENTS_FORM_POST_COMMENT; + const resolvedSubmitLabel = + submitLabel ?? + localization?.COMMENTS_FORM_POST_COMMENT ?? + t("comments.form.postComment", "Post comment"); const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); @@ -57,8 +59,16 @@ export function CommentForm({ await onSubmit(body.trim()); setBody(""); } catch (err) { + // Server-side Zod failures arrive as a StackError with a field-error + // map — surface the `body` message inline instead of the generic one. + const bodyError = (err as StackError)?.errors?.body; + const bodyMessage = Array.isArray(bodyError) ? bodyError[0] : bodyError; setError( - err instanceof Error ? err.message : loc.COMMENTS_FORM_SUBMIT_ERROR, + bodyMessage ?? + (err instanceof Error && err.message + ? err.message + : (localization?.COMMENTS_FORM_SUBMIT_ERROR ?? + t("comments.form.submitError", "Failed to submit comment"))), ); } finally { setIsPending(false); @@ -76,20 +86,33 @@ export function CommentForm({ value={body} onChange={setBody} disabled={isPending} - placeholder={loc.COMMENTS_FORM_PLACEHOLDER} + placeholder={ + localization?.COMMENTS_FORM_PLACEHOLDER ?? + t("comments.form.placeholder", "Write a comment…") + } /> ) : (