diff --git a/README.md b/README.md index d742852a..d3d6c09e 100644 --- a/README.md +++ b/README.md @@ -40,6 +40,7 @@ Enable the features you need and keep building your product. | **OpenAPI** | Auto-generated API documentation with interactive Scalar UI | | **Route Docs** | Auto-generated client route documentation with interactive navigation | | **Better Auth UI** | Beautiful shadcn/ui authentication components for better-auth | +| **Comments** | Commenting system with moderation, likes, and nested replies | Each plugin ships **frontend + backend together**: routes, APIs, database models, React components, SSR, and SEO — already wired. diff --git a/docs/content/docs/cli.mdx b/docs/content/docs/cli.mdx index fafb0aff..244fb555 100644 --- a/docs/content/docs/cli.mdx +++ b/docs/content/docs/cli.mdx @@ -124,3 +124,9 @@ Because the CLI executes your config file to extract the `dbSchema`, there are a ```bash SOME_VAR=value npx @btst/cli generate --config=lib/stack.ts --orm=prisma --output=schema.prisma ``` + +or using dotenv-cli: + +```bash +npx dotenv-cli -e .env.local -- npx @btst/cli generate --orm drizzle --config lib/stack.ts --output db/btst-schema.ts +``` \ No newline at end of file diff --git a/docs/content/docs/installation.mdx b/docs/content/docs/installation.mdx index 35db7142..8449717f 100644 --- a/docs/content/docs/installation.mdx +++ b/docs/content/docs/installation.mdx @@ -354,6 +354,7 @@ In order to use BTST, your application must meet the following requirements: export const GET = handler export const POST = handler export const PUT = handler + export const PATCH = handler export const DELETE = handler ``` @@ -390,6 +391,9 @@ In order to use BTST, your application must meet the following requirements: PUT: async ({ request }) => { return handler(request) }, + PATCH: async ({ request }) => { + return handler(request) + }, DELETE: async ({ request }) => { return handler(request) }, diff --git a/docs/content/docs/meta.json b/docs/content/docs/meta.json index dddd0005..02f60719 100644 --- a/docs/content/docs/meta.json +++ b/docs/content/docs/meta.json @@ -17,6 +17,7 @@ "plugins/form-builder", "plugins/ui-builder", "plugins/kanban", + "plugins/comments", "plugins/open-api", "plugins/route-docs", "plugins/better-auth-ui", diff --git a/docs/content/docs/plugins/blog.mdx b/docs/content/docs/plugins/blog.mdx index aa8894b0..12dd6cdf 100644 --- a/docs/content/docs/plugins/blog.mdx +++ b/docs/content/docs/plugins/blog.mdx @@ -509,6 +509,32 @@ overrides={{ }} ``` +**Slot overrides:** + +| Override | Type | Description | +|----------|------|-------------| +| `postBottomSlot` | `(post: SerializedPost) => ReactNode` | Render additional content below each blog post — use to embed a `CommentThread` | + +```tsx +import { CommentThread } from "@btst/stack/plugins/comments/client/components" + +overrides={{ + blog: { + // ... + postBottomSlot: (post) => ( + + ), + } +}} +``` + ## React Data Hooks and Types You can import the hooks from `"@btst/stack/plugins/blog/client/hooks"` to use in your components. diff --git a/docs/content/docs/plugins/comments.mdx b/docs/content/docs/plugins/comments.mdx new file mode 100644 index 00000000..6b73f7d2 --- /dev/null +++ b/docs/content/docs/plugins/comments.mdx @@ -0,0 +1,536 @@ +--- +title: Comments Plugin +description: Threaded comments with moderation, likes, replies, and embeddable CommentThread component +--- + +import { Tabs, Tab } from "fumadocs-ui/components/tabs"; +import { Callout } from "fumadocs-ui/components/callout"; + +The Comments plugin adds threaded commenting to any resource in your application — blog posts, Kanban tasks, CMS content, or your own custom pages. Comments are displayed with the embeddable `CommentThread` component and managed via a built-in moderation dashboard. + +**Key Features:** +- **Threaded replies** — Top-level comments and nested replies +- **Like system** — One like per user, optimistic UI updates, denormalized counter +- **Edit support** — Authors can edit their own comments; an "edited" timestamp is shown +- **Moderation dashboard** — Tabbed view (Pending / Approved / Spam) with bulk actions +- **Server-side user resolution** — `resolveUser` hook to embed author name and avatar in API responses +- **Optimistic updates** — New comments appear instantly with a "Pending approval" badge when `autoApprove: false` +- **Scroll-into-view lazy loading** — `CommentThread` is mounted only when it scrolls into the viewport + +## Installation + + +Ensure you followed the general [framework installation guide](/installation) first. + + +### 1. Add Plugin to Backend API + +Register the comments backend plugin in your `stack.ts` file: + +```ts title="lib/stack.ts" +import { stack } from "@btst/stack" +import { commentsBackendPlugin } from "@btst/stack/plugins/comments/api" + +const { handler, dbSchema } = stack({ + basePath: "/api/data", + plugins: { + comments: commentsBackendPlugin({ + // Automatically approve comments (default: false — requires moderation) + autoApprove: false, + + // Resolve author display name and avatar from your auth system + resolveUser: async (authorId) => { + const user = await db.users.findById(authorId) + return user + ? { name: user.displayName, avatarUrl: user.avatarUrl } + : null + }, + + // Lifecycle hooks — see Security section below for required configuration + onBeforeList: async (query, ctx) => { + // Restrict non-approved status filters (pending/spam) to admin sessions only + if (query.status && query.status !== "approved") { + const session = await getSession(ctx.headers) + if (!session?.user?.isAdmin) throw new Error("Admin access required") + } + }, + onBeforePost: async (comment, ctx) => { + // Required: resolve the authorId from the authenticated session + // Never use any ID supplied by the client + const session = await getSession(ctx.headers) + if (!session?.user) throw new Error("Authentication required") + return { authorId: session.user.id } + }, + onAfterPost: async (comment, ctx) => { + console.log("New comment posted:", comment.id) + }, + onBeforeEdit: async (commentId, update, ctx) => { + // Required: verify the caller owns the comment they are editing. + // Without this hook all edit requests return 403 by default. + const session = await getSession(ctx.headers) + if (!session?.user) throw new Error("Authentication required") + const comment = await db.comments.findById(commentId) + if (comment?.authorId !== session.user.id && !session.user.isAdmin) + throw new Error("Forbidden") + }, + onBeforeLike: async (commentId, authorId, ctx) => { + // Verify authorId matches the authenticated session + const session = await getSession(ctx.headers) + if (!session?.user) throw new Error("Authentication required") + if (authorId !== session.user.id) throw new Error("Forbidden") + }, + onBeforeStatusChange: async (commentId, status, ctx) => { + // Require admin/moderator role for the moderation endpoint + const session = await getSession(ctx.headers) + if (!session?.user?.isAdmin) throw new Error("Admin access required") + }, + onAfterApprove: async (comment, ctx) => { + // Send notification to comment author + await sendApprovalEmail(comment.authorId) + }, + onBeforeDelete: async (commentId, ctx) => { + // Require admin/moderator role — the Delete button is client-side only + const session = await getSession(ctx.headers) + if (!session?.user?.isAdmin) throw new Error("Admin access required") + }, + + // Required to show authors their own pending comments after posting. + // Without this hook the feature is disabled — client-supplied + // currentUserId is ignored server-side to prevent impersonation. + resolveCurrentUserId: async (ctx) => { + const session = await getSession(ctx.headers) + return session?.user?.id ?? null + }, + }) + }, + adapter: (db) => createMemoryAdapter(db)({}) +}) + +export { handler, dbSchema } +``` + +### 2. Add Plugin to Client + +Register the comments client plugin in your `stack-client.tsx` file: + +```tsx title="lib/stack-client.tsx" +import { createStackClient } from "@btst/stack/client" +import { commentsClientPlugin } from "@btst/stack/plugins/comments/client" +import { QueryClient } from "@tanstack/react-query" + +const getBaseURL = () => + process.env.BASE_URL || "http://localhost:3000" + +export const getStackClient = (queryClient: QueryClient) => { + const baseURL = getBaseURL() + return createStackClient({ + plugins: { + comments: commentsClientPlugin({ + queryClient, + siteBaseURL: baseURL, + siteBasePath: "/pages", + }), + }, + queryClient, + }) +} +``` + +### 3. Add CSS Import + + + +```css title="app/globals.css" +@import "@btst/stack/plugins/comments/css"; +``` + + +```css title="app/app.css" +@import "@btst/stack/plugins/comments/css"; +``` + + +```css title="src/styles/globals.css" +@import "@btst/stack/plugins/comments/css"; +``` + + + +### 4. Configure Overrides + +Add comments overrides to your layout file. You must also register the `CommentsPluginOverrides` type: + + + +```tsx title="app/pages/layout.tsx" +import type { CommentsPluginOverrides } from "@btst/stack/plugins/comments/client" + +type PluginOverrides = { + // ... existing plugins + comments: CommentsPluginOverrides +} + +// Inside your StackProvider overrides: +overrides={{ + comments: { + apiBaseURL: baseURL, + apiBasePath: "/api/data", + + // Access control for admin routes + onBeforeModerationPageRendered: async (context) => { + const session = await getSession() + if (!session?.user?.isAdmin) throw new Error("Admin access required") + }, + } +}} +``` + + +```tsx title="app/routes/pages/_layout.tsx" +import type { CommentsPluginOverrides } from "@btst/stack/plugins/comments/client" + +type PluginOverrides = { + // ... existing plugins + comments: CommentsPluginOverrides +} + +// Inside your StackProvider overrides: +overrides={{ + comments: { + apiBaseURL: baseURL, + apiBasePath: "/api/data", + onBeforeModerationPageRendered: async (context) => { + const session = await getSession() + if (!session?.user?.isAdmin) throw new Error("Admin access required") + }, + } +}} +``` + + +```tsx title="src/routes/pages/route.tsx" +import type { CommentsPluginOverrides } from "@btst/stack/plugins/comments/client" + +type PluginOverrides = { + // ... existing plugins + comments: CommentsPluginOverrides +} + +// Inside your StackProvider overrides: +overrides={{ + comments: { + apiBaseURL: baseURL, + apiBasePath: "/api/data", + onBeforeModerationPageRendered: async (context) => { + const session = await getSession() + if (!session?.user?.isAdmin) throw new Error("Admin access required") + }, + } +}} +``` + + + +## Embedding Comments + +The `CommentThread` component can be embedded anywhere — below a blog post, inside a Kanban task dialog, or on a custom page. + +```tsx +import { CommentThread } from "@btst/stack/plugins/comments/client/components" + + +``` + +### Props + +| Prop | Type | Required | Description | +|------|------|----------|-------------| +| `resourceId` | `string` | ✓ | Identifier for the resource (e.g. post slug, task ID) | +| `resourceType` | `string` | ✓ | Type of resource (`"blog-post"`, `"kanban-task"`, etc.) | +| `apiBaseURL` | `string` | ✓ | Base URL for API requests | +| `apiBasePath` | `string` | ✓ | Path prefix where the API is mounted | +| `currentUserId` | `string` | — | Authenticated user ID — enables edit/delete/pending badge | +| `loginHref` | `string` | — | Login page URL shown to unauthenticated users | +| `pageSize` | `number` | — | Comments per page. Falls back to `defaultCommentPageSize` from overrides, then 100. A "Load more" button appears when there are additional pages. | +| `components.Input` | `ComponentType` | — | Custom input component (default: ``) | +| `components.Renderer` | `ComponentType` | — | Custom renderer for comment body (default: ``) | + +### Blog Post Integration + +The blog plugin exposes a `postBottomSlot` override that renders below every post: + +```tsx +import { CommentThread } from "@btst/stack/plugins/comments/client/components" + +overrides={{ + blog: { + postBottomSlot: (post) => ( + + ), + } +}} +``` + +### Kanban Task Integration + +The Kanban plugin exposes a `taskDetailBottomSlot` override that renders at the bottom of the task detail dialog: + +```tsx +import { CommentThread } from "@btst/stack/plugins/comments/client/components" + +overrides={{ + kanban: { + taskDetailBottomSlot: (task) => ( + + ), + } +}} +``` + +### Comment Count Badge + +Use `CommentCount` to show the number of approved comments anywhere (e.g., in a post listing): + +```tsx +import { CommentCount } from "@btst/stack/plugins/comments/client/components" + + +``` + +## Moderation Dashboard + +The comments plugin adds a `/comments/moderation` admin route with: + +- **Tabbed views** — Pending, Approved, Spam +- **Bulk actions** — Approve, Mark as spam, Delete +- **Comment detail dialog** — View full body and metadata +- **Per-row actions** — Approve, spam, delete from the table row + +Access is controlled by the `onBeforeModerationPageRendered` hook in `CommentsPluginOverrides`. + +## Backend Configuration + +### `commentsBackendPlugin` Options + +| Option | Type | Default | Description | +|--------|------|---------|-------------| +| `autoApprove` | `boolean` | `false` | Automatically approve new comments | +| `resolveUser` | `(authorId: string) => Promise<{ name: string; avatarUrl?: string } \| null>` | — | Map author IDs to display info; returns `null` → shows `"[deleted]"` | +| `onBeforeList` | hook | — | Called before the comment list or count is returned. Throw to reject. When absent, any `status` filter other than `"approved"` is automatically rejected with 403 on both `GET /comments` and `GET /comments/count` — preventing anonymous access to, or probing of, the moderation queues. | +| `onBeforePost` | hook | **required** | Called before a comment is saved. Must return `{ authorId: string }` derived from the authenticated session. Throw to reject. Plugin throws at startup if absent. | +| `onAfterPost` | hook | — | Called after a comment is saved. | +| `onBeforeEdit` | hook | — | Called before a comment body is updated. Throw to reject. When absent, **all edit requests return 403** — preventing any unauthenticated caller from tampering with comment bodies. Configure to verify the caller owns the comment. | +| `onAfterEdit` | hook | — | Called after a comment body is updated. | +| `onBeforeLike` | hook | — | Called before a like is toggled. Throw to reject. When absent, **all like/unlike requests return 403** — preventing unauthenticated callers from toggling likes on behalf of arbitrary user IDs. Configure to verify `authorId` matches the authenticated session. | +| `onBeforeStatusChange` | hook | — | Called before moderation status is changed. Throw to reject. When absent, **all status-change requests return 403** — preventing unauthenticated callers from moderating comments. Configure to verify the caller has admin/moderator privileges. | +| `onAfterApprove` | hook | — | Called after a comment is approved. | +| `onBeforeDelete` | hook | — | Called before a comment is deleted. Throw to reject. When absent, **all delete requests return 403** — preventing unauthenticated callers from deleting comments. Configure to enforce admin-only access. | +| `onAfterDelete` | hook | — | Called after a comment is deleted. | +| `onBeforeListByAuthor` | hook | — | Called before returning comments filtered by `authorId`. Throw to reject. When absent, **any request with `authorId` returns 403** — preventing anonymous callers from reading any user's comment history. Use to verify `authorId` matches the authenticated session. | +| `resolveCurrentUserId` | hook | **required** | Resolve the current authenticated user's ID from the session. Used to safely include the user's own pending comments alongside approved ones in `GET /comments`. The client-supplied `currentUserId` query parameter is never trusted — identity is resolved exclusively via this hook. Return `null`/`undefined` for unauthenticated requests. Plugin throws at startup if absent. | + + +**`onBeforePost` and `resolveCurrentUserId` are both required.** `commentsBackendPlugin` throws at startup if either is absent. + +- `onBeforePost` must return `{ authorId: string }` derived from the session — `authorId` is intentionally absent from the POST body so clients can never forge authorship. +- `resolveCurrentUserId` must return the session-verified user ID (or `null` when unauthenticated) — the `?currentUserId=…` query parameter sent by the client is completely discarded. + + +### Server-Side API (`stack.api.comments`) + +Direct database access without HTTP, useful in Server Components, cron jobs, or AI tools: + +```ts +const items = await myStack.api.comments.listComments({ + resourceId: "my-post", + resourceType: "blog-post", + status: "approved", +}) + +const count = await myStack.api.comments.getCommentCount({ + resourceId: "my-post", + resourceType: "blog-post", +}) +``` + + +`stack().api.*` calls bypass authorization hooks. Callers are responsible for access control. + + +## React Hooks + +Import hooks from `@btst/stack/plugins/comments/client/hooks`: + +```tsx +import { + useComments, + useCommentCount, + usePostComment, + useUpdateComment, + useDeleteComment, + useToggleLike, + useUpdateCommentStatus, +} from "@btst/stack/plugins/comments/client/hooks" + +// Fetch approved comments for a resource +const { data, isLoading } = useComments({ + resourceId: "my-post", + resourceType: "blog-post", + status: "approved", +}) + +// Post a new comment (includes optimistic update) +const { mutate: postComment } = usePostComment() +postComment({ + resourceId: "my-post", + resourceType: "blog-post", + authorId: "user-123", + body: "Great post!", +}) + +// Toggle like (one per user; optimistic update) +const { mutate: toggleLike } = useToggleLike() +toggleLike({ commentId: "comment-id", authorId: "user-123" }) + +// Moderate a comment +const { mutate: updateStatus } = useUpdateCommentStatus() +updateStatus({ id: "comment-id", status: "approved" }) +``` + +## My Comments Page + +The comments plugin registers a `/comments/my-comments` route that shows the current user's full comment history — all statuses (approved, pending, spam) in a single paginated table, newest first. + +**Features:** +- All comment statuses visible to the owner in one list, each with an inline status badge +- Prev / Next pagination (20 per page) +- Resource link column — click through to the original resource when `resourceLinks` is configured (links automatically include `#comments` so the page scrolls to the comment thread) +- Delete button with confirmation dialog — calls `DELETE /comments/:id` (governed by `onBeforeDelete`) +- Login prompt when `currentUserId` is not configured + +### Setup + +Configure the overrides in your layout and the security hook in your backend: + +```tsx title="app/pages/layout.tsx" +overrides={{ + comments: { + apiBaseURL: baseURL, + apiBasePath: "/api/data", + + // Provide the current user's ID so the page can scope the query + currentUserId: session?.user?.id, + + // Map resource types to URLs so comments link back to their resource + resourceLinks: { + "blog-post": (slug) => `/pages/blog/${slug}`, + "kanban-task": (id) => `/pages/kanban?task=${id}`, + }, + + onBeforeMyCommentsPageRendered: (context) => { + if (!session?.user) throw new Error("Authentication required") + }, + } +}} +``` + +```ts title="lib/stack.ts" +commentsBackendPlugin({ + // ... + onBeforeListByAuthor: async (authorId, _query, ctx) => { + const session = await getSession(ctx.headers) + if (!session?.user) throw new Error("Authentication required") + if (authorId !== session.user.id && !session.user.isAdmin) + throw new Error("Forbidden") + }, +}) +``` + + +**`onBeforeListByAuthor` is 403 by default.** Any `GET /comments?authorId=...` request returns 403 unless `onBeforeListByAuthor` is configured. This prevents anonymous callers from reading any user's comment history. Always validate that `authorId` matches the authenticated session. + + +## API Reference + +### Client Plugin Overrides + +Configure the comments plugin behavior from your layout: + +#### `CommentsPluginOverrides` + +| Field | Type | Description | +|-------|------|-------------| +| `apiBaseURL` | `string` | Base URL for API requests | +| `apiBasePath` | `string` | Path prefix for the API | +| `currentUserId` | `string \| (() => string \| undefined \| Promise)` | Authenticated user's ID — used by the My Comments page. Supports async functions for session-based resolution. | +| `defaultCommentPageSize` | `number` | Default number of top-level comments per page for all `CommentThread` instances. Overridden per-instance by the `pageSize` prop. Defaults to `100` when not set. | +| `resourceLinks` | `Record string>` | Per-resource-type URL builders for linking comments back to their resource on the My Comments page (e.g. `{ "blog-post": (slug) => "/pages/blog/" + slug }`). The plugin appends `#comments` automatically so the page scrolls to the thread. | +| `localization` | `Partial` | Override any UI string in the plugin. Import `COMMENTS_LOCALIZATION` from `@btst/stack/plugins/comments/client` to see all available keys. | +| `onBeforeModerationPageRendered` | hook | Called before rendering the moderation dashboard. Throw to deny access. | +| `onBeforeResourceCommentsRendered` | hook | Called before rendering the per-resource comments admin view. Throw to deny access. | +| `onBeforeMyCommentsPageRendered` | hook | Called before rendering the My Comments page. Throw to deny access (e.g. when no session exists). | + +### HTTP Endpoints + +| Method | Path | Description | +|--------|------|-------------| +| `GET` | `/comments` | List comments for a resource | +| `POST` | `/comments` | Create a new comment | +| `PATCH` | `/comments/:id` | Edit a comment body | +| `GET` | `/comments/count` | Get approved comment count | +| `POST` | `/comments/:id/like` | Toggle like on a comment | +| `PATCH` | `/comments/:id/status` | Update moderation status | +| `DELETE` | `/comments/:id` | Delete a comment | + +### `SerializedComment` + +Comments returned by the API include resolved author information: + +| Field | Type | Description | +|-------|------|-------------| +| `id` | `string` | Comment ID | +| `resourceId` | `string` | Resource identifier | +| `resourceType` | `string` | Resource type | +| `parentId` | `string \| null` | Parent comment ID for replies | +| `authorId` | `string` | Author user ID | +| `resolvedAuthorName` | `string` | Display name from `resolveUser`, or `"[deleted]"` | +| `resolvedAvatarUrl` | `string \| null` | Avatar URL from `resolveUser` | +| `body` | `string` | Comment body | +| `status` | `"pending" \| "approved" \| "spam"` | Moderation status | +| `likes` | `number` | Denormalized like count | +| `isLikedByCurrentUser` | `boolean` | Whether the requesting user has liked this comment | +| `editedAt` | `string \| null` | ISO date string if the comment was edited | +| `createdAt` | `string` | ISO date string | +| `updatedAt` | `string` | ISO date string | diff --git a/docs/content/docs/plugins/kanban.mdx b/docs/content/docs/plugins/kanban.mdx index 715e031f..1149005f 100644 --- a/docs/content/docs/plugins/kanban.mdx +++ b/docs/content/docs/plugins/kanban.mdx @@ -696,6 +696,32 @@ overrides={{ | `resolveUser` | `(userId: string) => KanbanUser \| null` | Resolve user info from ID | | `searchUsers` | `(query: string, boardId?: string) => KanbanUser[]` | Search/list users for picker | +**Slot overrides:** + +| Override | Type | Description | +|----------|------|-------------| +| `taskDetailBottomSlot` | `(task: SerializedTask) => ReactNode` | Render additional content below task details — use to embed a `CommentThread` | + +```tsx +import { CommentThread } from "@btst/stack/plugins/comments/client/components" + +overrides={{ + kanban: { + // ... + taskDetailBottomSlot: (task) => ( + + ), + } +}} +``` + ## React Hooks Import hooks from `@btst/stack/plugins/kanban/client/hooks` to use in your components: diff --git a/e2e/playwright.config.ts b/e2e/playwright.config.ts index d5d4ba67..5ac8e410 100644 --- a/e2e/playwright.config.ts +++ b/e2e/playwright.config.ts @@ -98,6 +98,7 @@ export default defineConfig({ "**/*.form-builder.spec.ts", "**/*.ui-builder.spec.ts", "**/*.kanban.spec.ts", + "**/*.comments.spec.ts", "**/*.ssg.spec.ts", "**/*.page-context.spec.ts", "**/*.wealthreview.spec.ts", @@ -114,6 +115,7 @@ export default defineConfig({ "**/*.cms.spec.ts", "**/*.relations-cms.spec.ts", "**/*.form-builder.spec.ts", + "**/*.comments.spec.ts", "**/*.page-context.spec.ts", ], }, @@ -128,6 +130,7 @@ export default defineConfig({ "**/*.cms.spec.ts", "**/*.relations-cms.spec.ts", "**/*.form-builder.spec.ts", + "**/*.comments.spec.ts", "**/*.page-context.spec.ts", ], }, diff --git a/e2e/tests/smoke.comments.spec.ts b/e2e/tests/smoke.comments.spec.ts new file mode 100644 index 00000000..7e7466d0 --- /dev/null +++ b/e2e/tests/smoke.comments.spec.ts @@ -0,0 +1,1091 @@ +import { + expect, + test, + type APIRequestContext, + type Page, +} from "@playwright/test"; + +// ─── API Helpers ──────────────────────────────────────────────────────────────── + +/** Create a published blog post — used to host comment threads in load-more tests. */ +async function createBlogPost( + request: APIRequestContext, + data: { title: string; slug: string }, +) { + const response = await request.post("/api/data/posts", { + headers: { "content-type": "application/json" }, + data: { + title: data.title, + content: `Content for ${data.title}`, + excerpt: `Excerpt for ${data.title}`, + slug: data.slug, + published: true, + publishedAt: new Date().toISOString(), + image: "", + }, + }); + expect( + response.ok(), + `createBlogPost failed: ${await response.text()}`, + ).toBeTruthy(); + return response.json(); +} + +/** Create N approved comments on a resource, sequentially with predictable bodies. */ +async function createApprovedComments( + request: APIRequestContext, + resourceId: string, + resourceType: string, + count: number, + bodyPrefix = "Load More Comment", +) { + const comments = []; + for (let i = 1; i <= count; i++) { + const comment = await createComment(request, { + resourceId, + resourceType, + body: `${bodyPrefix} ${i}`, + }); + await approveComment(request, comment.id); + comments.push(comment); + } + return comments; +} + +/** + * Navigate to a blog post page, scroll to trigger the WhenVisible comment thread, + * then verify the load-more button and paginated comments behave correctly. + * + * Mirrors `testLoadMore` from smoke.blog.spec.ts. + */ +async function testLoadMoreComments( + page: Page, + postSlug: string, + totalCount: number, + options: { pageSize: number; bodyPrefix?: string }, +) { + const { pageSize, bodyPrefix = "Load More Comment" } = options; + + await page.goto(`/pages/blog/${postSlug}`, { waitUntil: "networkidle" }); + await expect(page.locator('[data-testid="post-page"]')).toBeVisible(); + + // Scroll to the bottom to trigger WhenVisible on the comment thread + await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight)); + await page.waitForTimeout(800); + + // Comment thread must be mounted + const thread = page.locator('[data-testid="comment-thread"]'); + await expect(thread).toBeVisible({ timeout: 8000 }); + + // First page of comments should be visible (comments are asc-sorted by date) + for (let i = 1; i <= pageSize; i++) { + await expect( + page.getByText(`${bodyPrefix} ${i}`, { exact: true }), + ).toBeVisible({ timeout: 5000 }); + } + + // Comments beyond the first page must NOT be visible yet + for (let i = pageSize + 1; i <= totalCount; i++) { + await expect( + page.getByText(`${bodyPrefix} ${i}`, { exact: true }), + ).not.toBeVisible(); + } + + // Load more button must be present + const loadMoreBtn = page.locator('[data-testid="load-more-comments"]'); + await expect(loadMoreBtn).toBeVisible(); + + // Click it and wait for the next page to arrive + await loadMoreBtn.click(); + await page.waitForTimeout(1000); + + // All comments must now be visible + for (let i = 1; i <= totalCount; i++) { + await expect( + page.getByText(`${bodyPrefix} ${i}`, { exact: true }), + ).toBeVisible({ timeout: 5000 }); + } + + // Load more button should be gone (no third page) + await expect(loadMoreBtn).not.toBeVisible(); +} + +async function createComment( + request: APIRequestContext, + data: { + resourceId: string; + resourceType: string; + parentId?: string | null; + body: string; + }, +) { + const response = await request.post("/api/data/comments", { + headers: { "content-type": "application/json" }, + data: { + resourceId: data.resourceId, + resourceType: data.resourceType, + parentId: data.parentId ?? null, + body: data.body, + }, + }); + expect( + response.ok(), + `createComment failed: ${await response.text()}`, + ).toBeTruthy(); + return response.json(); +} + +async function approveComment(request: APIRequestContext, id: string) { + const response = await request.patch(`/api/data/comments/${id}/status`, { + headers: { "content-type": "application/json" }, + data: { status: "approved" }, + }); + expect( + response.ok(), + `approveComment failed: ${await response.text()}`, + ).toBeTruthy(); + return response.json(); +} + +async function getCommentCount( + request: APIRequestContext, + resourceId: string, + resourceType: string, + status = "approved", +) { + const response = await request.get( + `/api/data/comments/count?resourceId=${encodeURIComponent(resourceId)}&resourceType=${encodeURIComponent(resourceType)}&status=${status}`, + ); + expect(response.ok()).toBeTruthy(); + const body = await response.json(); + return body.count as number; +} + +// ─── Tests ──────────────────────────────────────────────────────────────────── + +test.describe("Comments Plugin", () => { + test("moderation page renders", async ({ page }) => { + const errors: string[] = []; + page.on("console", (msg) => { + if (msg.type() === "error") errors.push(msg.text()); + }); + + await page.goto("/pages/comments/moderation", { + waitUntil: "networkidle", + }); + await expect(page.locator('[data-testid="moderation-page"]')).toBeVisible(); + + // Tab bar should be visible + await expect(page.locator('[data-testid="tab-pending"]')).toBeVisible(); + await expect(page.locator('[data-testid="tab-approved"]')).toBeVisible(); + await expect(page.locator('[data-testid="tab-spam"]')).toBeVisible(); + + expect(errors, `Console errors detected:\n${errors.join("\n")}`).toEqual( + [], + ); + }); + + test("post a comment — appears in pending moderation queue", async ({ + page, + request, + }) => { + const errors: string[] = []; + page.on("console", (msg) => { + if (msg.type() === "error") errors.push(msg.text()); + }); + + const resourceId = `e2e-post-${Date.now()}`; + const comment = await createComment(request, { + resourceId, + resourceType: "e2e-test", + body: "This is a test comment.", + }); + + expect(comment.status).toBe("pending"); + + // Navigate to the moderation page and verify the comment appears + await page.goto("/pages/comments/moderation", { + waitUntil: "networkidle", + }); + await expect(page.locator('[data-testid="moderation-page"]')).toBeVisible(); + + // Click the Pending tab + await page.locator('[data-testid="tab-pending"]').click(); + await page.waitForLoadState("networkidle"); + + // The comment should appear in the list + await expect(page.getByText("This is a test comment.")).toBeVisible(); + + expect(errors, `Console errors detected:\n${errors.join("\n")}`).toEqual( + [], + ); + }); + + test("approve comment via moderation dashboard — appears in approved list", async ({ + page, + request, + }) => { + const errors: string[] = []; + page.on("console", (msg) => { + if (msg.type() === "error") errors.push(msg.text()); + }); + + const resourceId = `e2e-approve-${Date.now()}`; + const comment = await createComment(request, { + resourceId, + resourceType: "e2e-test", + body: "Approvable comment.", + }); + + // Approve via API + const approved = await approveComment(request, comment.id); + expect(approved.status).toBe("approved"); + + // Navigate to moderation and switch to Approved tab + await page.goto("/pages/comments/moderation", { + waitUntil: "networkidle", + }); + await page.locator('[data-testid="tab-approved"]').click(); + await page.waitForLoadState("networkidle"); + + await expect(page.getByText("Approvable comment.")).toBeVisible(); + + expect(errors, `Console errors detected:\n${errors.join("\n")}`).toEqual( + [], + ); + }); + + test("approve a comment via moderation UI", async ({ page, request }) => { + const errors: string[] = []; + page.on("console", (msg) => { + if (msg.type() === "error") errors.push(msg.text()); + }); + + const resourceId = `e2e-ui-approve-${Date.now()}`; + await createComment(request, { + resourceId, + resourceType: "e2e-test", + body: "Approve me via UI.", + }); + + await page.goto("/pages/comments/moderation", { + waitUntil: "networkidle", + }); + await expect(page.locator('[data-testid="moderation-page"]')).toBeVisible(); + + // Ensure we're on the Pending tab + await page.locator('[data-testid="tab-pending"]').click(); + await page.waitForLoadState("networkidle"); + + // Find the approve button for our comment + const row = page + .locator('[data-testid="moderation-row"]') + .filter({ hasText: "Approve me via UI." }); + await expect(row).toBeVisible(); + + const approveBtn = row.locator('[data-testid="approve-button"]'); + await approveBtn.click(); + await page.waitForLoadState("networkidle"); + + // Switch to Approved tab and verify + await page.locator('[data-testid="tab-approved"]').click(); + await page.waitForLoadState("networkidle"); + await expect(page.getByText("Approve me via UI.")).toBeVisible(); + + expect(errors, `Console errors detected:\n${errors.join("\n")}`).toEqual( + [], + ); + }); + + test("comment count endpoint returns correct count", async ({ request }) => { + const resourceId = `e2e-count-${Date.now()}`; + + // No comments yet + const countBefore = await getCommentCount(request, resourceId, "e2e-test"); + expect(countBefore).toBe(0); + + // Post and approve a comment + const comment = await createComment(request, { + resourceId, + resourceType: "e2e-test", + body: "Count me.", + }); + await approveComment(request, comment.id); + + // Count should be 1 now + const countAfter = await getCommentCount(request, resourceId, "e2e-test"); + expect(countAfter).toBe(1); + }); + + test("like a comment — count increments", async ({ request }) => { + const resourceId = `e2e-like-${Date.now()}`; + const comment = await createComment(request, { + resourceId, + resourceType: "e2e-test", + body: "Like me.", + }); + + // Like the comment + const likeResponse = await request.post( + `/api/data/comments/${comment.id}/like`, + { + headers: { "content-type": "application/json" }, + data: { authorId: "user-liker" }, + }, + ); + expect(likeResponse.ok()).toBeTruthy(); + const likeResult = await likeResponse.json(); + expect(likeResult.isLiked).toBe(true); + expect(likeResult.likes).toBe(1); + + // Like again (toggle — should unlike) + const unlikeResponse = await request.post( + `/api/data/comments/${comment.id}/like`, + { + headers: { "content-type": "application/json" }, + data: { authorId: "user-liker" }, + }, + ); + expect(unlikeResponse.ok()).toBeTruthy(); + const unlikeResult = await unlikeResponse.json(); + expect(unlikeResult.isLiked).toBe(false); + expect(unlikeResult.likes).toBe(0); + }); + + test("reply to a comment — nested under parent", async ({ request }) => { + const resourceId = `e2e-reply-${Date.now()}`; + const parent = await createComment(request, { + resourceId, + resourceType: "e2e-test", + body: "Parent comment.", + }); + + const reply = await createComment(request, { + resourceId, + resourceType: "e2e-test", + parentId: parent.id, + body: "Reply to parent.", + }); + + expect(reply.parentId).toBe(parent.id); + expect(reply.status).toBe("pending"); + }); + + test("unauthenticated placeholder shown when blog post has no currentUserId", async ({ + page, + request, + }) => { + const errors: string[] = []; + page.on("console", (msg) => { + if (msg.type() === "error") errors.push(msg.text()); + }); + + // Create and approve a blog post comment so the thread renders + const resourceId = `e2e-auth-${Date.now()}`; + const comment = await createComment(request, { + resourceId, + resourceType: "blog-post", + body: "Public comment on blog post.", + }); + await approveComment(request, comment.id); + + // Create a blog post (rely on the existing blog post list) + // Just navigate to the blog list and check if a post has the login prompt + // (The layout wires CommentThread without currentUserId) + // Navigate to a blog post — the slot should show the login prompt + await page.goto("/pages/blog", { waitUntil: "networkidle" }); + const postLink = page + .locator("a") + .filter({ hasText: /read more|view post/i }) + .first(); + const hasPost = await postLink.isVisible().catch(() => false); + if (!hasPost) { + test.skip(); // No blog posts in the test db + return; + } + await postLink.click(); + await page.waitForLoadState("networkidle"); + + // Scroll down to trigger WhenVisible + await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight)); + await page.waitForTimeout(800); + + // Login prompt should be visible (no currentUserId in the test layout) + const loginPrompt = page.locator('[data-testid="login-prompt"]'); + await expect(loginPrompt).toBeVisible({ timeout: 5000 }); + + expect(errors, `Console errors detected:\n${errors.join("\n")}`).toEqual( + [], + ); + }); + + test("posting a comment via UI renders the comment card without error", async ({ + page, + request, + }) => { + // Regression test: POST /comments previously returned a raw Comment (no + // resolvedAuthorName), causing getInitials() to crash on the optimistic- + // update replacement. This test posts via the UI and verifies the comment + // card renders with no error boundary. + + const errors: string[] = []; + page.on("console", (msg) => { + if (msg.type() === "error") errors.push(msg.text()); + }); + + // Use a unique resourceId so the thread starts empty and the comment we + // post is the very first one (exercises the "create cache from scratch" path). + const resourceId = `e2e-ui-post-${Date.now()}`; + const resourceType = "e2e-test"; + + // Seed one approved comment so the thread is already rendered and the + // CommentThread component is mounted before we post. + const seed = await createComment(request, { + resourceId, + resourceType, + body: "Seed comment — thread is visible.", + }); + await approveComment(request, seed.id); + + // Navigate to the moderation page which embeds a CommentThread per-resource; + // use the direct resource-comments admin route instead of a blog page so we + // don't depend on specific blog posts existing in the test DB. + await page.goto( + `/pages/comments/moderation/resource?resourceId=${encodeURIComponent(resourceId)}&resourceType=${encodeURIComponent(resourceType)}`, + { waitUntil: "networkidle" }, + ); + + // If the route doesn't exist (some example apps may not expose it), fall + // back to verifying the API response contains resolvedAuthorName. + const hasThread = await page + .locator('[data-testid="comment-form"]') + .isVisible() + .catch(() => false); + + if (!hasThread) { + // Verify the API fix independently: POST must return resolvedAuthorName. + const comment = await createComment(request, { + resourceId, + resourceType, + body: "API regression check.", + }); + expect( + typeof comment.resolvedAuthorName, + "POST /comments must return resolvedAuthorName", + ).toBe("string"); + expect(comment.resolvedAuthorName.length).toBeGreaterThan(0); + return; + } + + // Type and submit a new comment via the browser form. + const textarea = page.locator('[data-testid="comment-form"] textarea'); + await textarea.fill("Hello from browser UI — regression test."); + await page + .locator('[data-testid="comment-form"] button[type="submit"]') + .click(); + + // The optimistic comment card should appear immediately. + await expect( + page.locator('[data-testid="comment-card"]').filter({ + hasText: "Hello from browser UI — regression test.", + }), + ).toBeVisible({ timeout: 5000 }); + + // No error boundary should have triggered. + await expect(page.getByText("Something went wrong")).not.toBeVisible(); + + // Console should be clean (no "Cannot read properties of undefined"). + const criticalErrors = errors.filter( + (e) => + e.includes("Cannot read properties of undefined") || + e.includes("getInitials"), + ); + expect( + criticalErrors, + `Critical console errors:\n${criticalErrors.join("\n")}`, + ).toEqual([]); + }); + + test("POST /comments response includes resolvedAuthorName (no undefined crash)", async ({ + request, + }) => { + // Regression test: the POST response previously returned a raw DB Comment + // that lacked resolvedAuthorName, causing getInitials() to crash when the + // optimistic-update replacement ran on the client. + const resourceId = `e2e-post-serialized-${Date.now()}`; + + const comment = await createComment(request, { + resourceId, + resourceType: "e2e-test", + body: "Serialized response check.", + }); + + // The response must include the enriched fields — not just the raw DB record. + expect( + typeof comment.resolvedAuthorName, + "POST /comments must return resolvedAuthorName", + ).toBe("string"); + expect( + comment.resolvedAuthorName.length, + "resolvedAuthorName must not be empty", + ).toBeGreaterThan(0); + expect( + "resolvedAvatarUrl" in comment, + "POST /comments must return resolvedAvatarUrl", + ).toBe(true); + expect( + "isLikedByCurrentUser" in comment, + "POST /comments must return isLikedByCurrentUser", + ).toBe(true); + }); + + test("resolved author name is returned for comments", async ({ request }) => { + const resourceId = `e2e-author-${Date.now()}`; + + const comment = await createComment(request, { + resourceId, + resourceType: "e2e-test", + body: "Comment with resolved author.", + }); + + // Approve it so it shows in the list + await approveComment(request, comment.id); + + // Fetch the comment list and verify resolvedAuthorName is populated + const listResponse = await request.get( + `/api/data/comments?resourceId=${encodeURIComponent(resourceId)}&resourceType=e2e-test&status=approved`, + ); + expect(listResponse.ok()).toBeTruthy(); + const list = await listResponse.json(); + const found = list.items.find((c: { id: string }) => c.id === comment.id); + expect(found).toBeDefined(); + // resolvedAuthorName should be a non-empty string (from resolveUser or "[deleted]" fallback) + expect(typeof found.resolvedAuthorName).toBe("string"); + expect(found.resolvedAuthorName.length).toBeGreaterThan(0); + }); +}); + +// ─── Own pending comments visibility ──────────────────────────────────────────── +// +// These tests cover the business rule: a user should always see their own +// pending (awaiting-moderation) comments and replies, even after a page +// refresh clears the React Query cache. The fix is server-side — GET /comments +// with `currentUserId` returns approved + own-pending in a single response. +// +// The example app's onBeforePost hook returns authorId "olliethedev" for every +// POST, so we use that as currentUserId in the query string to simulate the +// logged-in user fetching their own pending content. + +test.describe("Own pending comments — visible after refresh (server-side fix)", () => { + // Shared authorId used by the example app's onBeforePost hook + const CURRENT_USER_ID = "olliethedev"; + + test("own pending top-level comment is included when currentUserId matches author", async ({ + request, + }) => { + const resourceId = `e2e-own-pending-${Date.now()}`; + const resourceType = "e2e-test"; + + // POST creates a pending comment (autoApprove: false) + const comment = await createComment(request, { + resourceId, + resourceType, + body: "My pending comment — should survive refresh.", + }); + expect(comment.status).toBe("pending"); + + // Simulates a page-refresh fetch: status defaults to "approved" but + // currentUserId is provided — own pending comments must be included. + const response = await request.get( + `/api/data/comments?resourceId=${encodeURIComponent(resourceId)}&resourceType=${encodeURIComponent(resourceType)}¤tUserId=${CURRENT_USER_ID}`, + ); + expect(response.ok()).toBeTruthy(); + const body = await response.json(); + + const found = body.items.find((c: { id: string }) => c.id === comment.id); + expect( + found, + "Own pending comment must appear in the response with currentUserId", + ).toBeDefined(); + expect(found.status).toBe("pending"); + }); + + test("pending comment is NOT returned when currentUserId is absent", async ({ + request, + }) => { + const resourceId = `e2e-no-pending-${Date.now()}`; + const resourceType = "e2e-test"; + + const comment = await createComment(request, { + resourceId, + resourceType, + body: "Invisible pending comment — no currentUserId.", + }); + expect(comment.status).toBe("pending"); + + // Fetch without currentUserId — only approved comments should be returned + const response = await request.get( + `/api/data/comments?resourceId=${encodeURIComponent(resourceId)}&resourceType=${encodeURIComponent(resourceType)}`, + ); + expect(response.ok()).toBeTruthy(); + const body = await response.json(); + + const found = body.items.find((c: { id: string }) => c.id === comment.id); + expect( + found, + "Pending comment must NOT appear without currentUserId", + ).toBeUndefined(); + }); + + test("another user's pending comment is NOT included even with currentUserId", async ({ + request, + }) => { + const resourceId = `e2e-other-pending-${Date.now()}`; + const resourceType = "e2e-test"; + + // Comment is authored by "olliethedev" (from onBeforePost hook) + const comment = await createComment(request, { + resourceId, + resourceType, + body: "Comment by the real author.", + }); + expect(comment.status).toBe("pending"); + + // A *different* userId should not see this pending comment + const response = await request.get( + `/api/data/comments?resourceId=${encodeURIComponent(resourceId)}&resourceType=${encodeURIComponent(resourceType)}¤tUserId=some-other-user`, + ); + expect(response.ok()).toBeTruthy(); + const body = await response.json(); + + const found = body.items.find((c: { id: string }) => c.id === comment.id); + expect( + found, + "Pending comment from another author must NOT appear for a different currentUserId", + ).toBeUndefined(); + }); + + test("replyCount on parent includes own pending reply when currentUserId is provided", async ({ + request, + }) => { + const resourceId = `e2e-replycount-${Date.now()}`; + const resourceType = "e2e-test"; + + // Create and approve parent so it appears in the top-level list + const parent = await createComment(request, { + resourceId, + resourceType, + body: "Parent comment for reply-count test.", + }); + await approveComment(request, parent.id); + + // Post a pending reply + await createComment(request, { + resourceId, + resourceType, + parentId: parent.id, + body: "My pending reply — should increment replyCount.", + }); + + // Fetch top-level comments WITH currentUserId + const withUserResponse = await request.get( + `/api/data/comments?resourceId=${encodeURIComponent(resourceId)}&resourceType=${encodeURIComponent(resourceType)}&parentId=null¤tUserId=${CURRENT_USER_ID}`, + ); + expect(withUserResponse.ok()).toBeTruthy(); + const withUserBody = await withUserResponse.json(); + const parentItem = withUserBody.items.find( + (c: { id: string }) => c.id === parent.id, + ); + expect(parentItem).toBeDefined(); + expect( + parentItem.replyCount, + "replyCount must include own pending reply when currentUserId is provided", + ).toBe(1); + }); + + test("replyCount is 0 for a pending reply when currentUserId is absent", async ({ + request, + }) => { + const resourceId = `e2e-replycount-nouser-${Date.now()}`; + const resourceType = "e2e-test"; + + const parent = await createComment(request, { + resourceId, + resourceType, + body: "Parent for replyCount-without-user test.", + }); + await approveComment(request, parent.id); + + // Pending reply — not approved, not counted without currentUserId + await createComment(request, { + resourceId, + resourceType, + parentId: parent.id, + body: "Pending reply — invisible without currentUserId.", + }); + + // Fetch without currentUserId + const response = await request.get( + `/api/data/comments?resourceId=${encodeURIComponent(resourceId)}&resourceType=${encodeURIComponent(resourceType)}&parentId=null`, + ); + expect(response.ok()).toBeTruthy(); + const body = await response.json(); + const parentItem = body.items.find( + (c: { id: string }) => c.id === parent.id, + ); + expect(parentItem).toBeDefined(); + expect( + parentItem.replyCount, + "replyCount must be 0 when reply is pending and currentUserId is absent", + ).toBe(0); + }); + + test("own pending reply appears in replies list when currentUserId is provided", async ({ + request, + }) => { + const resourceId = `e2e-pending-reply-list-${Date.now()}`; + const resourceType = "e2e-test"; + + const parent = await createComment(request, { + resourceId, + resourceType, + body: "Parent comment.", + }); + await approveComment(request, parent.id); + + // Post a pending reply + const reply = await createComment(request, { + resourceId, + resourceType, + parentId: parent.id, + body: "My pending reply — must survive refresh.", + }); + expect(reply.status).toBe("pending"); + + // Simulates the RepliesSection fetch after a page refresh: + // status defaults to approved but currentUserId causes own-pending to be included + const response = await request.get( + `/api/data/comments?resourceId=${encodeURIComponent(resourceId)}&resourceType=${encodeURIComponent(resourceType)}&parentId=${encodeURIComponent(parent.id)}¤tUserId=${CURRENT_USER_ID}`, + ); + expect(response.ok()).toBeTruthy(); + const body = await response.json(); + + const found = body.items.find((c: { id: string }) => c.id === reply.id); + expect( + found, + "Own pending reply must appear in the replies list with currentUserId", + ).toBeDefined(); + expect(found.status).toBe("pending"); + }); + + test("own pending reply does NOT appear in replies list without currentUserId", async ({ + request, + }) => { + const resourceId = `e2e-pending-reply-hidden-${Date.now()}`; + const resourceType = "e2e-test"; + + const parent = await createComment(request, { + resourceId, + resourceType, + body: "Parent comment.", + }); + await approveComment(request, parent.id); + + const reply = await createComment(request, { + resourceId, + resourceType, + parentId: parent.id, + body: "Pending reply — hidden without currentUserId.", + }); + expect(reply.status).toBe("pending"); + + // Fetch without currentUserId — only approved replies returned + const response = await request.get( + `/api/data/comments?resourceId=${encodeURIComponent(resourceId)}&resourceType=${encodeURIComponent(resourceType)}&parentId=${encodeURIComponent(parent.id)}`, + ); + expect(response.ok()).toBeTruthy(); + const body = await response.json(); + + const found = body.items.find((c: { id: string }) => c.id === reply.id); + expect( + found, + "Pending reply must NOT appear in the list without currentUserId", + ).toBeUndefined(); + }); + + test("pending-badge is shown for own pending comment in the UI", async ({ + page, + request, + }) => { + // Seeds an approved comment so the thread renders, then posts via the UI + // and verifies the "Pending approval" badge appears on the new comment card. + const resourceId = `e2e-badge-${Date.now()}`; + const resourceType = "e2e-test"; + + const seed = await createComment(request, { + resourceId, + resourceType, + body: "Seed — ensures thread is mounted.", + }); + await approveComment(request, seed.id); + + await page.goto( + `/pages/comments/moderation/resource?resourceId=${encodeURIComponent(resourceId)}&resourceType=${encodeURIComponent(resourceType)}`, + { waitUntil: "networkidle" }, + ); + + const hasThread = await page + .locator('[data-testid="comment-form"]') + .isVisible() + .catch(() => false); + + if (!hasThread) { + // Resource-comments route not available in this example app — skip UI portion + test.skip(); + return; + } + + const textarea = page.locator('[data-testid="comment-form"] textarea'); + await textarea.fill("My new pending comment."); + await page + .locator('[data-testid="comment-form"] button[type="submit"]') + .click(); + + // The pending badge must appear on the newly posted comment card + const newCard = page + .locator('[data-testid="comment-card"]') + .filter({ hasText: "My new pending comment." }); + await expect(newCard).toBeVisible({ timeout: 5000 }); + await expect( + newCard.locator('[data-testid="pending-badge"]'), + ).toBeVisible(); + }); +}); + +// ─── My Comments Page ──────────────────────────────────────────────────────── +// +// The example app's onBeforePost returns authorId "olliethedev" for every POST, +// and the layout wires currentUserId: "olliethedev". All tests in this block +// rely on that fixture so they can verify comments appear on the my-comments page. + +test.describe("My Comments Page", () => { + const AUTHOR_ID = "olliethedev"; + + test("page renders without console errors", async ({ page }) => { + const errors: string[] = []; + page.on("console", (msg) => { + if (msg.type() === "error") errors.push(msg.text()); + }); + + await page.goto("/pages/comments/my-comments", { + waitUntil: "networkidle", + }); + + // Either the list or the empty-state element must be visible + const hasPage = await page + .locator('[data-testid="my-comments-page"]') + .isVisible() + .catch(() => false); + const hasEmpty = await page + .locator('[data-testid="my-comments-empty"]') + .isVisible() + .catch(() => false); + expect( + hasPage || hasEmpty, + "Expected my-comments-page or my-comments-empty to be visible", + ).toBe(true); + + expect(errors, `Console errors detected:\n${errors.join("\n")}`).toEqual( + [], + ); + }); + + test("populated state — comment created by current user appears in list", async ({ + page, + request, + }) => { + const errors: string[] = []; + page.on("console", (msg) => { + if (msg.type() === "error") errors.push(msg.text()); + }); + + // Create a comment — the example app's onBeforePost assigns authorId "olliethedev" + const uniqueBody = `My comment e2e ${Date.now()}`; + await createComment(request, { + resourceId: `e2e-mycomments-${Date.now()}`, + resourceType: "e2e-test", + body: uniqueBody, + }); + + await page.goto("/pages/comments/my-comments", { + waitUntil: "networkidle", + }); + + await expect( + page.locator('[data-testid="my-comments-list"]'), + ).toBeVisible(); + + // The comment body should appear somewhere in the list (possibly on page 1) + await expect(page.getByText(uniqueBody)).toBeVisible(); + + expect(errors, `Console errors detected:\n${errors.join("\n")}`).toEqual( + [], + ); + }); + + test("delete from list — comment disappears after confirmation", async ({ + page, + request, + }) => { + const errors: string[] = []; + page.on("console", (msg) => { + if (msg.type() === "error") errors.push(msg.text()); + }); + + const uniqueBody = `Delete me e2e ${Date.now()}`; + await createComment(request, { + resourceId: `e2e-delete-mycomments-${Date.now()}`, + resourceType: "e2e-test", + body: uniqueBody, + }); + + await page.goto("/pages/comments/my-comments", { + waitUntil: "networkidle", + }); + + // Find the row containing our comment + const row = page + .locator('[data-testid="my-comment-row"]') + .filter({ hasText: uniqueBody }); + await expect(row).toBeVisible(); + + // Click the delete button on that row + await row.locator('[data-testid="my-comment-delete-button"]').click(); + + // Confirm the AlertDialog + await page.locator("button", { hasText: "Delete" }).last().click(); + await page.waitForLoadState("networkidle"); + + // Row should no longer be visible + await expect(page.getByText(uniqueBody)).not.toBeVisible({ timeout: 5000 }); + + expect(errors, `Console errors detected:\n${errors.join("\n")}`).toEqual( + [], + ); + }); + + test("API security — GET /comments?authorId=unknown returns 403", async ({ + request, + }) => { + // The example app's onBeforeListByAuthor only allows "olliethedev" + const response = await request.get( + `/api/data/comments?authorId=unknown-user-12345`, + ); + expect( + response.status(), + "Expected 403 when onBeforeListByAuthor is absent or rejects", + ).toBe(403); + }); + + test("API — GET /comments?authorId=olliethedev returns comments", async ({ + request, + }) => { + // Seed a comment so we have at least one + await createComment(request, { + resourceId: `e2e-api-author-${Date.now()}`, + resourceType: "e2e-test", + body: "Author filter API test", + }); + + const response = await request.get( + `/api/data/comments?authorId=${encodeURIComponent(AUTHOR_ID)}`, + ); + expect(response.ok(), "Expected 200 for own-author query").toBeTruthy(); + const body = await response.json(); + expect(Array.isArray(body.items)).toBe(true); + // All returned comments must belong to the requested author + for (const item of body.items) { + expect(item.authorId).toBe(AUTHOR_ID); + } + }); +}); + +// ─── Load More ──────────────────────────────────────────────────────────────── +// +// These tests verify the comment thread pagination that powers the "Load more +// comments" button. They mirror the blog smoke tests for load-more: an API +// contract test validates server-side limit/offset, and a UI test exercises +// the full click-to-fetch cycle in the browser. +// +// The example app layouts set defaultCommentPageSize: 5 so that pagination +// triggers after 5 comments — mirroring the blog's 10-per-page default. + +test.describe("Comment thread — load more", () => { + test("API pagination contract: limit/offset return correct slices", async ({ + request, + }) => { + const resourceId = `e2e-pagination-${Date.now()}`; + const resourceType = "e2e-test"; + + // Create and approve 7 top-level comments + await createApprovedComments(request, resourceId, resourceType, 7); + + // First page: 5 items, total = 7 + const page1 = await request.get( + `/api/data/comments?resourceId=${encodeURIComponent(resourceId)}&resourceType=${encodeURIComponent(resourceType)}&parentId=null&status=approved&limit=5&offset=0`, + ); + expect(page1.ok()).toBeTruthy(); + const body1 = await page1.json(); + expect(body1.items).toHaveLength(5); + expect(body1.total).toBe(7); + expect(body1.limit).toBe(5); + expect(body1.offset).toBe(0); + + // Second page: 2 items, total still = 7 + const page2 = await request.get( + `/api/data/comments?resourceId=${encodeURIComponent(resourceId)}&resourceType=${encodeURIComponent(resourceType)}&parentId=null&status=approved&limit=5&offset=5`, + ); + expect(page2.ok()).toBeTruthy(); + const body2 = await page2.json(); + expect(body2.items).toHaveLength(2); + expect(body2.total).toBe(7); + expect(body2.limit).toBe(5); + expect(body2.offset).toBe(5); + + // Third page (beyond end): 0 items + const page3 = await request.get( + `/api/data/comments?resourceId=${encodeURIComponent(resourceId)}&resourceType=${encodeURIComponent(resourceType)}&parentId=null&status=approved&limit=5&offset=10`, + ); + expect(page3.ok()).toBeTruthy(); + const body3 = await page3.json(); + expect(body3.items).toHaveLength(0); + expect(body3.total).toBe(7); + }); + + test("load more button on blog post page", async ({ page, request }) => { + const errors: string[] = []; + page.on("console", (msg) => { + if (msg.type() === "error") errors.push(msg.text()); + }); + + const slug = `e2e-lm-comments-${Date.now()}`; + + // Create a published blog post to host the comment thread + await createBlogPost(request, { + title: "Load More Comments Test Post", + slug, + }); + + // Create 7 approved comments so two pages are needed (pageSize = 5) + await createApprovedComments(request, slug, "blog-post", 7); + + await testLoadMoreComments(page, slug, 7, { + pageSize: 5, + bodyPrefix: "Load More Comment", + }); + + expect(errors, `Console errors detected:\n${errors.join("\n")}`).toEqual( + [], + ); + }); +}); diff --git a/examples/nextjs/app/api/data/[[...all]]/route.ts b/examples/nextjs/app/api/data/[[...all]]/route.ts index 8f4d4e31..d60f8ef4 100644 --- a/examples/nextjs/app/api/data/[[...all]]/route.ts +++ b/examples/nextjs/app/api/data/[[...all]]/route.ts @@ -3,4 +3,5 @@ import { handler } from "@/lib/stack" export const GET = handler export const POST = handler export const PUT = handler +export const PATCH = handler export const DELETE = handler diff --git a/examples/nextjs/app/globals.css b/examples/nextjs/app/globals.css index d37c0845..580ce0b6 100644 --- a/examples/nextjs/app/globals.css +++ b/examples/nextjs/app/globals.css @@ -23,6 +23,7 @@ /* Import Kanban plugin styles */ @import "@btst/stack/plugins/kanban/css"; +@import "@btst/stack/plugins/comments/css"; @custom-variant dark (&:is(.dark *)); diff --git a/examples/nextjs/app/pages/layout.tsx b/examples/nextjs/app/pages/layout.tsx index 98117fbe..4b9d974f 100644 --- a/examples/nextjs/app/pages/layout.tsx +++ b/examples/nextjs/app/pages/layout.tsx @@ -16,6 +16,8 @@ import type { FormBuilderPluginOverrides } from "@btst/stack/plugins/form-builde import type { UIBuilderPluginOverrides } from "@btst/stack/plugins/ui-builder/client" import { defaultComponentRegistry } from "@btst/stack/plugins/ui-builder/client" import type { KanbanPluginOverrides } from "@btst/stack/plugins/kanban/client" +import type { CommentsPluginOverrides } from "@btst/stack/plugins/comments/client" +import { CommentThread } from "@btst/stack/plugins/comments/client/components" import { resolveUser, searchUsers } from "@/lib/mock-users" // Get base URL - works on both server and client @@ -80,6 +82,7 @@ type PluginOverrides = { "form-builder": FormBuilderPluginOverrides, "ui-builder": UIBuilderPluginOverrides, kanban: KanbanPluginOverrides, + comments: CommentsPluginOverrides, } export default function ExampleLayout({ @@ -111,6 +114,18 @@ export default function ExampleLayout({ refresh: () => router.refresh(), uploadImage: mockUploadFile, Image: NextImageWrapper, + // Wire comments into the bottom of each blog post + postBottomSlot: (post) => ( + + ), // Lifecycle Hooks - called during route rendering onRouteRender: async (routeName, context) => { console.log(`[${context.isSSR ? 'SSR' : 'CSR'}] onRouteRender: Route rendered:`, routeName, context.path); @@ -266,6 +281,17 @@ export default function ExampleLayout({ // User resolution for assignees resolveUser, searchUsers, + // Wire comments into the bottom of each task detail dialog + taskDetailBottomSlot: (task) => ( + + ), // Lifecycle hooks onRouteRender: async (routeName, context) => { console.log(`[${context.isSSR ? 'SSR' : 'CSR'}] Kanban route:`, routeName, context.path); @@ -281,6 +307,24 @@ export default function ExampleLayout({ console.log(`[${context.isSSR ? 'SSR' : 'CSR'}] onBeforeBoardPageRendered:`, boardId); return true; }, + }, + comments: { + apiBaseURL: baseURL, + apiBasePath: "/api/data", + // In production: derive from your auth session + currentUserId: "olliethedev", + defaultCommentPageSize: 5, + resourceLinks: { + "blog-post": (slug) => `/pages/blog/${slug}`, + }, + onBeforeModerationPageRendered: (context) => { + console.log(`[${context.isSSR ? 'SSR' : 'CSR'}] onBeforeModerationPageRendered`); + return true; // In production: check admin role + }, + onBeforeMyCommentsPageRendered: (context) => { + console.log(`[${context.isSSR ? 'SSR' : 'CSR'}] onBeforeMyCommentsPageRendered`); + return true; // In production: check authenticated session + }, } }} > diff --git a/examples/nextjs/lib/stack-client.tsx b/examples/nextjs/lib/stack-client.tsx index 02e2e282..5dcccd8c 100644 --- a/examples/nextjs/lib/stack-client.tsx +++ b/examples/nextjs/lib/stack-client.tsx @@ -7,6 +7,7 @@ import { formBuilderClientPlugin } from "@btst/stack/plugins/form-builder/client import { uiBuilderClientPlugin, defaultComponentRegistry } from "@btst/stack/plugins/ui-builder/client" import { routeDocsClientPlugin } from "@btst/stack/plugins/route-docs/client" import { kanbanClientPlugin } from "@btst/stack/plugins/kanban/client" +import { commentsClientPlugin } from "@btst/stack/plugins/comments/client" import { QueryClient } from "@tanstack/react-query" // Get base URL function - works on both server and client @@ -175,6 +176,15 @@ export const getStackClient = ( }, }, }), + // Comments plugin — registers the /comments/moderation admin route + comments: commentsClientPlugin({ + apiBaseURL: baseURL, + apiBasePath: "/api/data", + siteBaseURL: baseURL, + siteBasePath: "/pages", + queryClient: queryClient, + headers: options?.headers, + }), } }) } diff --git a/examples/nextjs/lib/stack.ts b/examples/nextjs/lib/stack.ts index 0af9829e..12ff4937 100644 --- a/examples/nextjs/lib/stack.ts +++ b/examples/nextjs/lib/stack.ts @@ -8,6 +8,7 @@ import { cmsBackendPlugin } from "@btst/stack/plugins/cms/api" import { formBuilderBackendPlugin } from "@btst/stack/plugins/form-builder/api" import { openApiBackendPlugin } from "@btst/stack/plugins/open-api/api" import { kanbanBackendPlugin } from "@btst/stack/plugins/kanban/api" +import { commentsBackendPlugin } from "@btst/stack/plugins/comments/api" import { UI_BUILDER_CONTENT_TYPE } from "@btst/stack/plugins/ui-builder" import { openai } from "@ai-sdk/openai" import { tool } from "ai" @@ -360,6 +361,62 @@ Keep all responses concise. Do not discuss the technology stack or internal tool description: "API documentation for the Next.js example application", theme: "kepler", }), + // Comments plugin for threaded discussions + comments: commentsBackendPlugin({ + autoApprove: false, + resolveUser: async (authorId) => { + // In production: look up your auth system's user by authorId + return { name: `User ${authorId}` } + }, + onBeforeList: async (query, ctx) => { + // Restrict pending/spam queues to admin sessions. + // Without this check a no-op hook would bypass the built-in 403 guard. + if (query.status && query.status !== "approved") { + // In production: replace with a real session/role check, e.g.: + // const session = await getSession(ctx.headers) + // if (!session?.user?.isAdmin) throw new Error("Admin access required") + console.log("onBeforeList: non-approved status filter — ensure admin check in production") + } + }, + onBeforePost: async (input, ctx) => { + // In production: verify the session and return the authenticated user's ID + // The authorId is no longer trusted from the client body — it is injected here + console.log("onBeforePost: new comment on", input.resourceType, input.resourceId) + return { authorId: "olliethedev" } // In production: return { authorId: session.user.id } + }, + onAfterPost: async (comment, ctx) => { + console.log("Comment created:", comment.id, "status:", comment.status) + }, + onBeforeLike: async (commentId, authorId, ctx) => { + // In production: verify authorId matches the authenticated session + console.log("onBeforeLike: user", authorId, "toggling like on comment", commentId) + }, + onBeforeStatusChange: async (commentId, status, ctx) => { + // In production: verify the caller has admin/moderator role + console.log("onBeforeStatusChange: comment", commentId, "->", status) + }, + onAfterApprove: async (comment, ctx) => { + console.log("Comment approved:", comment.id) + }, + onBeforeDelete: async (commentId, ctx) => { + // In production: verify the caller has admin/moderator role + console.log("onBeforeDelete: comment", commentId) + }, + onAfterDelete: async (commentId, ctx) => { + console.log("Comment deleted:", commentId) + }, + onBeforeListByAuthor: async (authorId, query, ctx) => { + // In production: verify authorId matches the authenticated session + // const session = await getSession(ctx.headers) + // if (!session?.user) throw new Error("Authentication required") + // if (authorId !== session.user.id && !session.user.isAdmin) throw new Error("Forbidden") + if (authorId !== "olliethedev") throw new Error("Forbidden") + }, + resolveCurrentUserId: async (ctx) => { + // In production: return session?.user?.id ?? null + return "olliethedev" + }, + }), // Kanban plugin for project management boards kanban: kanbanBackendPlugin({ onBeforeListBoards: async (filter, context) => { diff --git a/examples/react-router/app/app.css b/examples/react-router/app/app.css index 50a5a83a..67e1dbbe 100644 --- a/examples/react-router/app/app.css +++ b/examples/react-router/app/app.css @@ -18,6 +18,7 @@ /* Import Kanban plugin styles */ @import "@btst/stack/plugins/kanban/css"; +@import "@btst/stack/plugins/comments/css"; @custom-variant dark (&:is(.dark *)); diff --git a/examples/react-router/app/lib/stack-client.tsx b/examples/react-router/app/lib/stack-client.tsx index d25807ac..12ede79c 100644 --- a/examples/react-router/app/lib/stack-client.tsx +++ b/examples/react-router/app/lib/stack-client.tsx @@ -3,6 +3,7 @@ import { blogClientPlugin } from "@btst/stack/plugins/blog/client" import { aiChatClientPlugin } from "@btst/stack/plugins/ai-chat/client" import { cmsClientPlugin } from "@btst/stack/plugins/cms/client" import { formBuilderClientPlugin } from "@btst/stack/plugins/form-builder/client" +import { commentsClientPlugin } from "@btst/stack/plugins/comments/client" import { uiBuilderClientPlugin, defaultComponentRegistry } from "@btst/stack/plugins/ui-builder/client" import { routeDocsClientPlugin } from "@btst/stack/plugins/route-docs/client" import { kanbanClientPlugin } from "@btst/stack/plugins/kanban/client" @@ -133,6 +134,14 @@ export const getStackClient = (queryClient: QueryClient) => { description: "Manage your projects with kanban boards", }, }), + // Comments plugin — registers the /comments/moderation admin route + comments: commentsClientPlugin({ + apiBaseURL: baseURL, + apiBasePath: "/api/data", + siteBaseURL: baseURL, + siteBasePath: "/pages", + queryClient: queryClient, + }), } }) } diff --git a/examples/react-router/app/lib/stack.ts b/examples/react-router/app/lib/stack.ts index ab248e8b..301b5787 100644 --- a/examples/react-router/app/lib/stack.ts +++ b/examples/react-router/app/lib/stack.ts @@ -6,6 +6,7 @@ import { cmsBackendPlugin } from "@btst/stack/plugins/cms/api" import { formBuilderBackendPlugin } from "@btst/stack/plugins/form-builder/api" import { openApiBackendPlugin } from "@btst/stack/plugins/open-api/api" import { kanbanBackendPlugin } from "@btst/stack/plugins/kanban/api" +import { commentsBackendPlugin } from "@btst/stack/plugins/comments/api" import { UI_BUILDER_CONTENT_TYPE } from "@btst/stack/plugins/ui-builder" import { openai } from "@ai-sdk/openai" @@ -154,6 +155,52 @@ const { handler, dbSchema } = stack({ console.log("Board created:", board.id, board.name); }, }), + // Comments plugin for threaded discussions + comments: commentsBackendPlugin({ + autoApprove: false, + resolveUser: async (authorId) => { + // In production: look up your auth system's user by authorId + return { name: `User ${authorId}` } + }, + onBeforeList: async (query, ctx) => { + // Restrict pending/spam queues to admin sessions. + // Without this check a no-op hook would bypass the built-in 403 guard. + if (query.status && query.status !== "approved") { + // In production: replace with a real session/role check, e.g.: + // const session = await getSession(ctx.headers) + // if (!session?.user?.isAdmin) throw new Error("Admin access required") + console.log("onBeforeList: non-approved status filter — ensure admin check in production") + } + }, + onBeforePost: async (input, ctx) => { + // In production: verify the session and return the authenticated user's ID + console.log("onBeforePost: new comment on", input.resourceType, input.resourceId) + return { authorId: "olliethedev" } // In production: return { authorId: session.user.id } + }, + onBeforeLike: async (commentId, authorId, ctx) => { + // In production: verify authorId matches the authenticated session + console.log("onBeforeLike: user", authorId, "toggling like on comment", commentId) + }, + onBeforeStatusChange: async (commentId, status, ctx) => { + // In production: verify the caller has admin/moderator role + console.log("onBeforeStatusChange: comment", commentId, "->", status) + }, + onBeforeDelete: async (commentId, ctx) => { + // In production: verify the caller has admin/moderator role + console.log("onBeforeDelete: comment", commentId) + }, + onBeforeListByAuthor: async (authorId, query, ctx) => { + // In production: verify authorId matches the authenticated session + // const session = await getSession(ctx.headers) + // if (!session?.user) throw new Error("Authentication required") + // if (authorId !== session.user.id && !session.user.isAdmin) throw new Error("Forbidden") + if (authorId !== "olliethedev") throw new Error("Forbidden") + }, + resolveCurrentUserId: async (ctx) => { + // In production: return session?.user?.id ?? null + return "olliethedev" + }, + }), }, adapter: (db) => createMemoryAdapter(db)({}) }) diff --git a/examples/react-router/app/routes/pages/_layout.tsx b/examples/react-router/app/routes/pages/_layout.tsx index 225e752e..22fc738d 100644 --- a/examples/react-router/app/routes/pages/_layout.tsx +++ b/examples/react-router/app/routes/pages/_layout.tsx @@ -8,6 +8,8 @@ import { ChatLayout } from "@btst/stack/plugins/ai-chat/client" import type { CMSPluginOverrides } from "@btst/stack/plugins/cms/client" import type { FormBuilderPluginOverrides } from "@btst/stack/plugins/form-builder/client" import type { KanbanPluginOverrides } from "@btst/stack/plugins/kanban/client" +import type { CommentsPluginOverrides } from "@btst/stack/plugins/comments/client" +import { CommentThread } from "@btst/stack/plugins/comments/client/components" import { resolveUser, searchUsers } from "../../lib/mock-users" // Get base URL function - works on both server and client @@ -39,6 +41,7 @@ async function mockUploadFile(file: File): Promise { cms: CMSPluginOverrides, "form-builder": FormBuilderPluginOverrides, kanban: KanbanPluginOverrides, + comments: CommentsPluginOverrides, } export default function Layout() { @@ -88,6 +91,18 @@ export default function Layout() { console.log(`[${context.isSSR ? 'SSR' : 'CSR'}] onBeforePostPageRendered: checking access for`, slug, context.path); return true; }, + // Wire comments into the bottom of each blog post + postBottomSlot: (post) => ( + + ), }, "ai-chat": { mode: "authenticated", @@ -202,10 +217,39 @@ export default function Layout() { // User resolution for assignees resolveUser, searchUsers, + // Wire comments into task detail dialogs + taskDetailBottomSlot: (task) => ( + + ), // Lifecycle hooks onRouteRender: async (routeName, context) => { console.log(`[${context.isSSR ? 'SSR' : 'CSR'}] Kanban route:`, routeName, context.path); }, + }, + comments: { + apiBaseURL: baseURL, + apiBasePath: "/api/data", + // In production: derive from your auth session + currentUserId: "olliethedev", + defaultCommentPageSize: 5, + resourceLinks: { + "blog-post": (slug) => `/pages/blog/${slug}`, + }, + onBeforeModerationPageRendered: (context) => { + console.log(`[${context.isSSR ? 'SSR' : 'CSR'}] onBeforeModerationPageRendered`); + return true; // In production: check admin role + }, + onBeforeMyCommentsPageRendered: (context) => { + console.log(`[${context.isSSR ? 'SSR' : 'CSR'}] onBeforeMyCommentsPageRendered`); + return true; // In production: check authenticated session + }, } }} > diff --git a/examples/tanstack/src/lib/stack-client.tsx b/examples/tanstack/src/lib/stack-client.tsx index 5cb52761..043ce077 100644 --- a/examples/tanstack/src/lib/stack-client.tsx +++ b/examples/tanstack/src/lib/stack-client.tsx @@ -6,6 +6,7 @@ import { formBuilderClientPlugin } from "@btst/stack/plugins/form-builder/client import { uiBuilderClientPlugin, defaultComponentRegistry } from "@btst/stack/plugins/ui-builder/client" import { routeDocsClientPlugin } from "@btst/stack/plugins/route-docs/client" import { kanbanClientPlugin } from "@btst/stack/plugins/kanban/client" +import { commentsClientPlugin } from "@btst/stack/plugins/comments/client" import { QueryClient } from "@tanstack/react-query" // Get base URL function - works on both server and client @@ -133,6 +134,14 @@ export const getStackClient = (queryClient: QueryClient) => { description: "Manage your projects with kanban boards", }, }), + // Comments plugin — registers the /comments/moderation admin route + comments: commentsClientPlugin({ + apiBaseURL: baseURL, + apiBasePath: "/api/data", + siteBaseURL: baseURL, + siteBasePath: "/pages", + queryClient: queryClient, + }), } }) } diff --git a/examples/tanstack/src/lib/stack.ts b/examples/tanstack/src/lib/stack.ts index ac4b0be1..f82dccc6 100644 --- a/examples/tanstack/src/lib/stack.ts +++ b/examples/tanstack/src/lib/stack.ts @@ -6,6 +6,7 @@ import { cmsBackendPlugin } from "@btst/stack/plugins/cms/api" import { formBuilderBackendPlugin } from "@btst/stack/plugins/form-builder/api" import { openApiBackendPlugin } from "@btst/stack/plugins/open-api/api" import { kanbanBackendPlugin } from "@btst/stack/plugins/kanban/api" +import { commentsBackendPlugin } from "@btst/stack/plugins/comments/api" import { UI_BUILDER_CONTENT_TYPE } from "@btst/stack/plugins/ui-builder" import { openai } from "@ai-sdk/openai" @@ -153,6 +154,52 @@ const { handler, dbSchema } = stack({ console.log("Board created:", board.id, board.name); }, }), + // Comments plugin for threaded discussions + comments: commentsBackendPlugin({ + autoApprove: false, + resolveUser: async (authorId) => { + // In production: look up your auth system's user by authorId + return { name: `User ${authorId}` } + }, + onBeforeList: async (query, ctx) => { + // Restrict pending/spam queues to admin sessions. + // Without this check a no-op hook would bypass the built-in 403 guard. + if (query.status && query.status !== "approved") { + // In production: replace with a real session/role check, e.g.: + // const session = await getSession(ctx.headers) + // if (!session?.user?.isAdmin) throw new Error("Admin access required") + console.log("onBeforeList: non-approved status filter — ensure admin check in production") + } + }, + onBeforePost: async (input, ctx) => { + // In production: verify the session and return the authenticated user's ID + console.log("onBeforePost: new comment on", input.resourceType, input.resourceId) + return { authorId: "olliethedev" } // In production: return { authorId: session.user.id } + }, + onBeforeLike: async (commentId, authorId, ctx) => { + // In production: verify authorId matches the authenticated session + console.log("onBeforeLike: user", authorId, "toggling like on comment", commentId) + }, + onBeforeStatusChange: async (commentId, status, ctx) => { + // In production: verify the caller has admin/moderator role + console.log("onBeforeStatusChange: comment", commentId, "->", status) + }, + onBeforeDelete: async (commentId, ctx) => { + // In production: verify the caller has admin/moderator role + console.log("onBeforeDelete: comment", commentId) + }, + onBeforeListByAuthor: async (authorId, query, ctx) => { + // In production: verify authorId matches the authenticated session + // const session = await getSession(ctx.headers) + // if (!session?.user) throw new Error("Authentication required") + // if (authorId !== session.user.id && !session.user.isAdmin) throw new Error("Forbidden") + if (authorId !== "olliethedev") throw new Error("Forbidden") + }, + resolveCurrentUserId: async (ctx) => { + // In production: return session?.user?.id ?? null + return "olliethedev" + }, + }), }, adapter: (db) => createMemoryAdapter(db)({}) }) diff --git a/examples/tanstack/src/routes/api/data/$.ts b/examples/tanstack/src/routes/api/data/$.ts index fba2a048..ca1bfb81 100644 --- a/examples/tanstack/src/routes/api/data/$.ts +++ b/examples/tanstack/src/routes/api/data/$.ts @@ -14,6 +14,9 @@ export const Route = createFileRoute("/api/data/$")({ PUT: async ({ request }) => { return handler(request) }, + PATCH: async ({ request }) => { + return handler(request) + }, DELETE: async ({ request }) => { return handler(request) }, diff --git a/examples/tanstack/src/routes/pages/route.tsx b/examples/tanstack/src/routes/pages/route.tsx index cc2bac81..ef2507bd 100644 --- a/examples/tanstack/src/routes/pages/route.tsx +++ b/examples/tanstack/src/routes/pages/route.tsx @@ -8,6 +8,8 @@ import { ChatLayout } from "@btst/stack/plugins/ai-chat/client" import type { CMSPluginOverrides } from "@btst/stack/plugins/cms/client" import type { FormBuilderPluginOverrides } from "@btst/stack/plugins/form-builder/client" import type { KanbanPluginOverrides } from "@btst/stack/plugins/kanban/client" +import type { CommentsPluginOverrides } from "@btst/stack/plugins/comments/client" +import { CommentThread } from "@btst/stack/plugins/comments/client/components" import { resolveUser, searchUsers } from "../../lib/mock-users" import { Link, useRouter, Outlet, createFileRoute } from "@tanstack/react-router" @@ -40,6 +42,7 @@ type PluginOverrides = { cms: CMSPluginOverrides, "form-builder": FormBuilderPluginOverrides, kanban: KanbanPluginOverrides, + comments: CommentsPluginOverrides, } export const Route = createFileRoute('/pages')({ @@ -97,6 +100,18 @@ function Layout() { console.log(`[${context.isSSR ? 'SSR' : 'CSR'}] onBeforePostPageRendered: checking access for`, slug, context.path); return true; }, + // Wire comments into the bottom of each blog post + postBottomSlot: (post) => ( + + ), }, "ai-chat": { mode: "authenticated", @@ -211,10 +226,39 @@ function Layout() { // User resolution for assignees resolveUser, searchUsers, + // Wire comments into task detail dialogs + taskDetailBottomSlot: (task) => ( + + ), // Lifecycle hooks onRouteRender: async (routeName, context) => { console.log(`[${context.isSSR ? 'SSR' : 'CSR'}] Kanban route:`, routeName, context.path); }, + }, + comments: { + apiBaseURL: baseURL, + apiBasePath: "/api/data", + // In production: derive from your auth session + currentUserId: "olliethedev", + defaultCommentPageSize: 5, + resourceLinks: { + "blog-post": (slug) => `/pages/blog/${slug}`, + }, + onBeforeModerationPageRendered: (context) => { + console.log(`[${context.isSSR ? 'SSR' : 'CSR'}] onBeforeModerationPageRendered`); + return true; // In production: check admin role + }, + onBeforeMyCommentsPageRendered: (context) => { + console.log(`[${context.isSSR ? 'SSR' : 'CSR'}] onBeforeMyCommentsPageRendered`); + return true; // In production: check authenticated session + }, } }} > diff --git a/examples/tanstack/src/styles/globals.css b/examples/tanstack/src/styles/globals.css index 57c5835a..59c07329 100644 --- a/examples/tanstack/src/styles/globals.css +++ b/examples/tanstack/src/styles/globals.css @@ -7,6 +7,7 @@ @import "@btst/stack/plugins/ai-chat/css"; @import "@btst/stack/plugins/ui-builder/css"; @import "@btst/stack/plugins/kanban/css"; +@import "@btst/stack/plugins/comments/css"; @custom-variant dark (&:is(.dark *)); diff --git a/packages/stack/build.config.ts b/packages/stack/build.config.ts index 693ba112..0994dac2 100644 --- a/packages/stack/build.config.ts +++ b/packages/stack/build.config.ts @@ -104,6 +104,12 @@ export default defineBuildConfig({ "./src/plugins/kanban/client/components/index.tsx", "./src/plugins/kanban/client/hooks/index.tsx", "./src/plugins/kanban/query-keys.ts", + // comments plugin entries + "./src/plugins/comments/api/index.ts", + "./src/plugins/comments/client/index.ts", + "./src/plugins/comments/client/components/index.tsx", + "./src/plugins/comments/client/hooks/index.tsx", + "./src/plugins/comments/query-keys.ts", "./src/components/auto-form/index.ts", "./src/components/stepped-auto-form/index.ts", "./src/components/multi-select/index.ts", diff --git a/packages/stack/package.json b/packages/stack/package.json index 0636a5b3..b5600589 100644 --- a/packages/stack/package.json +++ b/packages/stack/package.json @@ -1,6 +1,6 @@ { "name": "@btst/stack", - "version": "2.7.0", + "version": "2.8.0", "description": "A composable, plugin-based library for building full-stack applications.", "repository": { "type": "git", @@ -363,6 +363,57 @@ } }, "./plugins/kanban/css": "./dist/plugins/kanban/style.css", + "./plugins/comments/api": { + "import": { + "types": "./dist/plugins/comments/api/index.d.ts", + "default": "./dist/plugins/comments/api/index.mjs" + }, + "require": { + "types": "./dist/plugins/comments/api/index.d.cts", + "default": "./dist/plugins/comments/api/index.cjs" + } + }, + "./plugins/comments/client": { + "import": { + "types": "./dist/plugins/comments/client/index.d.ts", + "default": "./dist/plugins/comments/client/index.mjs" + }, + "require": { + "types": "./dist/plugins/comments/client/index.d.cts", + "default": "./dist/plugins/comments/client/index.cjs" + } + }, + "./plugins/comments/client/components": { + "import": { + "types": "./dist/plugins/comments/client/components/index.d.ts", + "default": "./dist/plugins/comments/client/components/index.mjs" + }, + "require": { + "types": "./dist/plugins/comments/client/components/index.d.cts", + "default": "./dist/plugins/comments/client/components/index.cjs" + } + }, + "./plugins/comments/client/hooks": { + "import": { + "types": "./dist/plugins/comments/client/hooks/index.d.ts", + "default": "./dist/plugins/comments/client/hooks/index.mjs" + }, + "require": { + "types": "./dist/plugins/comments/client/hooks/index.d.cts", + "default": "./dist/plugins/comments/client/hooks/index.cjs" + } + }, + "./plugins/comments/query-keys": { + "import": { + "types": "./dist/plugins/comments/query-keys.d.ts", + "default": "./dist/plugins/comments/query-keys.mjs" + }, + "require": { + "types": "./dist/plugins/comments/query-keys.d.cts", + "default": "./dist/plugins/comments/query-keys.cjs" + } + }, + "./plugins/comments/css": "./dist/plugins/comments/style.css", "./plugins/route-docs/client": { "import": { "types": "./dist/plugins/route-docs/client/index.d.ts", @@ -544,6 +595,21 @@ "plugins/kanban/client/hooks": [ "./dist/plugins/kanban/client/hooks/index.d.ts" ], + "plugins/comments/api": [ + "./dist/plugins/comments/api/index.d.ts" + ], + "plugins/comments/client": [ + "./dist/plugins/comments/client/index.d.ts" + ], + "plugins/comments/client/components": [ + "./dist/plugins/comments/client/components/index.d.ts" + ], + "plugins/comments/client/hooks": [ + "./dist/plugins/comments/client/hooks/index.d.ts" + ], + "plugins/comments/query-keys": [ + "./dist/plugins/comments/query-keys.d.ts" + ], "plugins/route-docs/client": [ "./dist/plugins/route-docs/client/index.d.ts" ], @@ -600,7 +666,6 @@ "react-dom": "^18.0.0 || ^19.0.0", "react-error-boundary": ">=4.0.0", "react-hook-form": ">=7.55.0", - "react-intersection-observer": ">=9.0.0", "react-markdown": ">=9.1.0", "rehype-highlight": ">=7.0.0", "rehype-katex": ">=7.0.0", diff --git a/packages/stack/registry/btst-blog.json b/packages/stack/registry/btst-blog.json index e8ebe3d5..f3f6ca66 100644 --- a/packages/stack/registry/btst-blog.json +++ b/packages/stack/registry/btst-blog.json @@ -10,7 +10,6 @@ "@milkdown/kit", "date-fns", "highlight.js", - "react-intersection-observer", "react-markdown", "rehype-highlight", "rehype-katex", @@ -122,12 +121,24 @@ "content": "import {\n\tCard,\n\tCardContent,\n\tCardFooter,\n\tCardHeader,\n} from \"@/components/ui/card\";\nimport { Skeleton } from \"@/components/ui/skeleton\";\n\nexport function PostCardSkeleton() {\n\treturn (\n\t\t\n\t\t\t\n\t\t\t\t\n\t\t\t\n\t\t\t\n\t\t\t\t\n\t\t\t\t\n\t\t\t\n\t\t\t\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\n\t\t\t\t\n\t\t\t\n\t\t\n\t);\n}\n", "target": "src/components/btst/blog/client/components/loading/post-card-skeleton.tsx" }, + { + "path": "btst/blog/client/components/loading/post-navigation-skeleton.tsx", + "type": "registry:component", + "content": "import { Skeleton } from \"@/components/ui/skeleton\";\n\nexport function PostNavigationSkeleton() {\n\treturn (\n\t\t\n\t\t\t\n\t\t\t\n\t\t\n\t);\n}\n", + "target": "src/components/btst/blog/client/components/loading/post-navigation-skeleton.tsx" + }, { "path": "btst/blog/client/components/loading/post-page-skeleton.tsx", "type": "registry:component", "content": "import { PageHeaderSkeleton } from \"./page-header-skeleton\";\nimport { PageLayout } from \"@/components/ui/page-layout\";\nimport { Skeleton } from \"@/components/ui/skeleton\";\n\nexport function PostPageSkeleton() {\n\treturn (\n\t\t\n\t\t\t\n\t\t\t\t\n\t\t\t\n\t\t\t\n\t\t\n\t);\n}\n\nfunction PostSkeleton() {\n\treturn (\n\t\t\n\t\t\t{/* Title + Meta + Tags */}\n\t\t\t\n\t\t\t\t{/* Title */}\n\t\t\t\t\n\n\t\t\t\t{/* Meta: avatar, author, date */}\n\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\n\n\t\t\t\t{/* Tags */}\n\t\t\t\t\n\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{/* Hero / Cover image */}\n\t\t\t\n\n\t\t\t{/* Content blocks */}\n\t\t\t\n\t\t\t\t\n\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\nfunction ContentBlockSkeleton() {\n\treturn (\n\t\t\n\t\t\t{/* Section heading */}\n\t\t\t\n\t\t\t{/* Paragraph lines */}\n\t\t\t\n\t\t\t\t\n\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\nfunction ImageBlockSkeleton() {\n\treturn ;\n}\n\nfunction CodeBlockSkeleton() {\n\treturn ;\n}\n", "target": "src/components/btst/blog/client/components/loading/post-page-skeleton.tsx" }, + { + "path": "btst/blog/client/components/loading/recent-posts-carousel-skeleton.tsx", + "type": "registry:component", + "content": "import { Skeleton } from \"@/components/ui/skeleton\";\nimport { PostCardSkeleton } from \"./post-card-skeleton\";\n\nexport function RecentPostsCarouselSkeleton() {\n\treturn (\n\t\t\n\t\t\t\n\t\t\t\t\n\t\t\t\t\n\t\t\t\n\t\t\t\n\t\t\t\t{[1, 2, 3].map((i) => (\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/blog/client/components/loading/recent-posts-carousel-skeleton.tsx" + }, { "path": "btst/blog/client/components/pages/404-page.tsx", "type": "registry:page", @@ -179,7 +190,7 @@ { "path": "btst/blog/client/components/pages/post-page.internal.tsx", "type": "registry:component", - "content": "\"use client\";\n\nimport { usePluginOverrides, useBasePath } from \"@btst/stack/context\";\nimport { formatDate } from \"date-fns\";\nimport {\n\tuseSuspensePost,\n\tuseNextPreviousPosts,\n\tuseRecentPosts,\n} from \"@btst/stack/plugins/blog/client/hooks\";\nimport { EmptyList } from \"../shared/empty-list\";\nimport { MarkdownContent } from \"../shared/markdown-content\";\nimport { PageHeader } from \"../shared/page-header\";\nimport { PageWrapper } from \"../shared/page-wrapper\";\nimport type { BlogPluginOverrides } from \"../../overrides\";\nimport { DefaultImage, DefaultLink } from \"../shared/defaults\";\nimport { BLOG_LOCALIZATION } from \"../../localization\";\nimport { PostNavigation } from \"../shared/post-navigation\";\nimport { RecentPostsCarousel } from \"../shared/recent-posts-carousel\";\nimport { Badge } from \"@/components/ui/badge\";\nimport { useRouteLifecycle } from \"@/hooks/use-route-lifecycle\";\nimport { OnThisPage, OnThisPageSelect } from \"../shared/on-this-page\";\nimport type { SerializedPost } from \"../../../types\";\nimport { useRegisterPageAIContext } from \"@btst/stack/plugins/ai-chat/client/context\";\n\n// Internal component with actual page content\nexport function PostPage({ slug }: { slug: string }) {\n\tconst overrides = usePluginOverrides<\n\t\tBlogPluginOverrides,\n\t\tPartial\n\t>(\"blog\", {\n\t\tImage: DefaultImage,\n\t\tlocalization: BLOG_LOCALIZATION,\n\t});\n\tconst { Image, localization } = overrides;\n\n\t// Call lifecycle hooks\n\tuseRouteLifecycle({\n\t\trouteName: \"post\",\n\t\tcontext: {\n\t\t\tpath: `/blog/${slug}`,\n\t\t\tparams: { slug },\n\t\t\tisSSR: typeof window === \"undefined\",\n\t\t},\n\t\toverrides,\n\t\tbeforeRenderHook: (overrides, context) => {\n\t\t\tif (overrides.onBeforePostPageRendered) {\n\t\t\t\treturn overrides.onBeforePostPageRendered(slug, context);\n\t\t\t}\n\t\t\treturn true;\n\t\t},\n\t});\n\n\tconst { post } = useSuspensePost(slug ?? \"\");\n\n\tconst { previousPost, nextPost, ref } = useNextPreviousPosts(\n\t\tpost?.createdAt ?? new Date(),\n\t\t{\n\t\t\tenabled: !!post,\n\t\t},\n\t);\n\n\tconst { recentPosts, ref: recentPostsRef } = useRecentPosts({\n\t\tlimit: 5,\n\t\texcludeSlug: slug,\n\t\tenabled: !!post,\n\t});\n\n\t// Register page AI context so the chat can summarize and discuss this post\n\tuseRegisterPageAIContext(\n\t\tpost\n\t\t\t? {\n\t\t\t\t\trouteName: \"blog-post\",\n\t\t\t\t\tpageDescription:\n\t\t\t\t\t\t`Blog post: \"${post.title}\"\\nAuthor: ${post.authorId ?? \"Unknown\"}\\n\\n${post.content ?? \"\"}`.slice(\n\t\t\t\t\t\t\t0,\n\t\t\t\t\t\t\t16000,\n\t\t\t\t\t\t),\n\t\t\t\t\tsuggestions: [\n\t\t\t\t\t\t\"Summarize this post\",\n\t\t\t\t\t\t\"What are the key takeaways?\",\n\t\t\t\t\t\t\"Explain this in simpler terms\",\n\t\t\t\t\t],\n\t\t\t\t}\n\t\t\t: null,\n\t);\n\n\tif (!slug || !post) {\n\t\treturn (\n\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\n\t\t\t\t\t\n\n\t\t\t\t\t}\n\t\t\t\t\t/>\n\n\t\t\t\t\t{post.image && (\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\n\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\n\n\t\t\t\t\t\n\t\t\t\t\t\t\n\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\n\t\t\n\t);\n}\n\nfunction PostHeaderTop({ post }: { post: SerializedPost }) {\n\tconst { Link } = usePluginOverrides<\n\t\tBlogPluginOverrides,\n\t\tPartial\n\t>(\"blog\", {\n\t\tLink: DefaultLink,\n\t});\n\tconst basePath = useBasePath();\n\treturn (\n\t\t\n\t\t\t\n\t\t\t\t{formatDate(post.createdAt, \"MMMM d, yyyy\")}\n\t\t\t\n\t\t\t{post.tags && post.tags.length > 0 && (\n\t\t\t\t<>\n\t\t\t\t\t{post.tags.map((tag) => (\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t{tag.name}\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", + "content": "\"use client\";\n\nimport { usePluginOverrides, useBasePath } from \"@btst/stack/context\";\nimport { formatDate } from \"date-fns\";\nimport {\n\tuseSuspensePost,\n\tuseNextPreviousPosts,\n\tuseRecentPosts,\n} from \"@btst/stack/plugins/blog/client/hooks\";\nimport { EmptyList } from \"../shared/empty-list\";\nimport { MarkdownContent } from \"../shared/markdown-content\";\nimport { PageHeader } from \"../shared/page-header\";\nimport { PageWrapper } from \"../shared/page-wrapper\";\nimport type { BlogPluginOverrides } from \"../../overrides\";\nimport { DefaultImage, DefaultLink } from \"../shared/defaults\";\nimport { BLOG_LOCALIZATION } from \"../../localization\";\nimport { PostNavigation } from \"../shared/post-navigation\";\nimport { RecentPostsCarousel } from \"../shared/recent-posts-carousel\";\nimport { Badge } from \"@/components/ui/badge\";\nimport { useRouteLifecycle } from \"@/hooks/use-route-lifecycle\";\nimport { OnThisPage, OnThisPageSelect } from \"../shared/on-this-page\";\nimport type { SerializedPost } from \"../../../types\";\nimport { useRegisterPageAIContext } from \"@btst/stack/plugins/ai-chat/client/context\";\nimport { WhenVisible } from \"@/components/ui/when-visible\";\nimport { PostNavigationSkeleton } from \"../loading/post-navigation-skeleton\";\nimport { RecentPostsCarouselSkeleton } from \"../loading/recent-posts-carousel-skeleton\";\n\n// Internal component with actual page content\nexport function PostPage({ slug }: { slug: string }) {\n\tconst overrides = usePluginOverrides<\n\t\tBlogPluginOverrides,\n\t\tPartial\n\t>(\"blog\", {\n\t\tImage: DefaultImage,\n\t\tlocalization: BLOG_LOCALIZATION,\n\t});\n\tconst { Image, localization } = overrides;\n\n\t// Call lifecycle hooks\n\tuseRouteLifecycle({\n\t\trouteName: \"post\",\n\t\tcontext: {\n\t\t\tpath: `/blog/${slug}`,\n\t\t\tparams: { slug },\n\t\t\tisSSR: typeof window === \"undefined\",\n\t\t},\n\t\toverrides,\n\t\tbeforeRenderHook: (overrides, context) => {\n\t\t\tif (overrides.onBeforePostPageRendered) {\n\t\t\t\treturn overrides.onBeforePostPageRendered(slug, context);\n\t\t\t}\n\t\t\treturn true;\n\t\t},\n\t});\n\n\tconst { post } = useSuspensePost(slug ?? \"\");\n\n\tconst { previousPost, nextPost } = useNextPreviousPosts(\n\t\tpost?.createdAt ?? new Date(),\n\t\t{\n\t\t\tenabled: !!post,\n\t\t},\n\t);\n\n\tconst { recentPosts } = useRecentPosts({\n\t\tlimit: 5,\n\t\texcludeSlug: slug,\n\t\tenabled: !!post,\n\t});\n\n\t// Register page AI context so the chat can summarize and discuss this post\n\tuseRegisterPageAIContext(\n\t\tpost\n\t\t\t? {\n\t\t\t\t\trouteName: \"blog-post\",\n\t\t\t\t\tpageDescription:\n\t\t\t\t\t\t`Blog post: \"${post.title}\"\\nAuthor: ${post.authorId ?? \"Unknown\"}\\n\\n${post.content ?? \"\"}`.slice(\n\t\t\t\t\t\t\t0,\n\t\t\t\t\t\t\t16000,\n\t\t\t\t\t\t),\n\t\t\t\t\tsuggestions: [\n\t\t\t\t\t\t\"Summarize this post\",\n\t\t\t\t\t\t\"What are the key takeaways?\",\n\t\t\t\t\t\t\"Explain this in simpler terms\",\n\t\t\t\t\t],\n\t\t\t\t}\n\t\t\t: null,\n\t);\n\n\tif (!slug || !post) {\n\t\treturn (\n\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\n\t\t\t\t\t\n\n\t\t\t\t\t}\n\t\t\t\t\t/>\n\n\t\t\t\t\t{post.image && (\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\n\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\n\n\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\n\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\n\n\t\t\t\t\t\t{overrides.postBottomSlot && (\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t{overrides.postBottomSlot(post)}\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\t\n\t\t\t\n\t\t\n\t);\n}\n\nfunction PostHeaderTop({ post }: { post: SerializedPost }) {\n\tconst { Link } = usePluginOverrides<\n\t\tBlogPluginOverrides,\n\t\tPartial\n\t>(\"blog\", {\n\t\tLink: DefaultLink,\n\t});\n\tconst basePath = useBasePath();\n\treturn (\n\t\t\n\t\t\t\n\t\t\t\t{formatDate(post.createdAt, \"MMMM d, yyyy\")}\n\t\t\t\n\t\t\t{post.tags && post.tags.length > 0 && (\n\t\t\t\t<>\n\t\t\t\t\t{post.tags.map((tag) => (\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t{tag.name}\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", "target": "src/components/btst/blog/client/components/pages/post-page.internal.tsx" }, { @@ -269,7 +280,7 @@ { "path": "btst/blog/client/components/shared/post-navigation.tsx", "type": "registry:component", - "content": "\"use client\";\n\nimport { usePluginOverrides, useBasePath } from \"@btst/stack/context\";\nimport { Button } from \"@/components/ui/button\";\nimport { ChevronLeft, ChevronRight } from \"lucide-react\";\nimport type { BlogPluginOverrides } from \"../../overrides\";\nimport { DefaultLink } from \"./defaults\";\nimport type { SerializedPost } from \"../../../types\";\n\ninterface PostNavigationProps {\n\tpreviousPost: SerializedPost | null;\n\tnextPost: SerializedPost | null;\n\tref?: (node: Element | null) => void;\n}\n\nexport function PostNavigation({\n\tpreviousPost,\n\tnextPost,\n\tref,\n}: PostNavigationProps) {\n\tconst { Link } = usePluginOverrides<\n\t\tBlogPluginOverrides,\n\t\tPartial\n\t>(\"blog\", {\n\t\tLink: DefaultLink,\n\t});\n\tconst basePath = useBasePath();\n\tconst blogPath = `${basePath}/blog`;\n\n\treturn (\n\t\t<>\n\t\t\t{/* Ref div to trigger intersection observer when scrolled into view */}\n\t\t\t{ref && }\n\n\t\t\t{/* Only show navigation buttons if posts are available */}\n\t\t\t{(previousPost || nextPost) && (\n\t\t\t\t<>\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\t{previousPost ? (\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\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\tPrevious\n\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\t\t{previousPost.title}\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\t\t\n\t\t\t\t\t\t)}\n\n\t\t\t\t\t\t{nextPost ? (\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\n\t\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t\tNext\n\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\t\t{nextPost.title}\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\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\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", + "content": "\"use client\";\n\nimport { usePluginOverrides, useBasePath } from \"@btst/stack/context\";\nimport { Button } from \"@/components/ui/button\";\nimport { ChevronLeft, ChevronRight } from \"lucide-react\";\nimport type { BlogPluginOverrides } from \"../../overrides\";\nimport { DefaultLink } from \"./defaults\";\nimport type { SerializedPost } from \"../../../types\";\n\ninterface PostNavigationProps {\n\tpreviousPost: SerializedPost | null;\n\tnextPost: SerializedPost | null;\n}\n\nexport function PostNavigation({\n\tpreviousPost,\n\tnextPost,\n}: PostNavigationProps) {\n\tconst { Link } = usePluginOverrides<\n\t\tBlogPluginOverrides,\n\t\tPartial\n\t>(\"blog\", {\n\t\tLink: DefaultLink,\n\t});\n\tconst basePath = useBasePath();\n\tconst blogPath = `${basePath}/blog`;\n\n\treturn (\n\t\t<>\n\t\t\t{/* Only show navigation buttons if posts are available */}\n\t\t\t{(previousPost || nextPost) && (\n\t\t\t\t<>\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\t{previousPost ? (\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\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\tPrevious\n\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\t\t{previousPost.title}\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\t\t\n\t\t\t\t\t\t)}\n\n\t\t\t\t\t\t{nextPost ? (\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\n\t\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t\tNext\n\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\t\t{nextPost.title}\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\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\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", "target": "src/components/btst/blog/client/components/shared/post-navigation.tsx" }, { @@ -281,7 +292,7 @@ { "path": "btst/blog/client/components/shared/recent-posts-carousel.tsx", "type": "registry:component", - "content": "\"use client\";\n\nimport { useBasePath, usePluginOverrides } from \"@btst/stack/context\";\nimport type { BlogPluginOverrides } from \"../../overrides\";\nimport type { SerializedPost } from \"../../../types\";\nimport {\n\tCarousel,\n\tCarouselContent,\n\tCarouselItem,\n\tCarouselNext,\n\tCarouselPrevious,\n} from \"@/components/ui/carousel\";\nimport { PostCard as DefaultPostCard } from \"./post-card\";\nimport { DefaultLink } from \"./defaults\";\nimport { BLOG_LOCALIZATION } from \"../../localization\";\n\ninterface RecentPostsCarouselProps {\n\tposts: SerializedPost[];\n\tref?: (node: Element | null) => void;\n}\n\nexport function RecentPostsCarousel({ posts, ref }: RecentPostsCarouselProps) {\n\tconst { PostCard, Link, localization } = usePluginOverrides<\n\t\tBlogPluginOverrides,\n\t\tPartial\n\t>(\"blog\", {\n\t\tPostCard: DefaultPostCard,\n\t\tLink: DefaultLink,\n\t\tlocalization: BLOG_LOCALIZATION,\n\t});\n\tconst PostCardComponent = PostCard || DefaultPostCard;\n\tconst basePath = useBasePath();\n\treturn (\n\t\t\n\t\t\t{/* Ref div to trigger intersection observer when scrolled into view */}\n\t\t\t{ref && }\n\n\t\t\t{posts && posts.length > 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\t\n\t\t\t\t\t\t\t\t{localization.BLOG_POST_KEEP_READING}\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.BLOG_POST_VIEW_ALL}\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\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t{posts.map((post) => (\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\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\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 { useBasePath, usePluginOverrides } from \"@btst/stack/context\";\nimport type { BlogPluginOverrides } from \"../../overrides\";\nimport type { SerializedPost } from \"../../../types\";\nimport {\n\tCarousel,\n\tCarouselContent,\n\tCarouselItem,\n\tCarouselNext,\n\tCarouselPrevious,\n} from \"@/components/ui/carousel\";\nimport { PostCard as DefaultPostCard } from \"./post-card\";\nimport { DefaultLink } from \"./defaults\";\nimport { BLOG_LOCALIZATION } from \"../../localization\";\n\ninterface RecentPostsCarouselProps {\n\tposts: SerializedPost[];\n}\n\nexport function RecentPostsCarousel({ posts }: RecentPostsCarouselProps) {\n\tconst { PostCard, Link, localization } = usePluginOverrides<\n\t\tBlogPluginOverrides,\n\t\tPartial\n\t>(\"blog\", {\n\t\tPostCard: DefaultPostCard,\n\t\tLink: DefaultLink,\n\t\tlocalization: BLOG_LOCALIZATION,\n\t});\n\tconst PostCardComponent = PostCard || DefaultPostCard;\n\tconst basePath = useBasePath();\n\treturn (\n\t\t\n\t\t\t{posts && posts.length > 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\t\n\t\t\t\t\t\t\t\t{localization.BLOG_POST_KEEP_READING}\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.BLOG_POST_VIEW_ALL}\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\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t{posts.map((post) => (\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\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\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/blog/client/components/shared/recent-posts-carousel.tsx" }, { @@ -341,7 +352,7 @@ { "path": "btst/blog/client/overrides.ts", "type": "registry:lib", - "content": "import type { SerializedPost } from \"../types\";\nimport type { ComponentType } from \"react\";\nimport type { BlogLocalization } from \"./localization\";\n\n/**\n * Context passed to lifecycle hooks\n */\nexport interface RouteContext {\n\t/** Current route path */\n\tpath: string;\n\t/** Route parameters (e.g., { slug: \"my-post\" }) */\n\tparams?: Record;\n\t/** Whether rendering on server (true) or client (false) */\n\tisSSR: boolean;\n\t/** Additional context properties */\n\t[key: string]: any;\n}\n\n/**\n * Overridable components and functions for the Blog plugin\n *\n * External consumers can provide their own implementations of these\n * to customize the behavior for their framework (Next.js, React Router, etc.)\n */\nexport interface BlogPluginOverrides {\n\t/**\n\t * Link component for navigation\n\t */\n\tLink?: ComponentType & Record>;\n\t/**\n\t * Post card component for displaying a post\n\t */\n\tPostCard?: ComponentType<{\n\t\tpost: SerializedPost;\n\t}>;\n\t/**\n\t * Navigation function for programmatic navigation\n\t */\n\tnavigate: (path: string) => void | Promise;\n\t/**\n\t * Refresh function to invalidate server-side cache (e.g., Next.js router.refresh())\n\t */\n\trefresh?: () => void | Promise;\n\t/**\n\t * Image component for displaying images\n\t */\n\tImage?: ComponentType<\n\t\tReact.ImgHTMLAttributes & Record\n\t>;\n\t/**\n\t * Function used to upload an image and return its URL.\n\t */\n\tuploadImage: (file: File) => Promise;\n\t/**\n\t * Localization object for the blog plugin\n\t */\n\tlocalization?: BlogLocalization;\n\t/**\n\t * API base URL\n\t */\n\tapiBaseURL: string;\n\t/**\n\t * API base path\n\t */\n\tapiBasePath: string;\n\t/**\n\t * Whether to show the attribution\n\t */\n\tshowAttribution?: boolean;\n\t/**\n\t * Optional headers to pass with API requests (e.g., for SSR auth)\n\t */\n\theaders?: HeadersInit;\n\n\t// Lifecycle Hooks (optional)\n\t/**\n\t * Called when a route is rendered\n\t * @param routeName - Name of the route (e.g., 'posts', 'post', 'newPost')\n\t * @param context - Route context with path, params, etc.\n\t */\n\tonRouteRender?: (\n\t\trouteName: string,\n\t\tcontext: RouteContext,\n\t) => void | Promise;\n\n\t/**\n\t * Called when a route encounters an error\n\t * @param routeName - Name of the route\n\t * @param error - The error that occurred\n\t * @param context - Route context\n\t */\n\tonRouteError?: (\n\t\trouteName: string,\n\t\terror: Error,\n\t\tcontext: RouteContext,\n\t) => void | Promise;\n\n\t/**\n\t * Called before the posts list page is rendered\n\t * Return false to prevent rendering (e.g., for authorization)\n\t * @param context - Route context\n\t */\n\tonBeforePostsPageRendered?: (context: RouteContext) => boolean;\n\n\t/**\n\t * Called before a single post page is rendered\n\t * Return false to prevent rendering (e.g., for authorization)\n\t * @param slug - The post slug\n\t * @param context - Route context\n\t */\n\tonBeforePostPageRendered?: (slug: string, context: RouteContext) => boolean;\n\n\t/**\n\t * Called before the new post page is rendered\n\t * Return false to prevent rendering (e.g., for authorization)\n\t * @param context - Route context\n\t */\n\tonBeforeNewPostPageRendered?: (context: RouteContext) => boolean;\n\n\t/**\n\t * Called before the edit post page is rendered\n\t * Return false to prevent rendering (e.g., for authorization)\n\t * @param slug - The post slug being edited\n\t * @param context - Route context\n\t */\n\tonBeforeEditPostPageRendered?: (\n\t\tslug: string,\n\t\tcontext: RouteContext,\n\t) => boolean;\n\n\t/**\n\t * Called before the drafts page is rendered\n\t * Return false to prevent rendering (e.g., for authorization)\n\t * @param context - Route context\n\t */\n\tonBeforeDraftsPageRendered?: (context: RouteContext) => boolean;\n}\n", + "content": "import type { SerializedPost } from \"../types\";\nimport type { ComponentType, ReactNode } from \"react\";\nimport type { BlogLocalization } from \"./localization\";\n\n/**\n * Context passed to lifecycle hooks\n */\nexport interface RouteContext {\n\t/** Current route path */\n\tpath: string;\n\t/** Route parameters (e.g., { slug: \"my-post\" }) */\n\tparams?: Record;\n\t/** Whether rendering on server (true) or client (false) */\n\tisSSR: boolean;\n\t/** Additional context properties */\n\t[key: string]: any;\n}\n\n/**\n * Overridable components and functions for the Blog plugin\n *\n * External consumers can provide their own implementations of these\n * to customize the behavior for their framework (Next.js, React Router, etc.)\n */\nexport interface BlogPluginOverrides {\n\t/**\n\t * Link component for navigation\n\t */\n\tLink?: ComponentType & Record>;\n\t/**\n\t * Post card component for displaying a post\n\t */\n\tPostCard?: ComponentType<{\n\t\tpost: SerializedPost;\n\t}>;\n\t/**\n\t * Navigation function for programmatic navigation\n\t */\n\tnavigate: (path: string) => void | Promise;\n\t/**\n\t * Refresh function to invalidate server-side cache (e.g., Next.js router.refresh())\n\t */\n\trefresh?: () => void | Promise;\n\t/**\n\t * Image component for displaying images\n\t */\n\tImage?: ComponentType<\n\t\tReact.ImgHTMLAttributes & Record\n\t>;\n\t/**\n\t * Function used to upload an image and return its URL.\n\t */\n\tuploadImage: (file: File) => Promise;\n\t/**\n\t * Localization object for the blog plugin\n\t */\n\tlocalization?: BlogLocalization;\n\t/**\n\t * API base URL\n\t */\n\tapiBaseURL: string;\n\t/**\n\t * API base path\n\t */\n\tapiBasePath: string;\n\t/**\n\t * Whether to show the attribution\n\t */\n\tshowAttribution?: boolean;\n\t/**\n\t * Optional headers to pass with API requests (e.g., for SSR auth)\n\t */\n\theaders?: HeadersInit;\n\n\t// Lifecycle Hooks (optional)\n\t/**\n\t * Called when a route is rendered\n\t * @param routeName - Name of the route (e.g., 'posts', 'post', 'newPost')\n\t * @param context - Route context with path, params, etc.\n\t */\n\tonRouteRender?: (\n\t\trouteName: string,\n\t\tcontext: RouteContext,\n\t) => void | Promise;\n\n\t/**\n\t * Called when a route encounters an error\n\t * @param routeName - Name of the route\n\t * @param error - The error that occurred\n\t * @param context - Route context\n\t */\n\tonRouteError?: (\n\t\trouteName: string,\n\t\terror: Error,\n\t\tcontext: RouteContext,\n\t) => void | Promise;\n\n\t/**\n\t * Called before the posts list page is rendered\n\t * Return false to prevent rendering (e.g., for authorization)\n\t * @param context - Route context\n\t */\n\tonBeforePostsPageRendered?: (context: RouteContext) => boolean;\n\n\t/**\n\t * Called before a single post page is rendered\n\t * Return false to prevent rendering (e.g., for authorization)\n\t * @param slug - The post slug\n\t * @param context - Route context\n\t */\n\tonBeforePostPageRendered?: (slug: string, context: RouteContext) => boolean;\n\n\t/**\n\t * Called before the new post page is rendered\n\t * Return false to prevent rendering (e.g., for authorization)\n\t * @param context - Route context\n\t */\n\tonBeforeNewPostPageRendered?: (context: RouteContext) => boolean;\n\n\t/**\n\t * Called before the edit post page is rendered\n\t * Return false to prevent rendering (e.g., for authorization)\n\t * @param slug - The post slug being edited\n\t * @param context - Route context\n\t */\n\tonBeforeEditPostPageRendered?: (\n\t\tslug: string,\n\t\tcontext: RouteContext,\n\t) => boolean;\n\n\t/**\n\t * Called before the drafts page is rendered\n\t * Return false to prevent rendering (e.g., for authorization)\n\t * @param context - Route context\n\t */\n\tonBeforeDraftsPageRendered?: (context: RouteContext) => boolean;\n\n\t// ============ Slot Overrides ============\n\n\t/**\n\t * Optional slot rendered below the blog post body.\n\t * Use this to inject a comment thread or any custom content without\n\t * coupling the blog plugin to the comments plugin.\n\t *\n\t * @example\n\t * ```tsx\n\t * blog: {\n\t * postBottomSlot: (post) => (\n\t * \n\t * ),\n\t * }\n\t * ```\n\t */\n\tpostBottomSlot?: (post: SerializedPost) => ReactNode;\n}\n", "target": "src/components/btst/blog/client/overrides.ts" }, { @@ -362,6 +373,12 @@ "content": "\"use client\";\n\nimport { cn } from \"@/lib/utils\";\n\nexport interface PageLayoutProps {\n\tchildren: React.ReactNode;\n\tclassName?: string;\n\t\"data-testid\"?: string;\n}\n\n/**\n * Shared page layout component providing consistent container styling\n * for plugin pages. Used by blog, CMS, and other plugins.\n */\nexport function PageLayout({\n\tchildren,\n\tclassName,\n\t\"data-testid\": dataTestId,\n}: PageLayoutProps) {\n\treturn (\n\t\t\n\t\t\t{children}\n\t\t\n\t);\n}\n", "target": "src/components/ui/page-layout.tsx" }, + { + "path": "ui/components/when-visible.tsx", + "type": "registry:component", + "content": "\"use client\";\n\nimport { useEffect, useRef, useState, type ReactNode } from \"react\";\n\nexport interface WhenVisibleProps {\n\t/** Content to render once the element scrolls into view */\n\tchildren: ReactNode;\n\t/** Optional placeholder rendered before the element enters the viewport */\n\tfallback?: ReactNode;\n\t/** IntersectionObserver threshold (0–1). Defaults to 0 (any pixel visible). */\n\tthreshold?: number;\n\t/** Root margin passed to IntersectionObserver. Defaults to \"200px\" to preload slightly early. */\n\trootMargin?: string;\n\t/** Additional className applied to the sentinel wrapper div */\n\tclassName?: string;\n}\n\n/**\n * Lazy-mounts children only when the sentinel element scrolls into the viewport.\n * Once mounted, children remain mounted even if the element scrolls out of view.\n *\n * Use this to defer expensive renders (comment threads, carousels, etc.) until\n * the user actually scrolls to that section.\n */\nexport function WhenVisible({\n\tchildren,\n\tfallback = null,\n\tthreshold = 0,\n\trootMargin = \"200px\",\n\tclassName,\n}: WhenVisibleProps) {\n\tconst [isVisible, setIsVisible] = useState(false);\n\tconst sentinelRef = useRef(null);\n\n\tuseEffect(() => {\n\t\tconst el = sentinelRef.current;\n\t\tif (!el) return;\n\n\t\t// If IntersectionObserver is not available (SSR/old browsers), show immediately\n\t\tif (typeof IntersectionObserver === \"undefined\") {\n\t\t\tsetIsVisible(true);\n\t\t\treturn;\n\t\t}\n\n\t\tconst observer = new IntersectionObserver(\n\t\t\t(entries) => {\n\t\t\t\tconst entry = entries[0];\n\t\t\t\tif (entry?.isIntersecting) {\n\t\t\t\t\tsetIsVisible(true);\n\t\t\t\t\tobserver.disconnect();\n\t\t\t\t}\n\t\t\t},\n\t\t\t{ threshold, rootMargin },\n\t\t);\n\n\t\tobserver.observe(el);\n\t\treturn () => observer.disconnect();\n\t}, [threshold, rootMargin]);\n\n\treturn (\n\t\t\n\t\t\t{isVisible ? children : fallback}\n\t\t\n\t);\n}\n", + "target": "src/components/ui/when-visible.tsx" + }, { "path": "ui/components/empty.tsx", "type": "registry:component", diff --git a/packages/stack/registry/btst-cms.json b/packages/stack/registry/btst-cms.json index fbe81220..ea31783d 100644 --- a/packages/stack/registry/btst-cms.json +++ b/packages/stack/registry/btst-cms.json @@ -157,7 +157,7 @@ { "path": "btst/cms/client/components/shared/pagination.tsx", "type": "registry:component", - "content": "\"use client\";\n\nimport { Button } from \"@/components/ui/button\";\nimport { ChevronLeft, ChevronRight } from \"lucide-react\";\nimport { usePluginOverrides } from \"@btst/stack/context\";\nimport type { CMSPluginOverrides } from \"../../overrides\";\nimport { CMS_LOCALIZATION } from \"../../localization\";\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(\"cms\");\n\tconst localization = { ...CMS_LOCALIZATION, ...customLocalization };\n\n\tconst from = offset + 1;\n\tconst to = Math.min(offset + limit, total);\n\n\tif (totalPages <= 1) {\n\t\treturn null;\n\t}\n\n\treturn (\n\t\t\n\t\t\t\n\t\t\t\t{localization.CMS_LIST_PAGINATION_SHOWING.replace(\n\t\t\t\t\t\"{from}\",\n\t\t\t\t\tString(from),\n\t\t\t\t)\n\t\t\t\t\t.replace(\"{to}\", String(to))\n\t\t\t\t\t.replace(\"{total}\", String(total))}\n\t\t\t\n\t\t\t\n\t\t\t\t onPageChange(currentPage - 1)}\n\t\t\t\t\tdisabled={currentPage === 1}\n\t\t\t\t>\n\t\t\t\t\t\n\t\t\t\t\t{localization.CMS_LIST_PAGINATION_PREVIOUS}\n\t\t\t\t\n\t\t\t\t\n\t\t\t\t\t{currentPage} / {totalPages}\n\t\t\t\t\n\t\t\t\t onPageChange(currentPage + 1)}\n\t\t\t\t\tdisabled={currentPage === totalPages}\n\t\t\t\t>\n\t\t\t\t\t{localization.CMS_LIST_PAGINATION_NEXT}\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 { usePluginOverrides } from \"@btst/stack/context\";\nimport type { CMSPluginOverrides } from \"../../overrides\";\nimport { CMS_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(\"cms\");\n\tconst localization = { ...CMS_LOCALIZATION, ...customLocalization };\n\n\treturn (\n\t\t\n\t);\n}\n", "target": "src/components/btst/cms/client/components/shared/pagination.tsx" }, { @@ -256,6 +256,12 @@ "content": "\"use client\";\n\nimport { PageLayout } from \"./page-layout\";\nimport { StackAttribution } from \"./stack-attribution\";\n\nexport interface PageWrapperProps {\n\tchildren: React.ReactNode;\n\tclassName?: string;\n\ttestId?: string;\n\t/**\n\t * Whether to show the \"Powered by BTST\" attribution.\n\t * Defaults to true.\n\t */\n\tshowAttribution?: boolean;\n}\n\n/**\n * Shared page wrapper component providing consistent layout and optional attribution\n * for plugin pages. Used by blog, CMS, and other plugins.\n *\n * @example\n * ```tsx\n * \n * \n * My Page\n * \n * \n * ```\n */\nexport function PageWrapper({\n\tchildren,\n\tclassName,\n\ttestId,\n\tshowAttribution = true,\n}: PageWrapperProps) {\n\treturn (\n\t\t<>\n\t\t\t\n\t\t\t\t{children}\n\t\t\t\n\n\t\t\t{showAttribution && }\n\t\t>\n\t);\n}\n", "target": "src/components/ui/page-wrapper.tsx" }, + { + "path": "ui/components/pagination-controls.tsx", + "type": "registry:component", + "content": "\"use client\";\n\nimport { Button } from \"@/components/ui/button\";\nimport { ChevronLeft, ChevronRight } from \"lucide-react\";\n\nexport interface PaginationControlsProps {\n\t/** Current page, 1-based */\n\tcurrentPage: number;\n\ttotalPages: number;\n\ttotal: number;\n\tlimit: number;\n\toffset: number;\n\tonPageChange: (page: number) => void;\n\tlabels?: {\n\t\tprevious?: string;\n\t\tnext?: string;\n\t\t/** Template string; use {from}, {to}, {total} as placeholders */\n\t\tshowing?: string;\n\t};\n}\n\n/**\n * Generic Prev/Next pagination control with a \"Showing X–Y of Z\" label.\n * Plugin-agnostic — pass localized labels as props.\n * Returns null when totalPages ≤ 1.\n */\nexport function PaginationControls({\n\tcurrentPage,\n\ttotalPages,\n\ttotal,\n\tlimit,\n\toffset,\n\tonPageChange,\n\tlabels,\n}: PaginationControlsProps) {\n\tconst previous = labels?.previous ?? \"Previous\";\n\tconst next = labels?.next ?? \"Next\";\n\tconst showingTemplate = labels?.showing ?? \"Showing {from}–{to} of {total}\";\n\n\tconst from = offset + 1;\n\tconst to = Math.min(offset + limit, total);\n\n\tconst showingText = showingTemplate\n\t\t.replace(\"{from}\", String(from))\n\t\t.replace(\"{to}\", String(to))\n\t\t.replace(\"{total}\", String(total));\n\n\tif (totalPages <= 1) {\n\t\treturn null;\n\t}\n\n\treturn (\n\t\t\n\t\t\t{showingText}\n\t\t\t\n\t\t\t\t onPageChange(currentPage - 1)}\n\t\t\t\t\tdisabled={currentPage === 1}\n\t\t\t\t>\n\t\t\t\t\t\n\t\t\t\t\t{previous}\n\t\t\t\t\n\t\t\t\t\n\t\t\t\t\t{currentPage} / {totalPages}\n\t\t\t\t\n\t\t\t\t onPageChange(currentPage + 1)}\n\t\t\t\t\tdisabled={currentPage === totalPages}\n\t\t\t\t>\n\t\t\t\t\t{next}\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/ui/pagination-controls.tsx" + }, { "path": "ui/hooks/use-route-lifecycle.ts", "type": "registry:hook", diff --git a/packages/stack/registry/btst-comments.json b/packages/stack/registry/btst-comments.json new file mode 100644 index 00000000..20413a9d --- /dev/null +++ b/packages/stack/registry/btst-comments.json @@ -0,0 +1,152 @@ +{ + "name": "btst-comments", + "type": "registry:block", + "title": "Comments Plugin Pages", + "description": "Ejectable page components for the @btst/stack comments plugin. Customize the UI layer while keeping data-fetching in @btst/stack.", + "author": "BTST ", + "dependencies": [ + "@btst/stack", + "date-fns" + ], + "registryDependencies": [ + "alert-dialog", + "avatar", + "badge", + "button", + "checkbox", + "dialog", + "separator", + "table", + "tabs", + "textarea" + ], + "files": [ + { + "path": "btst/comments/types.ts", + "type": "registry:lib", + "content": "/**\n * Comment status values\n */\nexport type CommentStatus = \"pending\" | \"approved\" | \"spam\";\n\n/**\n * A comment record as stored in the database\n */\nexport type Comment = {\n\tid: string;\n\tresourceId: string;\n\tresourceType: string;\n\tparentId: string | null;\n\tauthorId: string;\n\tbody: string;\n\tstatus: CommentStatus;\n\tlikes: number;\n\teditedAt?: Date;\n\tcreatedAt: Date;\n\tupdatedAt: Date;\n};\n\n/**\n * A like record linking an author to a comment\n */\nexport type CommentLike = {\n\tid: string;\n\tcommentId: string;\n\tauthorId: string;\n\tcreatedAt: Date;\n};\n\n/**\n * A comment enriched with server-resolved author info and like status.\n * All dates are ISO strings (safe for serialisation over HTTP / React Query cache).\n */\nexport interface SerializedComment {\n\tid: string;\n\tresourceId: string;\n\tresourceType: string;\n\tparentId: string | null;\n\tauthorId: string;\n\t/** Resolved from resolveUser(authorId). Falls back to \"[deleted]\" when user cannot be found. */\n\tresolvedAuthorName: string;\n\t/** Resolved avatar URL or null */\n\tresolvedAvatarUrl: string | null;\n\tbody: string;\n\tstatus: CommentStatus;\n\t/** Denormalized counter — updated atomically on toggleLike */\n\tlikes: number;\n\t/** True when the currentUserId query param matches an existing commentLike row */\n\tisLikedByCurrentUser: boolean;\n\t/** ISO string set when the comment body was edited; null for unedited comments */\n\teditedAt: string | null;\n\tcreatedAt: string;\n\tupdatedAt: string;\n\t/**\n\t * Number of direct replies visible to the requesting user.\n\t * Includes approved replies plus any pending replies authored by `currentUserId`.\n\t * Always 0 for reply comments (non-null parentId).\n\t */\n\treplyCount: number;\n}\n\n/**\n * Paginated list result for comments\n */\nexport interface CommentListResult {\n\titems: SerializedComment[];\n\ttotal: number;\n\tlimit: number;\n\toffset: number;\n}\n", + "target": "src/components/btst/comments/types.ts" + }, + { + "path": "btst/comments/schemas.ts", + "type": "registry:lib", + "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\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\tcurrentUserId: z.string().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\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/client/components/comment-count.tsx", + "type": "registry:component", + "content": "\"use client\";\n\nimport { MessageSquare } from \"lucide-react\";\nimport { useCommentCount } from \"@btst/stack/plugins/comments/client/hooks\";\n\nexport interface CommentCountProps {\n\tresourceId: string;\n\tresourceType: string;\n\t/** Only count approved comments (default) */\n\tstatus?: \"pending\" | \"approved\" | \"spam\";\n\tapiBaseURL: string;\n\tapiBasePath: string;\n\theaders?: HeadersInit;\n\t/** Optional className for the wrapper span */\n\tclassName?: string;\n}\n\n/**\n * Lightweight badge showing the comment count for a resource.\n * Does not mount a full comment thread — suitable for post list cards.\n *\n * @example\n * ```tsx\n * \n * ```\n */\nexport function CommentCount({\n\tresourceId,\n\tresourceType,\n\tstatus = \"approved\",\n\tapiBaseURL,\n\tapiBasePath,\n\theaders,\n\tclassName,\n}: CommentCountProps) {\n\tconst { count, isLoading } = useCommentCount(\n\t\t{ apiBaseURL, apiBasePath, headers },\n\t\t{ resourceId, resourceType, status },\n\t);\n\n\tif (isLoading) {\n\t\treturn (\n\t\t\t\n\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{count}\n\t\t\n\t);\n}\n", + "target": "src/components/btst/comments/client/components/comment-count.tsx" + }, + { + "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\t\t{isPending ? loc.COMMENTS_FORM_POSTING : resolvedSubmitLabel}\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 { 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 { Heart, MessageSquare, Pencil, Check, X, LogIn } from \"lucide-react\";\nimport { formatDistanceToNow } from \"date-fns\";\nimport type { SerializedComment } from \"../../types\";\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}\n\nconst DEFAULT_RENDERER: ComponentType = ({ body }) => (\n\t{body}\n);\n\nfunction getInitials(name: string | null | undefined) {\n\tif (!name) return \"?\";\n\treturn name\n\t\t.split(\" \")\n\t\t.slice(0, 2)\n\t\t.map((n) => n[0])\n\t\t.join(\"\")\n\t\t.toUpperCase();\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}: {\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}) {\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{currentUserId && !comment.parentId && isApproved && (\n\t\t\t\t\t\t\t onReplyClick(comment.id)}\n\t\t\t\t\t\t\t\tdata-testid=\"reply-button\"\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{loc.COMMENTS_REPLY_BUTTON}\n\t\t\t\t\t\t\t\n\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{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;\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}: 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 loc = { ...COMMENTS_LOCALIZATION, ...localizationProp };\n\tconst [replyingTo, setReplyingTo] = useState(null);\n\tconst [expandedReplies, setExpandedReplies] = useState>(\n\t\tnew Set(),\n\t);\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\tpageSize,\n\t});\n\n\tconst postMutation = usePostComment(config, {\n\t\tresourceId,\n\t\tresourceType,\n\t\tcurrentUserId,\n\t\tinfiniteKey: threadQueryKey,\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});\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/>\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\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/>\n\n\t\t\t\t\t\t\t{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\n\n\t\t\t{currentUserId ? (\n\t\t\t\t\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\n\t\t\t\t\t\t{loc.COMMENTS_LOGIN_PROMPT}\n\t\t\t\t\t\n\t\t\t\t\t{loginHref && (\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t{loc.COMMENTS_LOGIN_LINK}\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}: {\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}) {\n\tconst config = { apiBaseURL, apiBasePath, headers };\n\t// Only fetch reply bodies once the section is expanded.\n\tconst { comments: replies } = 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},\n\t\t{ enabled: expanded },\n\t);\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 ? replies.length || replyCount : replyCount;\n\n\treturn (\n\t\t\n\t\t\t{!expanded && (\n\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t{displayCount}{\" \"}\n\t\t\t\t\t{displayCount === 1\n\t\t\t\t\t\t? loc.COMMENTS_REPLIES_SINGULAR\n\t\t\t\t\t\t: loc.COMMENTS_REPLIES_PLURAL}\n\t\t\t\t\n\t\t\t)}\n\t\t\t{expanded && (\n\t\t\t\t\n\t\t\t\t\t{replies.map((reply) => (\n\t\t\t\t\t\t {}} // No nested replies in v1\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{loc.COMMENTS_HIDE_REPLIES}\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\trootMargin=\"300px\"\n\t\t\tclassName={props.className}\n\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\tuseSuspenseComments,\n\tuseUpdateCommentStatus,\n\tuseDeleteComment,\n} from \"@btst/stack/plugins/comments/client/hooks\";\nimport {\n\tCOMMENTS_LOCALIZATION,\n\ttype CommentsLocalization,\n} from \"../../localization\";\n\ninterface ModerationPageProps {\n\tapiBaseURL: string;\n\tapiBasePath: string;\n\theaders?: HeadersInit;\n\tlocalization?: CommentsLocalization;\n}\n\nfunction getInitials(name: string | null | undefined) {\n\tif (!name) return \"?\";\n\treturn name\n\t\t.split(\" \")\n\t\t.slice(0, 2)\n\t\t.map((n) => n[0])\n\t\t.join(\"\")\n\t\t.toUpperCase();\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 [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, refetch } = useSuspenseComments(config, {\n\t\tstatus: activeTab,\n\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\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 0\n\t\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\t\tonCheckedChange={toggleSelectAll}\n\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/>\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t{loc.COMMENTS_MODERATION_COL_AUTHOR}\n\t\t\t\t\t\t\t\t{loc.COMMENTS_MODERATION_COL_COMMENT}\n\t\t\t\t\t\t\t\t{loc.COMMENTS_MODERATION_COL_RESOURCE}\n\t\t\t\t\t\t\t\t{loc.COMMENTS_MODERATION_COL_DATE}\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t{loc.COMMENTS_MODERATION_COL_ACTIONS}\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\t\n\t\t\t\t\t\t\t{comments.map((comment) => (\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 toggleSelect(comment.id)}\n\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/>\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\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.resolvedAvatarUrl && (\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{getInitials(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\t\n\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\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\n\t\t\t\t\t\t\t\t\t\t\t{comment.body}\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\n\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\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{formatDistanceToNow(new Date(comment.createdAt), {\n\t\t\t\t\t\t\t\t\t\t\taddSuffix: true,\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\n\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\tdata-testid=\"view-button\"\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\n\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 handleApprove(comment.id)}\n\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\tdata-testid=\"approve-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\t{activeTab !== \"spam\" && (\n\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\tdisabled={updateStatus.isPending}\n\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>\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\t setDeleteIds([comment.id])}\n\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>\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\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", + "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", + "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, useEffect } 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\";\n\nconst PAGE_LIMIT = 20;\n\ninterface MyCommentsPageProps {\n\tapiBaseURL: string;\n\tapiBasePath: string;\n\theaders?: HeadersInit;\n\tcurrentUserId?: CommentsPluginOverrides[\"currentUserId\"];\n\tresourceLinks?: CommentsPluginOverrides[\"resourceLinks\"];\n\tlocalization?: CommentsLocalization;\n}\n\nfunction getInitials(name: string | null | undefined) {\n\tif (!name) return \"?\";\n\treturn name\n\t\t.split(\" \")\n\t\t.slice(0, 2)\n\t\t.map((n) => n[0])\n\t\t.join(\"\")\n\t\t.toUpperCase();\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// ─── Resolved currentUserId hook ─────────────────────────────────────────────\n\nfunction 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()).then((id) => {\n\t\t\t\tsetResolved(id ?? undefined);\n\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// ─── Main export ──────────────────────────────────────────────────────────────\n\nexport function MyCommentsPage({\n\tapiBaseURL,\n\tapiBasePath,\n\theaders,\n\tcurrentUserId: currentUserIdProp,\n\tresourceLinks,\n\tlocalization: localizationProp,\n}: MyCommentsPageProps) {\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 MyCommentsList({\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 resourceUrl = resourceLinks?.[comment.resourceType]?.(\n\t\tcomment.resourceId,\n\t);\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", + "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 MyCommentsPageInternal = lazy(() =>\n\timport(\"./my-comments-page.internal\").then((m) => ({\n\t\tdefault: m.MyCommentsPage,\n\t})),\n);\n\nfunction MyCommentsPageSkeleton() {\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 MyCommentsPageComponent() {\n\treturn (\n\t\t\n\t\t\t\tconsole.error(\"[btst/comments] My Comments error:\", error)\n\t\t\t}\n\t\t/>\n\t);\n}\n\nfunction MyCommentsPageWrapper() {\n\tconst overrides = usePluginOverrides(\"comments\");\n\tconst loc = { ...COMMENTS_LOCALIZATION, ...overrides.localization };\n\n\tuseRouteLifecycle({\n\t\trouteName: \"myComments\",\n\t\tcontext: {\n\t\t\tpath: \"/comments/my-comments\",\n\t\t\tisSSR: typeof window === \"undefined\",\n\t\t},\n\t\toverrides,\n\t\tbeforeRenderHook: (o, context) => {\n\t\t\tif (o.onBeforeMyCommentsPageRendered) {\n\t\t\t\tconst result = o.onBeforeMyCommentsPageRendered(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\";\n\ninterface ResourceCommentsPageProps {\n\tresourceId: string;\n\tresourceType: string;\n\tapiBaseURL: string;\n\tapiBasePath: string;\n\theaders?: HeadersInit;\n\tlocalization?: CommentsLocalization;\n}\n\nfunction getInitials(name: string | null | undefined) {\n\tif (!name) return \"?\";\n\treturn name\n\t\t.split(\" \")\n\t\t.slice(0, 2)\n\t\t.map((n) => n[0])\n\t\t.join(\"\")\n\t\t.toUpperCase();\n}\n\nexport function ResourceCommentsPage({\n\tresourceId,\n\tresourceType,\n\tapiBaseURL,\n\tapiBasePath,\n\theaders,\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", + "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\";\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\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" + }, + { + "path": "btst/comments/client/components/shared/page-wrapper.tsx", + "type": "registry:component", + "content": "\"use client\";\n\nimport { usePluginOverrides } from \"@btst/stack/context\";\nimport { PageWrapper as SharedPageWrapper } from \"@/components/ui/page-wrapper\";\nimport type { CommentsPluginOverrides } from \"../../overrides\";\n\nexport function PageWrapper({\n\tchildren,\n\tclassName,\n\ttestId,\n}: {\n\tchildren: React.ReactNode;\n\tclassName?: string;\n\ttestId?: string;\n}) {\n\tconst { showAttribution } = usePluginOverrides<\n\t\tCommentsPluginOverrides,\n\t\tPartial\n\t>(\"comments\", {\n\t\tshowAttribution: true,\n\t});\n\n\treturn (\n\t\t\n\t\t\t{children}\n\t\t\n\t);\n}\n", + "target": "src/components/btst/comments/client/components/shared/page-wrapper.tsx" + }, + { + "path": "btst/comments/client/localization/comments-moderation.ts", + "type": "registry:lib", + "content": "export const COMMENTS_MODERATION = {\n\tCOMMENTS_MODERATION_TITLE: \"Comment Moderation\",\n\tCOMMENTS_MODERATION_DESCRIPTION:\n\t\t\"Review and manage comments across all resources.\",\n\n\tCOMMENTS_MODERATION_TAB_PENDING: \"Pending\",\n\tCOMMENTS_MODERATION_TAB_APPROVED: \"Approved\",\n\tCOMMENTS_MODERATION_TAB_SPAM: \"Spam\",\n\n\tCOMMENTS_MODERATION_SELECTED: \"{n} selected\",\n\tCOMMENTS_MODERATION_APPROVE_SELECTED: \"Approve selected\",\n\tCOMMENTS_MODERATION_DELETE_SELECTED: \"Delete selected\",\n\tCOMMENTS_MODERATION_EMPTY: \"No {status} comments.\",\n\n\tCOMMENTS_MODERATION_COL_AUTHOR: \"Author\",\n\tCOMMENTS_MODERATION_COL_COMMENT: \"Comment\",\n\tCOMMENTS_MODERATION_COL_RESOURCE: \"Resource\",\n\tCOMMENTS_MODERATION_COL_DATE: \"Date\",\n\tCOMMENTS_MODERATION_COL_ACTIONS: \"Actions\",\n\tCOMMENTS_MODERATION_SELECT_ALL: \"Select all\",\n\tCOMMENTS_MODERATION_SELECT_ONE: \"Select comment\",\n\n\tCOMMENTS_MODERATION_ACTION_VIEW: \"View\",\n\tCOMMENTS_MODERATION_ACTION_APPROVE: \"Approve\",\n\tCOMMENTS_MODERATION_ACTION_SPAM: \"Mark as spam\",\n\tCOMMENTS_MODERATION_ACTION_DELETE: \"Delete\",\n\n\tCOMMENTS_MODERATION_TOAST_APPROVED: \"Comment approved\",\n\tCOMMENTS_MODERATION_TOAST_APPROVE_ERROR: \"Failed to approve comment\",\n\tCOMMENTS_MODERATION_TOAST_SPAM: \"Marked as spam\",\n\tCOMMENTS_MODERATION_TOAST_SPAM_ERROR: \"Failed to update status\",\n\tCOMMENTS_MODERATION_TOAST_DELETED: \"Comment deleted\",\n\tCOMMENTS_MODERATION_TOAST_DELETED_PLURAL: \"{n} comments deleted\",\n\tCOMMENTS_MODERATION_TOAST_DELETE_ERROR: \"Failed to delete comment(s)\",\n\tCOMMENTS_MODERATION_TOAST_BULK_APPROVED: \"{n} comment(s) approved\",\n\tCOMMENTS_MODERATION_TOAST_BULK_APPROVE_ERROR: \"Failed to approve comments\",\n\n\tCOMMENTS_MODERATION_DIALOG_TITLE: \"Comment Details\",\n\tCOMMENTS_MODERATION_DIALOG_RESOURCE: \"Resource\",\n\tCOMMENTS_MODERATION_DIALOG_LIKES: \"Likes\",\n\tCOMMENTS_MODERATION_DIALOG_REPLY_TO: \"Reply to\",\n\tCOMMENTS_MODERATION_DIALOG_EDITED: \"Edited\",\n\tCOMMENTS_MODERATION_DIALOG_BODY: \"Body\",\n\tCOMMENTS_MODERATION_DIALOG_APPROVE: \"Approve\",\n\tCOMMENTS_MODERATION_DIALOG_MARK_SPAM: \"Mark spam\",\n\tCOMMENTS_MODERATION_DIALOG_DELETE: \"Delete\",\n\n\tCOMMENTS_MODERATION_DELETE_TITLE_SINGULAR: \"Delete comment?\",\n\tCOMMENTS_MODERATION_DELETE_TITLE_PLURAL: \"Delete {n} comments?\",\n\tCOMMENTS_MODERATION_DELETE_DESCRIPTION_SINGULAR:\n\t\t\"This action cannot be undone. The comment will be permanently deleted.\",\n\tCOMMENTS_MODERATION_DELETE_DESCRIPTION_PLURAL:\n\t\t\"This action cannot be undone. The comments will be permanently deleted.\",\n\tCOMMENTS_MODERATION_DELETE_CANCEL: \"Cancel\",\n\tCOMMENTS_MODERATION_DELETE_CONFIRM: \"Delete\",\n\tCOMMENTS_MODERATION_DELETE_DELETING: \"Deleting…\",\n\n\tCOMMENTS_RESOURCE_TITLE: \"Comments\",\n\tCOMMENTS_RESOURCE_PENDING_SECTION: \"Pending Review\",\n\tCOMMENTS_RESOURCE_THREAD_SECTION: \"Thread\",\n\tCOMMENTS_RESOURCE_ACTION_APPROVE: \"Approve\",\n\tCOMMENTS_RESOURCE_ACTION_SPAM: \"Spam\",\n\tCOMMENTS_RESOURCE_ACTION_DELETE: \"Delete\",\n\tCOMMENTS_RESOURCE_DELETE_CONFIRM: \"Delete this comment?\",\n\tCOMMENTS_RESOURCE_TOAST_APPROVED: \"Comment approved\",\n\tCOMMENTS_RESOURCE_TOAST_APPROVE_ERROR: \"Failed to approve\",\n\tCOMMENTS_RESOURCE_TOAST_SPAM: \"Marked as spam\",\n\tCOMMENTS_RESOURCE_TOAST_SPAM_ERROR: \"Failed to update\",\n\tCOMMENTS_RESOURCE_TOAST_DELETED: \"Comment deleted\",\n\tCOMMENTS_RESOURCE_TOAST_DELETE_ERROR: \"Failed to delete\",\n};\n", + "target": "src/components/btst/comments/client/localization/comments-moderation.ts" + }, + { + "path": "btst/comments/client/localization/comments-my.ts", + "type": "registry:lib", + "content": "export const COMMENTS_MY = {\n\tCOMMENTS_MY_LOGIN_TITLE: \"Please log in to view your comments\",\n\tCOMMENTS_MY_LOGIN_DESCRIPTION:\n\t\t\"You need to be logged in to see your comment history.\",\n\n\tCOMMENTS_MY_EMPTY_TITLE: \"No comments yet\",\n\tCOMMENTS_MY_EMPTY_DESCRIPTION: \"Comments you post will appear here.\",\n\n\tCOMMENTS_MY_PAGE_TITLE: \"My Comments\",\n\n\tCOMMENTS_MY_COL_COMMENT: \"Comment\",\n\tCOMMENTS_MY_COL_RESOURCE: \"Resource\",\n\tCOMMENTS_MY_COL_STATUS: \"Status\",\n\tCOMMENTS_MY_COL_DATE: \"Date\",\n\n\tCOMMENTS_MY_REPLY_INDICATOR: \"↩ Reply\",\n\tCOMMENTS_MY_VIEW_LINK: \"View\",\n\n\tCOMMENTS_MY_STATUS_APPROVED: \"Approved\",\n\tCOMMENTS_MY_STATUS_PENDING: \"Pending\",\n\tCOMMENTS_MY_STATUS_SPAM: \"Spam\",\n\n\tCOMMENTS_MY_TOAST_DELETED: \"Comment deleted\",\n\tCOMMENTS_MY_TOAST_DELETE_ERROR: \"Failed to delete comment\",\n\n\tCOMMENTS_MY_DELETE_TITLE: \"Delete comment?\",\n\tCOMMENTS_MY_DELETE_DESCRIPTION:\n\t\t\"This action cannot be undone. The comment will be permanently removed.\",\n\tCOMMENTS_MY_DELETE_CANCEL: \"Cancel\",\n\tCOMMENTS_MY_DELETE_CONFIRM: \"Delete\",\n\tCOMMENTS_MY_DELETE_BUTTON_SR: \"Delete comment\",\n};\n", + "target": "src/components/btst/comments/client/localization/comments-my.ts" + }, + { + "path": "btst/comments/client/localization/comments-thread.ts", + "type": "registry:lib", + "content": "export const COMMENTS_THREAD = {\n\tCOMMENTS_TITLE: \"Comments\",\n\tCOMMENTS_EMPTY: \"Be the first to comment.\",\n\n\tCOMMENTS_EDITED_BADGE: \"(edited)\",\n\tCOMMENTS_PENDING_BADGE: \"Pending approval\",\n\n\tCOMMENTS_LIKE_ARIA: \"Like\",\n\tCOMMENTS_UNLIKE_ARIA: \"Unlike\",\n\tCOMMENTS_REPLY_BUTTON: \"Reply\",\n\tCOMMENTS_EDIT_BUTTON: \"Edit\",\n\tCOMMENTS_DELETE_BUTTON: \"Delete\",\n\tCOMMENTS_SAVE_EDIT: \"Save\",\n\n\tCOMMENTS_REPLIES_SINGULAR: \"reply\",\n\tCOMMENTS_REPLIES_PLURAL: \"replies\",\n\tCOMMENTS_HIDE_REPLIES: \"Hide replies\",\n\tCOMMENTS_DELETE_CONFIRM: \"Delete this comment?\",\n\n\tCOMMENTS_LOGIN_PROMPT: \"Please sign in to leave a comment.\",\n\tCOMMENTS_LOGIN_LINK: \"Sign in\",\n\n\tCOMMENTS_FORM_PLACEHOLDER: \"Write a comment…\",\n\tCOMMENTS_FORM_CANCEL: \"Cancel\",\n\tCOMMENTS_FORM_POST_COMMENT: \"Post comment\",\n\tCOMMENTS_FORM_POST_REPLY: \"Post reply\",\n\tCOMMENTS_FORM_POSTING: \"Posting…\",\n\tCOMMENTS_FORM_SUBMIT_ERROR: \"Failed to submit comment\",\n\n\tCOMMENTS_LOAD_MORE: \"Load more comments\",\n\tCOMMENTS_LOADING_MORE: \"Loading…\",\n};\n", + "target": "src/components/btst/comments/client/localization/comments-thread.ts" + }, + { + "path": "btst/comments/client/localization/index.ts", + "type": "registry:lib", + "content": "import { COMMENTS_THREAD } from \"./comments-thread\";\nimport { COMMENTS_MODERATION } from \"./comments-moderation\";\nimport { COMMENTS_MY } from \"./comments-my\";\n\nexport const COMMENTS_LOCALIZATION = {\n\t...COMMENTS_THREAD,\n\t...COMMENTS_MODERATION,\n\t...COMMENTS_MY,\n};\n\nexport type CommentsLocalization = typeof COMMENTS_LOCALIZATION;\n", + "target": "src/components/btst/comments/client/localization/index.ts" + }, + { + "path": "btst/comments/client/overrides.ts", + "type": "registry:lib", + "content": "/**\n * Context passed to lifecycle hooks\n */\nexport interface RouteContext {\n\t/** Current route path */\n\tpath: string;\n\t/** Route parameters (e.g., { resourceId: \"my-post\", resourceType: \"blog-post\" }) */\n\tparams?: Record;\n\t/** Whether rendering on server (true) or client (false) */\n\tisSSR: boolean;\n\t/** Additional context properties */\n\t[key: string]: unknown;\n}\n\nimport type { CommentsLocalization } from \"./localization\";\n\n/**\n * Overridable configuration and hooks for the Comments plugin.\n *\n * Provide these in the layout wrapping your pages via `PluginOverridesProvider`.\n */\nexport interface CommentsPluginOverrides {\n\t/**\n\t * Localization strings for all Comments plugin UI.\n\t * Defaults to English when not provided.\n\t */\n\tlocalization?: Partial;\n\t/**\n\t * Base URL for API calls (e.g., \"https://example.com\")\n\t */\n\tapiBaseURL: string;\n\n\t/**\n\t * Path where the API is mounted (e.g., \"/api/data\")\n\t */\n\tapiBasePath: string;\n\n\t/**\n\t * Optional headers for authenticated API calls (e.g., forwarding cookies)\n\t */\n\theaders?: Record;\n\n\t/**\n\t * Whether to show the \"Powered by BTST\" attribution on plugin pages.\n\t * Defaults to true.\n\t */\n\tshowAttribution?: boolean;\n\n\t/**\n\t * The ID of the currently authenticated user.\n\t *\n\t * Used by the My Comments page to scope the comment list to the current user.\n\t * Can be a static string or an async function (useful when the user ID must\n\t * be resolved from a session cookie at render time).\n\t *\n\t * When absent the My Comments page shows a \"Please log in\" prompt.\n\t */\n\tcurrentUserId?:\n\t\t| string\n\t\t| (() => string | undefined | Promise);\n\n\t/**\n\t * Default number of top-level comments to load per page in `CommentThread`.\n\t * Can be overridden per-instance via the `pageSize` prop.\n\t * Defaults to 100 when not set.\n\t */\n\tdefaultCommentPageSize?: number;\n\n\t/**\n\t * Per-resource-type URL builders used to link each comment back to its\n\t * original resource on the My Comments page.\n\t *\n\t * @example\n\t * ```ts\n\t * resourceLinks: {\n\t * \"blog-post\": (slug) => `/pages/blog/${slug}`,\n\t * \"kanban-task\": (id) => `/pages/kanban?task=${id}`,\n\t * }\n\t * ```\n\t *\n\t * When a resource type has no entry the ID is shown as plain text.\n\t */\n\tresourceLinks?: Record string>;\n\n\t// ============ Access Control Hooks ============\n\n\t/**\n\t * Called before the moderation dashboard page is rendered.\n\t * Return false to block rendering (e.g., redirect to login or show 403).\n\t * @param context - Route context\n\t */\n\tonBeforeModerationPageRendered?: (context: RouteContext) => boolean;\n\n\t/**\n\t * Called before the per-resource comments page is rendered.\n\t * Return false to block rendering (e.g., for authorization).\n\t * @param resourceType - The type of resource (e.g., \"blog-post\")\n\t * @param resourceId - The ID of the resource\n\t * @param context - Route context\n\t */\n\tonBeforeResourceCommentsRendered?: (\n\t\tresourceType: string,\n\t\tresourceId: string,\n\t\tcontext: RouteContext,\n\t) => boolean;\n\n\t/**\n\t * Called before the My Comments page is rendered.\n\t * Throw to block rendering (e.g., when the user is not authenticated).\n\t * @param context - Route context\n\t */\n\tonBeforeMyCommentsPageRendered?: (context: RouteContext) => boolean | void;\n\n\t// ============ Lifecycle Hooks ============\n\n\t/**\n\t * Called when a route is rendered.\n\t * @param routeName - Name of the route (e.g., 'moderation', 'resourceComments')\n\t * @param context - Route context\n\t */\n\tonRouteRender?: (\n\t\trouteName: string,\n\t\tcontext: RouteContext,\n\t) => void | Promise;\n\n\t/**\n\t * Called when a route encounters an error.\n\t * @param routeName - Name of the route\n\t * @param error - The error that occurred\n\t * @param context - Route context\n\t */\n\tonRouteError?: (\n\t\trouteName: string,\n\t\terror: Error,\n\t\tcontext: RouteContext,\n\t) => void | Promise;\n}\n", + "target": "src/components/btst/comments/client/overrides.ts" + }, + { + "path": "ui/components/when-visible.tsx", + "type": "registry:component", + "content": "\"use client\";\n\nimport { useEffect, useRef, useState, type ReactNode } from \"react\";\n\nexport interface WhenVisibleProps {\n\t/** Content to render once the element scrolls into view */\n\tchildren: ReactNode;\n\t/** Optional placeholder rendered before the element enters the viewport */\n\tfallback?: ReactNode;\n\t/** IntersectionObserver threshold (0–1). Defaults to 0 (any pixel visible). */\n\tthreshold?: number;\n\t/** Root margin passed to IntersectionObserver. Defaults to \"200px\" to preload slightly early. */\n\trootMargin?: string;\n\t/** Additional className applied to the sentinel wrapper div */\n\tclassName?: string;\n}\n\n/**\n * Lazy-mounts children only when the sentinel element scrolls into the viewport.\n * Once mounted, children remain mounted even if the element scrolls out of view.\n *\n * Use this to defer expensive renders (comment threads, carousels, etc.) until\n * the user actually scrolls to that section.\n */\nexport function WhenVisible({\n\tchildren,\n\tfallback = null,\n\tthreshold = 0,\n\trootMargin = \"200px\",\n\tclassName,\n}: WhenVisibleProps) {\n\tconst [isVisible, setIsVisible] = useState(false);\n\tconst sentinelRef = useRef(null);\n\n\tuseEffect(() => {\n\t\tconst el = sentinelRef.current;\n\t\tif (!el) return;\n\n\t\t// If IntersectionObserver is not available (SSR/old browsers), show immediately\n\t\tif (typeof IntersectionObserver === \"undefined\") {\n\t\t\tsetIsVisible(true);\n\t\t\treturn;\n\t\t}\n\n\t\tconst observer = new IntersectionObserver(\n\t\t\t(entries) => {\n\t\t\t\tconst entry = entries[0];\n\t\t\t\tif (entry?.isIntersecting) {\n\t\t\t\t\tsetIsVisible(true);\n\t\t\t\t\tobserver.disconnect();\n\t\t\t\t}\n\t\t\t},\n\t\t\t{ threshold, rootMargin },\n\t\t);\n\n\t\tobserver.observe(el);\n\t\treturn () => observer.disconnect();\n\t}, [threshold, rootMargin]);\n\n\treturn (\n\t\t\n\t\t\t{isVisible ? children : fallback}\n\t\t\n\t);\n}\n", + "target": "src/components/ui/when-visible.tsx" + }, + { + "path": "ui/components/pagination-controls.tsx", + "type": "registry:component", + "content": "\"use client\";\n\nimport { Button } from \"@/components/ui/button\";\nimport { ChevronLeft, ChevronRight } from \"lucide-react\";\n\nexport interface PaginationControlsProps {\n\t/** Current page, 1-based */\n\tcurrentPage: number;\n\ttotalPages: number;\n\ttotal: number;\n\tlimit: number;\n\toffset: number;\n\tonPageChange: (page: number) => void;\n\tlabels?: {\n\t\tprevious?: string;\n\t\tnext?: string;\n\t\t/** Template string; use {from}, {to}, {total} as placeholders */\n\t\tshowing?: string;\n\t};\n}\n\n/**\n * Generic Prev/Next pagination control with a \"Showing X–Y of Z\" label.\n * Plugin-agnostic — pass localized labels as props.\n * Returns null when totalPages ≤ 1.\n */\nexport function PaginationControls({\n\tcurrentPage,\n\ttotalPages,\n\ttotal,\n\tlimit,\n\toffset,\n\tonPageChange,\n\tlabels,\n}: PaginationControlsProps) {\n\tconst previous = labels?.previous ?? \"Previous\";\n\tconst next = labels?.next ?? \"Next\";\n\tconst showingTemplate = labels?.showing ?? \"Showing {from}–{to} of {total}\";\n\n\tconst from = offset + 1;\n\tconst to = Math.min(offset + limit, total);\n\n\tconst showingText = showingTemplate\n\t\t.replace(\"{from}\", String(from))\n\t\t.replace(\"{to}\", String(to))\n\t\t.replace(\"{total}\", String(total));\n\n\tif (totalPages <= 1) {\n\t\treturn null;\n\t}\n\n\treturn (\n\t\t\n\t\t\t{showingText}\n\t\t\t\n\t\t\t\t onPageChange(currentPage - 1)}\n\t\t\t\t\tdisabled={currentPage === 1}\n\t\t\t\t>\n\t\t\t\t\t\n\t\t\t\t\t{previous}\n\t\t\t\t\n\t\t\t\t\n\t\t\t\t\t{currentPage} / {totalPages}\n\t\t\t\t\n\t\t\t\t onPageChange(currentPage + 1)}\n\t\t\t\t\tdisabled={currentPage === totalPages}\n\t\t\t\t>\n\t\t\t\t\t{next}\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/ui/pagination-controls.tsx" + }, + { + "path": "ui/components/page-wrapper.tsx", + "type": "registry:component", + "content": "\"use client\";\n\nimport { PageLayout } from \"./page-layout\";\nimport { StackAttribution } from \"./stack-attribution\";\n\nexport interface PageWrapperProps {\n\tchildren: React.ReactNode;\n\tclassName?: string;\n\ttestId?: string;\n\t/**\n\t * Whether to show the \"Powered by BTST\" attribution.\n\t * Defaults to true.\n\t */\n\tshowAttribution?: boolean;\n}\n\n/**\n * Shared page wrapper component providing consistent layout and optional attribution\n * for plugin pages. Used by blog, CMS, and other plugins.\n *\n * @example\n * ```tsx\n * \n * \n * My Page\n * \n * \n * ```\n */\nexport function PageWrapper({\n\tchildren,\n\tclassName,\n\ttestId,\n\tshowAttribution = true,\n}: PageWrapperProps) {\n\treturn (\n\t\t<>\n\t\t\t\n\t\t\t\t{children}\n\t\t\t\n\n\t\t\t{showAttribution && }\n\t\t>\n\t);\n}\n", + "target": "src/components/ui/page-wrapper.tsx" + }, + { + "path": "ui/hooks/use-route-lifecycle.ts", + "type": "registry:hook", + "content": "\"use client\";\n\nimport { useEffect } from \"react\";\n\n/**\n * Base route context interface that plugins can extend\n */\nexport interface BaseRouteContext {\n\t/** Current route path */\n\tpath: string;\n\t/** Route parameters (e.g., { slug: \"my-post\" }) */\n\tparams?: Record;\n\t/** Whether rendering on server (true) or client (false) */\n\tisSSR: boolean;\n\t/** Additional context properties */\n\t[key: string]: unknown;\n}\n\n/**\n * Minimum interface required for route lifecycle hooks\n * Plugin overrides should implement these optional hooks\n */\nexport interface RouteLifecycleOverrides {\n\t/** Called when a route is rendered */\n\tonRouteRender?: (\n\t\trouteName: string,\n\t\tcontext: TContext,\n\t) => void | Promise;\n\t/** Called when a route encounters an error */\n\tonRouteError?: (\n\t\trouteName: string,\n\t\terror: Error,\n\t\tcontext: TContext,\n\t) => void | Promise;\n}\n\n/**\n * Hook to handle route lifecycle events\n * - Calls authorization check before render\n * - Calls onRouteRender on mount\n * - Handles errors with onRouteError\n *\n * @example\n * ```tsx\n * const overrides = usePluginOverrides(\"myPlugin\");\n *\n * useRouteLifecycle({\n * routeName: \"dashboard\",\n * context: { path: \"/dashboard\", isSSR: typeof window === \"undefined\" },\n * overrides,\n * beforeRenderHook: (overrides, context) => {\n * if (overrides.onBeforeDashboardRendered) {\n * return overrides.onBeforeDashboardRendered(context);\n * }\n * return true;\n * },\n * });\n * ```\n */\nexport function useRouteLifecycle<\n\tTContext extends BaseRouteContext,\n\tTOverrides extends RouteLifecycleOverrides,\n>({\n\trouteName,\n\tcontext,\n\toverrides,\n\tbeforeRenderHook,\n}: {\n\trouteName: string;\n\tcontext: TContext;\n\toverrides: TOverrides;\n\tbeforeRenderHook?: (overrides: TOverrides, context: TContext) => boolean;\n}) {\n\t// Authorization check - runs synchronously before render\n\tif (beforeRenderHook) {\n\t\tconst canRender = beforeRenderHook(overrides, context);\n\t\tif (!canRender) {\n\t\t\tconst error = new Error(`Unauthorized: Cannot render ${routeName}`);\n\t\t\t// Call error hook synchronously\n\t\t\tif (overrides.onRouteError) {\n\t\t\t\ttry {\n\t\t\t\t\tconst result = overrides.onRouteError(routeName, error, context);\n\t\t\t\t\tif (result instanceof Promise) {\n\t\t\t\t\t\tresult.catch(() => {}); // Ignore promise rejection\n\t\t\t\t\t}\n\t\t\t\t} catch {\n\t\t\t\t\t// Ignore errors in error hook\n\t\t\t\t}\n\t\t\t}\n\t\t\tthrow error;\n\t\t}\n\t}\n\n\t// Lifecycle hook - runs on mount\n\tuseEffect(() => {\n\t\tif (overrides.onRouteRender) {\n\t\t\ttry {\n\t\t\t\tconst result = overrides.onRouteRender(routeName, context);\n\t\t\t\tif (result instanceof Promise) {\n\t\t\t\t\tresult.catch((error) => {\n\t\t\t\t\t\t// If onRouteRender throws, call onRouteError\n\t\t\t\t\t\tif (overrides.onRouteError) {\n\t\t\t\t\t\t\toverrides.onRouteError(routeName, error, context);\n\t\t\t\t\t\t}\n\t\t\t\t\t});\n\t\t\t\t}\n\t\t\t} catch (error) {\n\t\t\t\t// If onRouteRender throws, call onRouteError\n\t\t\t\tif (overrides.onRouteError) {\n\t\t\t\t\toverrides.onRouteError(routeName, error as Error, context);\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}, [routeName, overrides, context]);\n}\n", + "target": "src/hooks/use-route-lifecycle.ts" + } + ], + "docs": "https://better-stack.ai/docs/plugins/comments" +} diff --git a/packages/stack/registry/btst-kanban.json b/packages/stack/registry/btst-kanban.json index 63a919ec..968bab17 100644 --- a/packages/stack/registry/btst-kanban.json +++ b/packages/stack/registry/btst-kanban.json @@ -91,7 +91,7 @@ { "path": "btst/kanban/client/components/pages/board-page.internal.tsx", "type": "registry:component", - "content": "\"use client\";\n\nimport { useState, useCallback, useMemo, useEffect } from \"react\";\nimport { ArrowLeft, Plus, Settings, Trash2, Pencil } from \"lucide-react\";\nimport { toast } from \"sonner\";\nimport { Button } from \"@/components/ui/button\";\nimport {\n\tDropdownMenu,\n\tDropdownMenuContent,\n\tDropdownMenuItem,\n\tDropdownMenuSeparator,\n\tDropdownMenuTrigger,\n} from \"@/components/ui/dropdown-menu\";\nimport {\n\tDialog,\n\tDialogContent,\n\tDialogDescription,\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 {\n\tuseSuspenseBoard,\n\tuseBoardMutations,\n\tuseColumnMutations,\n\tuseTaskMutations,\n} from \"@btst/stack/plugins/kanban/client/hooks\";\nimport { usePluginOverrides } from \"@btst/stack/context\";\nimport type { KanbanPluginOverrides } from \"../../overrides\";\nimport { KanbanBoard } from \"../shared/kanban-board\";\nimport { ColumnForm } from \"../forms/column-form\";\nimport { BoardForm } from \"../forms/board-form\";\nimport { TaskForm } from \"../forms/task-form\";\nimport { PageWrapper } from \"../shared/page-wrapper\";\nimport { EmptyState } from \"../shared/empty-state\";\nimport type { SerializedTask, SerializedColumn } from \"../../../types\";\n\ninterface BoardPageProps {\n\tboardId: string;\n}\n\ntype ModalState =\n\t| { type: \"none\" }\n\t| { type: \"addColumn\" }\n\t| { type: \"editColumn\"; columnId: string }\n\t| { type: \"deleteColumn\"; columnId: string }\n\t| { type: \"editBoard\" }\n\t| { type: \"deleteBoard\" }\n\t| { type: \"addTask\"; columnId: string }\n\t| { type: \"editTask\"; columnId: string; taskId: string };\n\nexport function BoardPage({ boardId }: BoardPageProps) {\n\tconst { data: board, error, refetch, isFetching } = useSuspenseBoard(boardId);\n\n\t// Suspense hooks only throw on initial fetch, not refetch failures\n\tif (error && !isFetching) {\n\t\tthrow error;\n\t}\n\n\tconst { Link: OverrideLink, navigate: overrideNavigate } =\n\t\tusePluginOverrides(\"kanban\");\n\tconst navigate =\n\t\toverrideNavigate ||\n\t\t((path: string) => {\n\t\t\twindow.location.href = path;\n\t\t});\n\tconst Link = OverrideLink || \"a\";\n\n\tconst { deleteBoard, isDeleting } = useBoardMutations();\n\tconst { deleteColumn, reorderColumns } = useColumnMutations();\n\tconst { deleteTask, moveTask, reorderTasks } = useTaskMutations();\n\n\tconst [modalState, setModalState] = useState({ type: \"none\" });\n\n\t// Helper function to convert board columns to kanban state format\n\tconst computeKanbanData = useCallback(\n\t\t(\n\t\t\tcolumns: SerializedColumn[] | undefined,\n\t\t): Record => {\n\t\t\tif (!columns) return {};\n\t\t\treturn columns.reduce(\n\t\t\t\t(acc, column) => {\n\t\t\t\t\tacc[column.id] = column.tasks || [];\n\t\t\t\t\treturn acc;\n\t\t\t\t},\n\t\t\t\t{} as Record,\n\t\t\t);\n\t\t},\n\t\t[],\n\t);\n\n\t// Initialize kanbanState with data from board to avoid flash of empty state\n\t// Using lazy initializer ensures we have the correct state on first render\n\tconst [kanbanState, setKanbanState] = useState<\n\t\tRecord\n\t>(() => computeKanbanData(board?.columns));\n\n\t// Keep kanbanState in sync when server data changes (e.g., after refetch)\n\tconst serverKanbanData = useMemo(\n\t\t() => computeKanbanData(board?.columns),\n\t\t[board?.columns, computeKanbanData],\n\t);\n\n\tuseEffect(() => {\n\t\tsetKanbanState(serverKanbanData);\n\t}, [serverKanbanData]);\n\n\tconst closeModal = useCallback(() => {\n\t\tsetModalState({ type: \"none\" });\n\t}, []);\n\n\tconst handleDeleteBoard = useCallback(async () => {\n\t\ttry {\n\t\t\tawait deleteBoard(boardId);\n\t\t\tcloseModal();\n\t\t\t// Use both navigate and a fallback to ensure navigation works\n\t\t\t// Some frameworks may have issues with router.push after mutations\n\t\t\tnavigate(\"/pages/kanban\");\n\t\t\t// Fallback: if navigate doesn't work, use window.location\n\t\t\tif (typeof window !== \"undefined\") {\n\t\t\t\tsetTimeout(() => {\n\t\t\t\t\t// Only redirect if we're still on the same page after 100ms\n\t\t\t\t\tif (window.location.pathname.includes(boardId)) {\n\t\t\t\t\t\twindow.location.href = \"/pages/kanban\";\n\t\t\t\t\t}\n\t\t\t\t}, 100);\n\t\t\t}\n\t\t} catch (error) {\n\t\t\tconst message =\n\t\t\t\terror instanceof Error ? error.message : \"Failed to delete board\";\n\t\t\ttoast.error(message);\n\t\t}\n\t}, [deleteBoard, boardId, navigate, closeModal]);\n\n\tconst handleKanbanChange = useCallback(\n\t\tasync (newData: Record) => {\n\t\t\tif (!board) return;\n\n\t\t\t// Capture current state for change detection\n\t\t\t// Note: We use a functional update to get the actual current state,\n\t\t\t// avoiding stale closure issues with rapid successive operations\n\t\t\tlet previousState: Record = {};\n\t\t\tsetKanbanState((current) => {\n\t\t\t\tpreviousState = current;\n\t\t\t\treturn newData;\n\t\t\t});\n\n\t\t\ttry {\n\t\t\t\t// Detect column reorder\n\t\t\t\tconst oldKeys = Object.keys(previousState);\n\t\t\t\tconst newKeys = Object.keys(newData);\n\t\t\t\tconst isColumnMove =\n\t\t\t\t\toldKeys.length === newKeys.length &&\n\t\t\t\t\toldKeys.join(\"\") !== newKeys.join(\"\");\n\n\t\t\t\tif (isColumnMove) {\n\t\t\t\t\t// Column reorder - use atomic batch endpoint with transaction support\n\t\t\t\t\tawait reorderColumns(board.id, newKeys);\n\t\t\t\t} else {\n\t\t\t\t\t// Task changes - detect cross-column moves and within-column reorders\n\t\t\t\t\tconst crossColumnMoves: Array<{\n\t\t\t\t\t\ttaskId: string;\n\t\t\t\t\t\ttargetColumnId: string;\n\t\t\t\t\t\ttargetOrder: number;\n\t\t\t\t\t}> = [];\n\t\t\t\t\tconst columnsToReorder: Map = new Map();\n\t\t\t\t\tconst targetColumnsOfCrossMove = new Set();\n\n\t\t\t\t\tfor (const [columnId, tasks] of Object.entries(newData)) {\n\t\t\t\t\t\tconst oldTasks = previousState[columnId] || [];\n\t\t\t\t\t\tlet hasOrderChanges = false;\n\n\t\t\t\t\t\tfor (let i = 0; i < tasks.length; i++) {\n\t\t\t\t\t\t\tconst task = tasks[i];\n\t\t\t\t\t\t\tif (!task) continue;\n\n\t\t\t\t\t\t\tif (task.columnId !== columnId) {\n\t\t\t\t\t\t\t\t// Task moved from another column - needs cross-column move\n\t\t\t\t\t\t\t\tcrossColumnMoves.push({\n\t\t\t\t\t\t\t\t\ttaskId: task.id,\n\t\t\t\t\t\t\t\t\ttargetColumnId: columnId,\n\t\t\t\t\t\t\t\t\ttargetOrder: i,\n\t\t\t\t\t\t\t\t});\n\t\t\t\t\t\t\t\ttargetColumnsOfCrossMove.add(columnId);\n\t\t\t\t\t\t\t} else if (task.order !== i) {\n\t\t\t\t\t\t\t\t// Task order changed within same column\n\t\t\t\t\t\t\t\thasOrderChanges = true;\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t}\n\n\t\t\t\t\t\t// Check if tasks were removed from this column (moved elsewhere)\n\t\t\t\t\t\tconst newTaskIds = new Set(tasks.map((t) => t.id));\n\t\t\t\t\t\tconst tasksRemoved = oldTasks.some((t) => !newTaskIds.has(t.id));\n\n\t\t\t\t\t\t// If order changes within column (not a target of cross-column move),\n\t\t\t\t\t\t// use atomic reorder\n\t\t\t\t\t\tif (\n\t\t\t\t\t\t\thasOrderChanges &&\n\t\t\t\t\t\t\t!targetColumnsOfCrossMove.has(columnId) &&\n\t\t\t\t\t\t\t!tasksRemoved\n\t\t\t\t\t\t) {\n\t\t\t\t\t\t\tcolumnsToReorder.set(\n\t\t\t\t\t\t\t\tcolumnId,\n\t\t\t\t\t\t\t\ttasks.map((t) => t.id),\n\t\t\t\t\t\t\t);\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\n\t\t\t\t\t// Handle cross-column moves first (these need individual moveTask calls)\n\t\t\t\t\tfor (const move of crossColumnMoves) {\n\t\t\t\t\t\tawait moveTask(move.taskId, move.targetColumnId, move.targetOrder);\n\t\t\t\t\t}\n\n\t\t\t\t\t// Then handle within-column reorders atomically\n\t\t\t\t\tfor (const [columnId, taskIds] of columnsToReorder) {\n\t\t\t\t\t\tawait reorderTasks(columnId, taskIds);\n\t\t\t\t\t}\n\n\t\t\t\t\t// Reorder target columns of cross-column moves to fix order collisions\n\t\t\t\t\t// The moveTask only sets the moved task's order, so other tasks need reordering\n\t\t\t\t\tfor (const targetColumnId of targetColumnsOfCrossMove) {\n\t\t\t\t\t\tconst tasks = newData[targetColumnId];\n\t\t\t\t\t\tif (tasks) {\n\t\t\t\t\t\t\tawait reorderTasks(\n\t\t\t\t\t\t\t\ttargetColumnId,\n\t\t\t\t\t\t\t\ttasks.map((t) => t.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// Sync with server after successful mutations\n\t\t\t\trefetch();\n\t\t\t} catch (error) {\n\t\t\t\t// On error, refetch from server to get the authoritative state.\n\t\t\t\t// We avoid manual rollback to previousState because with rapid successive\n\t\t\t\t// operations, the captured previousState may be stale - a later operation\n\t\t\t\t// may have already updated the state, and reverting would incorrectly\n\t\t\t\t// undo that operation too. The server is the source of truth.\n\t\t\t\trefetch();\n\t\t\t\t// Re-throw so error boundaries or toast handlers can catch it\n\t\t\t\tthrow error;\n\t\t\t}\n\t\t},\n\t\t[board, reorderColumns, moveTask, reorderTasks, refetch],\n\t);\n\n\tconst orderedColumns = useMemo(() => {\n\t\tif (!board?.columns) return [];\n\t\tconst columnMap = new Map(board.columns.map((c) => [c.id, c]));\n\t\treturn Object.keys(kanbanState)\n\t\t\t.map((columnId) => {\n\t\t\t\tconst column = columnMap.get(columnId);\n\t\t\t\tif (!column) return null;\n\t\t\t\treturn {\n\t\t\t\t\t...column,\n\t\t\t\t\ttasks: kanbanState[columnId] || [],\n\t\t\t\t};\n\t\t\t})\n\t\t\t.filter(\n\t\t\t\t(c): c is SerializedColumn & { tasks: SerializedTask[] } => c !== null,\n\t\t\t);\n\t}, [board?.columns, kanbanState]);\n\n\t// Board not found - only shown after data has loaded (not during loading)\n\tif (!board) {\n\t\treturn (\n\t\t\t navigate(\"/pages/kanban\")}>\n\t\t\t\t\t\t\n\t\t\t\t\t\tBack to Boards\n\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\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{board.name}\n\t\t\t\t\t\t\n\t\t\t\t\t\t{board.description && (\n\t\t\t\t\t\t\t{board.description}\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\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\tActions\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 setModalState({ type: \"addColumn\" })}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\tAdd Column\n\t\t\t\t\t\t\n\t\t\t\t\t\t setModalState({ type: \"editBoard\" })}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\tEdit Board\n\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t setModalState({ type: \"deleteBoard\" })}\n\t\t\t\t\t\t\tclassName=\"text-red-600 focus:text-red-600\"\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\tDelete Board\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{orderedColumns.length > 0 ? (\n\t\t\t\t setModalState({ type: \"addTask\", columnId })}\n\t\t\t\t\tonEditTask={(columnId, taskId) =>\n\t\t\t\t\t\tsetModalState({ type: \"editTask\", columnId, taskId })\n\t\t\t\t\t}\n\t\t\t\t\tonEditColumn={(columnId) =>\n\t\t\t\t\t\tsetModalState({ type: \"editColumn\", columnId })\n\t\t\t\t\t}\n\t\t\t\t\tonDeleteColumn={(columnId) =>\n\t\t\t\t\t\tsetModalState({ type: \"deleteColumn\", columnId })\n\t\t\t\t\t}\n\t\t\t\t/>\n\t\t\t) : (\n\t\t\t\t setModalState({ type: \"addColumn\" })}>\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\tAdd Column\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{/* Add Column Modal */}\n\t\t\t !open && closeModal()}\n\t\t\t>\n\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\tAdd Column\n\t\t\t\t\t\t\n\t\t\t\t\t\t\tAdd a new column to this board.\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\tcloseModal();\n\t\t\t\t\t\t\trefetch();\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{/* Edit Column Modal */}\n\t\t\t !open && closeModal()}\n\t\t\t>\n\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\tEdit Column\n\t\t\t\t\t\tUpdate the column details.\n\t\t\t\t\t\n\t\t\t\t\t{modalState.type === \"editColumn\" && (\n\t\t\t\t\t\t c.id === modalState.columnId)}\n\t\t\t\t\t\t\tonClose={closeModal}\n\t\t\t\t\t\t\tonSuccess={() => {\n\t\t\t\t\t\t\t\tcloseModal();\n\t\t\t\t\t\t\t\trefetch();\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 Column Modal */}\n\t\t\t !open && closeModal()}\n\t\t\t>\n\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\tDelete Column\n\t\t\t\t\t\t\n\t\t\t\t\t\t\tAre you sure you want to delete this column? All tasks in this\n\t\t\t\t\t\t\tcolumn will be permanently removed.\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\tCancel\n\t\t\t\t\t\t {\n\t\t\t\t\t\t\t\tif (modalState.type === \"deleteColumn\") {\n\t\t\t\t\t\t\t\t\ttry {\n\t\t\t\t\t\t\t\t\t\tawait deleteColumn(modalState.columnId);\n\t\t\t\t\t\t\t\t\t\tcloseModal();\n\t\t\t\t\t\t\t\t\t\trefetch();\n\t\t\t\t\t\t\t\t\t} catch (error) {\n\t\t\t\t\t\t\t\t\t\tconst message =\n\t\t\t\t\t\t\t\t\t\t\terror instanceof Error\n\t\t\t\t\t\t\t\t\t\t\t\t? error.message\n\t\t\t\t\t\t\t\t\t\t\t\t: \"Failed to delete column\";\n\t\t\t\t\t\t\t\t\t\ttoast.error(message);\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\tclassName=\"bg-red-600 hover:bg-red-700\"\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\tDelete\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{/* Edit Board Modal */}\n\t\t\t !open && closeModal()}\n\t\t\t>\n\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\tEdit Board\n\t\t\t\t\t\tUpdate board details.\n\t\t\t\t\t\n\t\t\t\t\t {\n\t\t\t\t\t\t\tcloseModal();\n\t\t\t\t\t\t\trefetch();\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 Board Modal */}\n\t\t\t !open && closeModal()}\n\t\t\t>\n\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\tDelete Board\n\t\t\t\t\t\t\n\t\t\t\t\t\t\tAre you sure you want to delete this board? This action cannot be\n\t\t\t\t\t\t\tundone. All columns and tasks will be permanently removed.\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\tCancel\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t{isDeleting ? \"Deleting...\" : \"Delete\"}\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{/* Add Task Modal */}\n\t\t\t !open && closeModal()}\n\t\t\t>\n\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\tAdd Task\n\t\t\t\t\t\tCreate a new task.\n\t\t\t\t\t\n\t\t\t\t\t{modalState.type === \"addTask\" && (\n\t\t\t\t\t\t {\n\t\t\t\t\t\t\t\tcloseModal();\n\t\t\t\t\t\t\t\trefetch();\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{/* Edit Task Modal */}\n\t\t\t !open && closeModal()}\n\t\t\t>\n\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\tEdit Task\n\t\t\t\t\t\tUpdate task details.\n\t\t\t\t\t\n\t\t\t\t\t{modalState.type === \"editTask\" && (\n\t\t\t\t\t\t c.id === modalState.columnId)\n\t\t\t\t\t\t\t\t?.tasks?.find((t) => t.id === modalState.taskId)}\n\t\t\t\t\t\t\tcolumns={board.columns || []}\n\t\t\t\t\t\t\tonClose={closeModal}\n\t\t\t\t\t\t\tonSuccess={() => {\n\t\t\t\t\t\t\t\tcloseModal();\n\t\t\t\t\t\t\t\trefetch();\n\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\tonDelete={async () => {\n\t\t\t\t\t\t\t\ttry {\n\t\t\t\t\t\t\t\t\tawait deleteTask(modalState.taskId);\n\t\t\t\t\t\t\t\t\tcloseModal();\n\t\t\t\t\t\t\t\t\trefetch();\n\t\t\t\t\t\t\t\t} catch (error) {\n\t\t\t\t\t\t\t\t\tconst message =\n\t\t\t\t\t\t\t\t\t\terror instanceof Error\n\t\t\t\t\t\t\t\t\t\t\t? error.message\n\t\t\t\t\t\t\t\t\t\t\t: \"Failed to delete task\";\n\t\t\t\t\t\t\t\t\ttoast.error(message);\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", + "content": "\"use client\";\n\nimport { useState, useCallback, useMemo, useEffect } from \"react\";\nimport { ArrowLeft, Plus, Settings, Trash2, Pencil } from \"lucide-react\";\nimport { toast } from \"sonner\";\nimport { Button } from \"@/components/ui/button\";\nimport {\n\tDropdownMenu,\n\tDropdownMenuContent,\n\tDropdownMenuItem,\n\tDropdownMenuSeparator,\n\tDropdownMenuTrigger,\n} from \"@/components/ui/dropdown-menu\";\nimport {\n\tDialog,\n\tDialogContent,\n\tDialogDescription,\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 {\n\tuseSuspenseBoard,\n\tuseBoardMutations,\n\tuseColumnMutations,\n\tuseTaskMutations,\n} from \"@btst/stack/plugins/kanban/client/hooks\";\nimport { usePluginOverrides } from \"@btst/stack/context\";\nimport type { KanbanPluginOverrides } from \"../../overrides\";\nimport { KanbanBoard } from \"../shared/kanban-board\";\nimport { ColumnForm } from \"../forms/column-form\";\nimport { BoardForm } from \"../forms/board-form\";\nimport { TaskForm } from \"../forms/task-form\";\nimport { PageWrapper } from \"../shared/page-wrapper\";\nimport { EmptyState } from \"../shared/empty-state\";\nimport type { SerializedTask, SerializedColumn } from \"../../../types\";\n\ninterface BoardPageProps {\n\tboardId: string;\n}\n\ntype ModalState =\n\t| { type: \"none\" }\n\t| { type: \"addColumn\" }\n\t| { type: \"editColumn\"; columnId: string }\n\t| { type: \"deleteColumn\"; columnId: string }\n\t| { type: \"editBoard\" }\n\t| { type: \"deleteBoard\" }\n\t| { type: \"addTask\"; columnId: string }\n\t| { type: \"editTask\"; columnId: string; taskId: string };\n\nexport function BoardPage({ boardId }: BoardPageProps) {\n\tconst { data: board, error, refetch, isFetching } = useSuspenseBoard(boardId);\n\n\t// Suspense hooks only throw on initial fetch, not refetch failures\n\tif (error && !isFetching) {\n\t\tthrow error;\n\t}\n\n\tconst {\n\t\tLink: OverrideLink,\n\t\tnavigate: overrideNavigate,\n\t\ttaskDetailBottomSlot,\n\t} = usePluginOverrides(\"kanban\");\n\tconst navigate =\n\t\toverrideNavigate ||\n\t\t((path: string) => {\n\t\t\twindow.location.href = path;\n\t\t});\n\tconst Link = OverrideLink || \"a\";\n\n\tconst { deleteBoard, isDeleting } = useBoardMutations();\n\tconst { deleteColumn, reorderColumns } = useColumnMutations();\n\tconst { deleteTask, moveTask, reorderTasks } = useTaskMutations();\n\n\tconst [modalState, setModalState] = useState({ type: \"none\" });\n\n\t// Helper function to convert board columns to kanban state format\n\tconst computeKanbanData = useCallback(\n\t\t(\n\t\t\tcolumns: SerializedColumn[] | undefined,\n\t\t): Record => {\n\t\t\tif (!columns) return {};\n\t\t\treturn columns.reduce(\n\t\t\t\t(acc, column) => {\n\t\t\t\t\tacc[column.id] = column.tasks || [];\n\t\t\t\t\treturn acc;\n\t\t\t\t},\n\t\t\t\t{} as Record,\n\t\t\t);\n\t\t},\n\t\t[],\n\t);\n\n\t// Initialize kanbanState with data from board to avoid flash of empty state\n\t// Using lazy initializer ensures we have the correct state on first render\n\tconst [kanbanState, setKanbanState] = useState<\n\t\tRecord\n\t>(() => computeKanbanData(board?.columns));\n\n\t// Keep kanbanState in sync when server data changes (e.g., after refetch)\n\tconst serverKanbanData = useMemo(\n\t\t() => computeKanbanData(board?.columns),\n\t\t[board?.columns, computeKanbanData],\n\t);\n\n\tuseEffect(() => {\n\t\tsetKanbanState(serverKanbanData);\n\t}, [serverKanbanData]);\n\n\tconst closeModal = useCallback(() => {\n\t\tsetModalState({ type: \"none\" });\n\t}, []);\n\n\tconst handleDeleteBoard = useCallback(async () => {\n\t\ttry {\n\t\t\tawait deleteBoard(boardId);\n\t\t\tcloseModal();\n\t\t\t// Use both navigate and a fallback to ensure navigation works\n\t\t\t// Some frameworks may have issues with router.push after mutations\n\t\t\tnavigate(\"/pages/kanban\");\n\t\t\t// Fallback: if navigate doesn't work, use window.location\n\t\t\tif (typeof window !== \"undefined\") {\n\t\t\t\tsetTimeout(() => {\n\t\t\t\t\t// Only redirect if we're still on the same page after 100ms\n\t\t\t\t\tif (window.location.pathname.includes(boardId)) {\n\t\t\t\t\t\twindow.location.href = \"/pages/kanban\";\n\t\t\t\t\t}\n\t\t\t\t}, 100);\n\t\t\t}\n\t\t} catch (error) {\n\t\t\tconst message =\n\t\t\t\terror instanceof Error ? error.message : \"Failed to delete board\";\n\t\t\ttoast.error(message);\n\t\t}\n\t}, [deleteBoard, boardId, navigate, closeModal]);\n\n\tconst handleKanbanChange = useCallback(\n\t\tasync (newData: Record) => {\n\t\t\tif (!board) return;\n\n\t\t\t// Capture current state for change detection\n\t\t\t// Note: We use a functional update to get the actual current state,\n\t\t\t// avoiding stale closure issues with rapid successive operations\n\t\t\tlet previousState: Record = {};\n\t\t\tsetKanbanState((current) => {\n\t\t\t\tpreviousState = current;\n\t\t\t\treturn newData;\n\t\t\t});\n\n\t\t\ttry {\n\t\t\t\t// Detect column reorder\n\t\t\t\tconst oldKeys = Object.keys(previousState);\n\t\t\t\tconst newKeys = Object.keys(newData);\n\t\t\t\tconst isColumnMove =\n\t\t\t\t\toldKeys.length === newKeys.length &&\n\t\t\t\t\toldKeys.join(\"\") !== newKeys.join(\"\");\n\n\t\t\t\tif (isColumnMove) {\n\t\t\t\t\t// Column reorder - use atomic batch endpoint with transaction support\n\t\t\t\t\tawait reorderColumns(board.id, newKeys);\n\t\t\t\t} else {\n\t\t\t\t\t// Task changes - detect cross-column moves and within-column reorders\n\t\t\t\t\tconst crossColumnMoves: Array<{\n\t\t\t\t\t\ttaskId: string;\n\t\t\t\t\t\ttargetColumnId: string;\n\t\t\t\t\t\ttargetOrder: number;\n\t\t\t\t\t}> = [];\n\t\t\t\t\tconst columnsToReorder: Map = new Map();\n\t\t\t\t\tconst targetColumnsOfCrossMove = new Set();\n\n\t\t\t\t\tfor (const [columnId, tasks] of Object.entries(newData)) {\n\t\t\t\t\t\tconst oldTasks = previousState[columnId] || [];\n\t\t\t\t\t\tlet hasOrderChanges = false;\n\n\t\t\t\t\t\tfor (let i = 0; i < tasks.length; i++) {\n\t\t\t\t\t\t\tconst task = tasks[i];\n\t\t\t\t\t\t\tif (!task) continue;\n\n\t\t\t\t\t\t\tif (task.columnId !== columnId) {\n\t\t\t\t\t\t\t\t// Task moved from another column - needs cross-column move\n\t\t\t\t\t\t\t\tcrossColumnMoves.push({\n\t\t\t\t\t\t\t\t\ttaskId: task.id,\n\t\t\t\t\t\t\t\t\ttargetColumnId: columnId,\n\t\t\t\t\t\t\t\t\ttargetOrder: i,\n\t\t\t\t\t\t\t\t});\n\t\t\t\t\t\t\t\ttargetColumnsOfCrossMove.add(columnId);\n\t\t\t\t\t\t\t} else if (task.order !== i) {\n\t\t\t\t\t\t\t\t// Task order changed within same column\n\t\t\t\t\t\t\t\thasOrderChanges = true;\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t}\n\n\t\t\t\t\t\t// Check if tasks were removed from this column (moved elsewhere)\n\t\t\t\t\t\tconst newTaskIds = new Set(tasks.map((t) => t.id));\n\t\t\t\t\t\tconst tasksRemoved = oldTasks.some((t) => !newTaskIds.has(t.id));\n\n\t\t\t\t\t\t// If order changes within column (not a target of cross-column move),\n\t\t\t\t\t\t// use atomic reorder\n\t\t\t\t\t\tif (\n\t\t\t\t\t\t\thasOrderChanges &&\n\t\t\t\t\t\t\t!targetColumnsOfCrossMove.has(columnId) &&\n\t\t\t\t\t\t\t!tasksRemoved\n\t\t\t\t\t\t) {\n\t\t\t\t\t\t\tcolumnsToReorder.set(\n\t\t\t\t\t\t\t\tcolumnId,\n\t\t\t\t\t\t\t\ttasks.map((t) => t.id),\n\t\t\t\t\t\t\t);\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\n\t\t\t\t\t// Handle cross-column moves first (these need individual moveTask calls)\n\t\t\t\t\tfor (const move of crossColumnMoves) {\n\t\t\t\t\t\tawait moveTask(move.taskId, move.targetColumnId, move.targetOrder);\n\t\t\t\t\t}\n\n\t\t\t\t\t// Then handle within-column reorders atomically\n\t\t\t\t\tfor (const [columnId, taskIds] of columnsToReorder) {\n\t\t\t\t\t\tawait reorderTasks(columnId, taskIds);\n\t\t\t\t\t}\n\n\t\t\t\t\t// Reorder target columns of cross-column moves to fix order collisions\n\t\t\t\t\t// The moveTask only sets the moved task's order, so other tasks need reordering\n\t\t\t\t\tfor (const targetColumnId of targetColumnsOfCrossMove) {\n\t\t\t\t\t\tconst tasks = newData[targetColumnId];\n\t\t\t\t\t\tif (tasks) {\n\t\t\t\t\t\t\tawait reorderTasks(\n\t\t\t\t\t\t\t\ttargetColumnId,\n\t\t\t\t\t\t\t\ttasks.map((t) => t.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// Sync with server after successful mutations\n\t\t\t\trefetch();\n\t\t\t} catch (error) {\n\t\t\t\t// On error, refetch from server to get the authoritative state.\n\t\t\t\t// We avoid manual rollback to previousState because with rapid successive\n\t\t\t\t// operations, the captured previousState may be stale - a later operation\n\t\t\t\t// may have already updated the state, and reverting would incorrectly\n\t\t\t\t// undo that operation too. The server is the source of truth.\n\t\t\t\trefetch();\n\t\t\t\t// Re-throw so error boundaries or toast handlers can catch it\n\t\t\t\tthrow error;\n\t\t\t}\n\t\t},\n\t\t[board, reorderColumns, moveTask, reorderTasks, refetch],\n\t);\n\n\tconst orderedColumns = useMemo(() => {\n\t\tif (!board?.columns) return [];\n\t\tconst columnMap = new Map(board.columns.map((c) => [c.id, c]));\n\t\treturn Object.keys(kanbanState)\n\t\t\t.map((columnId) => {\n\t\t\t\tconst column = columnMap.get(columnId);\n\t\t\t\tif (!column) return null;\n\t\t\t\treturn {\n\t\t\t\t\t...column,\n\t\t\t\t\ttasks: kanbanState[columnId] || [],\n\t\t\t\t};\n\t\t\t})\n\t\t\t.filter(\n\t\t\t\t(c): c is SerializedColumn & { tasks: SerializedTask[] } => c !== null,\n\t\t\t);\n\t}, [board?.columns, kanbanState]);\n\n\t// Board not found - only shown after data has loaded (not during loading)\n\tif (!board) {\n\t\treturn (\n\t\t\t navigate(\"/pages/kanban\")}>\n\t\t\t\t\t\t\n\t\t\t\t\t\tBack to Boards\n\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\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{board.name}\n\t\t\t\t\t\t\n\t\t\t\t\t\t{board.description && (\n\t\t\t\t\t\t\t{board.description}\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\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\tActions\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 setModalState({ type: \"addColumn\" })}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\tAdd Column\n\t\t\t\t\t\t\n\t\t\t\t\t\t setModalState({ type: \"editBoard\" })}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\tEdit Board\n\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t setModalState({ type: \"deleteBoard\" })}\n\t\t\t\t\t\t\tclassName=\"text-red-600 focus:text-red-600\"\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\tDelete Board\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{orderedColumns.length > 0 ? (\n\t\t\t\t setModalState({ type: \"addTask\", columnId })}\n\t\t\t\t\tonEditTask={(columnId, taskId) =>\n\t\t\t\t\t\tsetModalState({ type: \"editTask\", columnId, taskId })\n\t\t\t\t\t}\n\t\t\t\t\tonEditColumn={(columnId) =>\n\t\t\t\t\t\tsetModalState({ type: \"editColumn\", columnId })\n\t\t\t\t\t}\n\t\t\t\t\tonDeleteColumn={(columnId) =>\n\t\t\t\t\t\tsetModalState({ type: \"deleteColumn\", columnId })\n\t\t\t\t\t}\n\t\t\t\t/>\n\t\t\t) : (\n\t\t\t\t setModalState({ type: \"addColumn\" })}>\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\tAdd Column\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{/* Add Column Modal */}\n\t\t\t !open && closeModal()}\n\t\t\t>\n\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\tAdd Column\n\t\t\t\t\t\t\n\t\t\t\t\t\t\tAdd a new column to this board.\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\tcloseModal();\n\t\t\t\t\t\t\trefetch();\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{/* Edit Column Modal */}\n\t\t\t !open && closeModal()}\n\t\t\t>\n\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\tEdit Column\n\t\t\t\t\t\tUpdate the column details.\n\t\t\t\t\t\n\t\t\t\t\t{modalState.type === \"editColumn\" && (\n\t\t\t\t\t\t c.id === modalState.columnId)}\n\t\t\t\t\t\t\tonClose={closeModal}\n\t\t\t\t\t\t\tonSuccess={() => {\n\t\t\t\t\t\t\t\tcloseModal();\n\t\t\t\t\t\t\t\trefetch();\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 Column Modal */}\n\t\t\t !open && closeModal()}\n\t\t\t>\n\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\tDelete Column\n\t\t\t\t\t\t\n\t\t\t\t\t\t\tAre you sure you want to delete this column? All tasks in this\n\t\t\t\t\t\t\tcolumn will be permanently removed.\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\tCancel\n\t\t\t\t\t\t {\n\t\t\t\t\t\t\t\tif (modalState.type === \"deleteColumn\") {\n\t\t\t\t\t\t\t\t\ttry {\n\t\t\t\t\t\t\t\t\t\tawait deleteColumn(modalState.columnId);\n\t\t\t\t\t\t\t\t\t\tcloseModal();\n\t\t\t\t\t\t\t\t\t\trefetch();\n\t\t\t\t\t\t\t\t\t} catch (error) {\n\t\t\t\t\t\t\t\t\t\tconst message =\n\t\t\t\t\t\t\t\t\t\t\terror instanceof Error\n\t\t\t\t\t\t\t\t\t\t\t\t? error.message\n\t\t\t\t\t\t\t\t\t\t\t\t: \"Failed to delete column\";\n\t\t\t\t\t\t\t\t\t\ttoast.error(message);\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\tclassName=\"bg-red-600 hover:bg-red-700\"\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\tDelete\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{/* Edit Board Modal */}\n\t\t\t !open && closeModal()}\n\t\t\t>\n\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\tEdit Board\n\t\t\t\t\t\tUpdate board details.\n\t\t\t\t\t\n\t\t\t\t\t {\n\t\t\t\t\t\t\tcloseModal();\n\t\t\t\t\t\t\trefetch();\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 Board Modal */}\n\t\t\t !open && closeModal()}\n\t\t\t>\n\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\tDelete Board\n\t\t\t\t\t\t\n\t\t\t\t\t\t\tAre you sure you want to delete this board? This action cannot be\n\t\t\t\t\t\t\tundone. All columns and tasks will be permanently removed.\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\tCancel\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t{isDeleting ? \"Deleting...\" : \"Delete\"}\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{/* Add Task Modal */}\n\t\t\t !open && closeModal()}\n\t\t\t>\n\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\tAdd Task\n\t\t\t\t\t\tCreate a new task.\n\t\t\t\t\t\n\t\t\t\t\t{modalState.type === \"addTask\" && (\n\t\t\t\t\t\t {\n\t\t\t\t\t\t\t\tcloseModal();\n\t\t\t\t\t\t\t\trefetch();\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{/* Edit Task Modal */}\n\t\t\t !open && closeModal()}\n\t\t\t>\n\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\tEdit Task\n\t\t\t\t\t\tUpdate task details.\n\t\t\t\t\t\n\t\t\t\t\t{modalState.type === \"editTask\" && (\n\t\t\t\t\t\t<>\n\t\t\t\t\t\t\t c.id === modalState.columnId)\n\t\t\t\t\t\t\t\t\t?.tasks?.find((t) => t.id === modalState.taskId)}\n\t\t\t\t\t\t\t\tcolumns={board.columns || []}\n\t\t\t\t\t\t\t\tonClose={closeModal}\n\t\t\t\t\t\t\t\tonSuccess={() => {\n\t\t\t\t\t\t\t\t\tcloseModal();\n\t\t\t\t\t\t\t\t\trefetch();\n\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\tonDelete={async () => {\n\t\t\t\t\t\t\t\t\ttry {\n\t\t\t\t\t\t\t\t\t\tawait deleteTask(modalState.taskId);\n\t\t\t\t\t\t\t\t\t\tcloseModal();\n\t\t\t\t\t\t\t\t\t\trefetch();\n\t\t\t\t\t\t\t\t\t} catch (error) {\n\t\t\t\t\t\t\t\t\t\tconst message =\n\t\t\t\t\t\t\t\t\t\t\terror instanceof Error\n\t\t\t\t\t\t\t\t\t\t\t\t? error.message\n\t\t\t\t\t\t\t\t\t\t\t\t: \"Failed to delete task\";\n\t\t\t\t\t\t\t\t\t\ttoast.error(message);\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{taskDetailBottomSlot &&\n\t\t\t\t\t\t\t\t(() => {\n\t\t\t\t\t\t\t\t\tconst task = board.columns\n\t\t\t\t\t\t\t\t\t\t?.find((c) => c.id === modalState.columnId)\n\t\t\t\t\t\t\t\t\t\t?.tasks?.find((t) => t.id === modalState.taskId);\n\t\t\t\t\t\t\t\t\treturn task ? (\n\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t{taskDetailBottomSlot(task)}\n\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t) : null;\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\n\t\t\t\n\t\t\n\t);\n}\n", "target": "src/components/btst/kanban/client/components/pages/board-page.internal.tsx" }, { @@ -193,7 +193,7 @@ { "path": "btst/kanban/client/overrides.ts", "type": "registry:lib", - "content": "import type { ComponentType } from \"react\";\nimport type { KanbanLocalization } from \"./localization\";\n\n/**\n * User information for assignee display/selection\n * Framework-agnostic - consumers map their auth system to this shape\n */\nexport interface KanbanUser {\n\tid: string;\n\tname: string;\n\tavatarUrl?: string;\n\temail?: string;\n}\n\n/**\n * Context passed to lifecycle hooks\n */\nexport interface RouteContext {\n\t/** Current route path */\n\tpath: string;\n\t/** Route parameters (e.g., { boardId: \"abc123\" }) */\n\tparams?: Record;\n\t/** Whether rendering on server (true) or client (false) */\n\tisSSR: boolean;\n\t/** Additional context properties */\n\t[key: string]: unknown;\n}\n\n/**\n * Overridable components and functions for the Kanban plugin\n *\n * External consumers can provide their own implementations of these\n * to customize the behavior for their framework (Next.js, React Router, etc.)\n */\nexport interface KanbanPluginOverrides {\n\t/**\n\t * Link component for navigation\n\t */\n\tLink?: ComponentType & Record>;\n\t/**\n\t * Navigation function for programmatic navigation\n\t */\n\tnavigate: (path: string) => void | Promise;\n\t/**\n\t * Refresh function to invalidate server-side cache (e.g., Next.js router.refresh())\n\t */\n\trefresh?: () => void | Promise;\n\t/**\n\t * Image component for displaying images\n\t */\n\tImage?: ComponentType<\n\t\tReact.ImgHTMLAttributes & Record\n\t>;\n\t/**\n\t * Localization object for the kanban plugin\n\t */\n\tlocalization?: KanbanLocalization;\n\t/**\n\t * API base URL\n\t */\n\tapiBaseURL: string;\n\t/**\n\t * API base path\n\t */\n\tapiBasePath: string;\n\t/**\n\t * Whether to show the attribution\n\t */\n\tshowAttribution?: boolean;\n\t/**\n\t * Optional headers to pass with API requests (e.g., for SSR auth)\n\t */\n\theaders?: HeadersInit;\n\n\t// ============ User Resolution (required for assignee features) ============\n\n\t/**\n\t * Resolve user info from an assigneeId\n\t * Called when rendering task cards/forms that have an assignee\n\t * Return null for unknown users (will show fallback UI)\n\t */\n\tresolveUser: (\n\t\tuserId: string,\n\t) => Promise | KanbanUser | null;\n\n\t/**\n\t * Search/list users available for assignment\n\t * Called when user opens the assignee picker\n\t * @param query - Search query (empty string for initial load)\n\t * @param boardId - Optional board context for scoped user lists\n\t */\n\tsearchUsers: (\n\t\tquery: string,\n\t\tboardId?: string,\n\t) => Promise | KanbanUser[];\n\n\t// ============ Lifecycle Hooks (optional) ============\n\n\t/**\n\t * Called when a route is rendered\n\t * @param routeName - Name of the route (e.g., 'boards', 'board', 'newBoard')\n\t * @param context - Route context with path, params, etc.\n\t */\n\tonRouteRender?: (\n\t\trouteName: string,\n\t\tcontext: RouteContext,\n\t) => void | Promise;\n\n\t/**\n\t * Called when a route encounters an error\n\t * @param routeName - Name of the route\n\t * @param error - The error that occurred\n\t * @param context - Route context\n\t */\n\tonRouteError?: (\n\t\trouteName: string,\n\t\terror: Error,\n\t\tcontext: RouteContext,\n\t) => void | Promise;\n\n\t/**\n\t * Called before the boards list page is rendered\n\t * Return false to prevent rendering (e.g., for authorization)\n\t * @param context - Route context\n\t */\n\tonBeforeBoardsPageRendered?: (context: RouteContext) => boolean;\n\n\t/**\n\t * Called before a single board page is rendered\n\t * Return false to prevent rendering (e.g., for authorization)\n\t * @param boardId - The board ID\n\t * @param context - Route context\n\t */\n\tonBeforeBoardPageRendered?: (\n\t\tboardId: string,\n\t\tcontext: RouteContext,\n\t) => boolean;\n\n\t/**\n\t * Called before the new board page is rendered\n\t * Return false to prevent rendering (e.g., for authorization)\n\t * @param context - Route context\n\t */\n\tonBeforeNewBoardPageRendered?: (context: RouteContext) => boolean;\n}\n", + "content": "import type { ComponentType, ReactNode } from \"react\";\nimport type { KanbanLocalization } from \"./localization\";\nimport type { SerializedTask } from \"../types\";\n\n/**\n * User information for assignee display/selection\n * Framework-agnostic - consumers map their auth system to this shape\n */\nexport interface KanbanUser {\n\tid: string;\n\tname: string;\n\tavatarUrl?: string;\n\temail?: string;\n}\n\n/**\n * Context passed to lifecycle hooks\n */\nexport interface RouteContext {\n\t/** Current route path */\n\tpath: string;\n\t/** Route parameters (e.g., { boardId: \"abc123\" }) */\n\tparams?: Record;\n\t/** Whether rendering on server (true) or client (false) */\n\tisSSR: boolean;\n\t/** Additional context properties */\n\t[key: string]: unknown;\n}\n\n/**\n * Overridable components and functions for the Kanban plugin\n *\n * External consumers can provide their own implementations of these\n * to customize the behavior for their framework (Next.js, React Router, etc.)\n */\nexport interface KanbanPluginOverrides {\n\t/**\n\t * Link component for navigation\n\t */\n\tLink?: ComponentType & Record>;\n\t/**\n\t * Navigation function for programmatic navigation\n\t */\n\tnavigate: (path: string) => void | Promise;\n\t/**\n\t * Refresh function to invalidate server-side cache (e.g., Next.js router.refresh())\n\t */\n\trefresh?: () => void | Promise;\n\t/**\n\t * Image component for displaying images\n\t */\n\tImage?: ComponentType<\n\t\tReact.ImgHTMLAttributes & Record\n\t>;\n\t/**\n\t * Localization object for the kanban plugin\n\t */\n\tlocalization?: KanbanLocalization;\n\t/**\n\t * API base URL\n\t */\n\tapiBaseURL: string;\n\t/**\n\t * API base path\n\t */\n\tapiBasePath: string;\n\t/**\n\t * Whether to show the attribution\n\t */\n\tshowAttribution?: boolean;\n\t/**\n\t * Optional headers to pass with API requests (e.g., for SSR auth)\n\t */\n\theaders?: HeadersInit;\n\n\t// ============ User Resolution (required for assignee features) ============\n\n\t/**\n\t * Resolve user info from an assigneeId\n\t * Called when rendering task cards/forms that have an assignee\n\t * Return null for unknown users (will show fallback UI)\n\t */\n\tresolveUser: (\n\t\tuserId: string,\n\t) => Promise | KanbanUser | null;\n\n\t/**\n\t * Search/list users available for assignment\n\t * Called when user opens the assignee picker\n\t * @param query - Search query (empty string for initial load)\n\t * @param boardId - Optional board context for scoped user lists\n\t */\n\tsearchUsers: (\n\t\tquery: string,\n\t\tboardId?: string,\n\t) => Promise | KanbanUser[];\n\n\t// ============ Lifecycle Hooks (optional) ============\n\n\t/**\n\t * Called when a route is rendered\n\t * @param routeName - Name of the route (e.g., 'boards', 'board', 'newBoard')\n\t * @param context - Route context with path, params, etc.\n\t */\n\tonRouteRender?: (\n\t\trouteName: string,\n\t\tcontext: RouteContext,\n\t) => void | Promise;\n\n\t/**\n\t * Called when a route encounters an error\n\t * @param routeName - Name of the route\n\t * @param error - The error that occurred\n\t * @param context - Route context\n\t */\n\tonRouteError?: (\n\t\trouteName: string,\n\t\terror: Error,\n\t\tcontext: RouteContext,\n\t) => void | Promise;\n\n\t/**\n\t * Called before the boards list page is rendered\n\t * Return false to prevent rendering (e.g., for authorization)\n\t * @param context - Route context\n\t */\n\tonBeforeBoardsPageRendered?: (context: RouteContext) => boolean;\n\n\t/**\n\t * Called before a single board page is rendered\n\t * Return false to prevent rendering (e.g., for authorization)\n\t * @param boardId - The board ID\n\t * @param context - Route context\n\t */\n\tonBeforeBoardPageRendered?: (\n\t\tboardId: string,\n\t\tcontext: RouteContext,\n\t) => boolean;\n\n\t/**\n\t * Called before the new board page is rendered\n\t * Return false to prevent rendering (e.g., for authorization)\n\t * @param context - Route context\n\t */\n\tonBeforeNewBoardPageRendered?: (context: RouteContext) => boolean;\n\n\t// ============ Slot Overrides ============\n\n\t/**\n\t * Optional slot rendered at the bottom of the task detail dialog.\n\t * Use this to inject a comment thread or any custom content without\n\t * coupling the kanban plugin to the comments plugin.\n\t *\n\t * @example\n\t * ```tsx\n\t * kanban: {\n\t * taskDetailBottomSlot: (task) => (\n\t * \n\t * ),\n\t * }\n\t * ```\n\t */\n\ttaskDetailBottomSlot?: (task: SerializedTask) => ReactNode;\n}\n", "target": "src/components/btst/kanban/client/overrides.ts" }, { diff --git a/packages/stack/registry/registry.json b/packages/stack/registry/registry.json index 7355e1eb..fcc8f8d1 100644 --- a/packages/stack/registry/registry.json +++ b/packages/stack/registry/registry.json @@ -15,7 +15,6 @@ "@milkdown/kit", "date-fns", "highlight.js", - "react-intersection-observer", "react-markdown", "rehype-highlight", "rehype-katex", @@ -169,6 +168,30 @@ ], "docs": "https://better-stack.ai/docs/plugins/kanban" }, + { + "name": "btst-comments", + "type": "registry:block", + "title": "Comments Plugin Pages", + "description": "Ejectable page components for the @btst/stack comments plugin. Customize the UI layer while keeping data-fetching in @btst/stack.", + "author": "BTST ", + "dependencies": [ + "@btst/stack", + "date-fns" + ], + "registryDependencies": [ + "alert-dialog", + "avatar", + "badge", + "button", + "checkbox", + "dialog", + "separator", + "table", + "tabs", + "textarea" + ], + "docs": "https://better-stack.ai/docs/plugins/comments" + }, { "name": "btst-ui-builder", "type": "registry:block", diff --git a/packages/stack/scripts/build-registry.ts b/packages/stack/scripts/build-registry.ts index 7e5b79e1..b34f3a5b 100644 --- a/packages/stack/scripts/build-registry.ts +++ b/packages/stack/scripts/build-registry.ts @@ -173,7 +173,6 @@ const PLUGINS: PluginConfig[] = [ "@milkdown/kit", "date-fns", "highlight.js", - "react-intersection-observer", "react-markdown", "rehype-highlight", "rehype-katex", @@ -273,6 +272,16 @@ const PLUGINS: PluginConfig[] = [ // kanban/utils.ts has no external npm imports (pure utility functions) pluginRootFiles: ["types.ts", "schemas.ts", "utils.ts"], }, + { + name: "comments", + title: "Comments Plugin Pages", + description: + "Ejectable page components for the @btst/stack comments plugin. " + + "Customize the UI layer while keeping data-fetching in @btst/stack.", + extraNpmDeps: ["date-fns"], + extraRegistryDeps: [], + pluginRootFiles: ["types.ts", "schemas.ts"], + }, { name: "ui-builder", title: "UI Builder Plugin Pages", diff --git a/packages/stack/scripts/test-registry.sh b/packages/stack/scripts/test-registry.sh index 10ba8a95..ddbcd135 100755 --- a/packages/stack/scripts/test-registry.sh +++ b/packages/stack/scripts/test-registry.sh @@ -219,7 +219,7 @@ console.log('tsconfig.json patched'); # embedded from packages/ui (see build-registry.ts — "form" excluded from # STANDARD_SHADCN_COMPONENTS). All other standard components (select, accordion, # dialog, dropdown-menu, …) are correctly Radix-based with this flag. - npx --yes shadcn@latest init --defaults --force --base radix + npx --yes shadcn@4.0.5 init --defaults --force --base radix success "shadcn init completed (radix-nova)" INSTALL_FAILURES=() @@ -230,7 +230,7 @@ console.log('tsconfig.json patched'); # We treat those as warnings so the rest of the test can proceed. for PLUGIN in "${PLUGIN_NAMES[@]}"; do echo "Installing btst-${PLUGIN}…" - if npx --yes shadcn@latest add \ + if npx --yes shadcn@4.0.5 add \ "http://localhost:$SERVER_PORT/btst-${PLUGIN}.json" \ --yes --overwrite 2>&1; then success "btst-${PLUGIN} installed" diff --git a/packages/stack/src/plugins/blog/client/components/loading/post-navigation-skeleton.tsx b/packages/stack/src/plugins/blog/client/components/loading/post-navigation-skeleton.tsx new file mode 100644 index 00000000..e63763df --- /dev/null +++ b/packages/stack/src/plugins/blog/client/components/loading/post-navigation-skeleton.tsx @@ -0,0 +1,10 @@ +import { Skeleton } from "@workspace/ui/components/skeleton"; + +export function PostNavigationSkeleton() { + return ( + + + + + ); +} diff --git a/packages/stack/src/plugins/blog/client/components/loading/recent-posts-carousel-skeleton.tsx b/packages/stack/src/plugins/blog/client/components/loading/recent-posts-carousel-skeleton.tsx new file mode 100644 index 00000000..e8354568 --- /dev/null +++ b/packages/stack/src/plugins/blog/client/components/loading/recent-posts-carousel-skeleton.tsx @@ -0,0 +1,18 @@ +import { Skeleton } from "@workspace/ui/components/skeleton"; +import { PostCardSkeleton } from "./post-card-skeleton"; + +export function RecentPostsCarouselSkeleton() { + return ( + + + + + + + {[1, 2, 3].map((i) => ( + + ))} + + + ); +} diff --git a/packages/stack/src/plugins/blog/client/components/pages/post-page.internal.tsx b/packages/stack/src/plugins/blog/client/components/pages/post-page.internal.tsx index 387c1772..4cfdd9a6 100644 --- a/packages/stack/src/plugins/blog/client/components/pages/post-page.internal.tsx +++ b/packages/stack/src/plugins/blog/client/components/pages/post-page.internal.tsx @@ -21,6 +21,9 @@ import { useRouteLifecycle } from "@workspace/ui/hooks/use-route-lifecycle"; import { OnThisPage, OnThisPageSelect } from "../shared/on-this-page"; import type { SerializedPost } from "../../../types"; import { useRegisterPageAIContext } from "@btst/stack/plugins/ai-chat/client/context"; +import { WhenVisible } from "@workspace/ui/components/when-visible"; +import { PostNavigationSkeleton } from "../loading/post-navigation-skeleton"; +import { RecentPostsCarouselSkeleton } from "../loading/recent-posts-carousel-skeleton"; // Internal component with actual page content export function PostPage({ slug }: { slug: string }) { @@ -52,14 +55,14 @@ export function PostPage({ slug }: { slug: string }) { const { post } = useSuspensePost(slug ?? ""); - const { previousPost, nextPost, ref } = useNextPreviousPosts( + const { previousPost, nextPost } = useNextPreviousPosts( post?.createdAt ?? new Date(), { enabled: !!post, }, ); - const { recentPosts, ref: recentPostsRef } = useRecentPosts({ + const { recentPosts } = useRecentPosts({ limit: 5, excludeSlug: slug, enabled: !!post, @@ -120,13 +123,25 @@ export function PostPage({ slug }: { slug: string }) { - + } + > + + - + } + > + + + + {overrides.postBottomSlot && ( + + {overrides.postBottomSlot(post)} + + )} diff --git a/packages/stack/src/plugins/blog/client/components/shared/post-navigation.tsx b/packages/stack/src/plugins/blog/client/components/shared/post-navigation.tsx index 62cec3af..302e6512 100644 --- a/packages/stack/src/plugins/blog/client/components/shared/post-navigation.tsx +++ b/packages/stack/src/plugins/blog/client/components/shared/post-navigation.tsx @@ -10,13 +10,11 @@ import type { SerializedPost } from "../../../types"; interface PostNavigationProps { previousPost: SerializedPost | null; nextPost: SerializedPost | null; - ref?: (node: Element | null) => void; } export function PostNavigation({ previousPost, nextPost, - ref, }: PostNavigationProps) { const { Link } = usePluginOverrides< BlogPluginOverrides, @@ -29,9 +27,6 @@ export function PostNavigation({ return ( <> - {/* Ref div to trigger intersection observer when scrolled into view */} - {ref && } - {/* Only show navigation buttons if posts are available */} {(previousPost || nextPost) && ( <> diff --git a/packages/stack/src/plugins/blog/client/components/shared/recent-posts-carousel.tsx b/packages/stack/src/plugins/blog/client/components/shared/recent-posts-carousel.tsx index 401819a9..c403bcb5 100644 --- a/packages/stack/src/plugins/blog/client/components/shared/recent-posts-carousel.tsx +++ b/packages/stack/src/plugins/blog/client/components/shared/recent-posts-carousel.tsx @@ -16,10 +16,9 @@ import { BLOG_LOCALIZATION } from "../../localization"; interface RecentPostsCarouselProps { posts: SerializedPost[]; - ref?: (node: Element | null) => void; } -export function RecentPostsCarousel({ posts, ref }: RecentPostsCarouselProps) { +export function RecentPostsCarousel({ posts }: RecentPostsCarouselProps) { const { PostCard, Link, localization } = usePluginOverrides< BlogPluginOverrides, Partial @@ -32,9 +31,6 @@ export function RecentPostsCarousel({ posts, ref }: RecentPostsCarouselProps) { const basePath = useBasePath(); return ( - {/* Ref div to trigger intersection observer when scrolled into view */} - {ref && } - {posts && posts.length > 0 && ( <> diff --git a/packages/stack/src/plugins/blog/client/hooks/blog-hooks.tsx b/packages/stack/src/plugins/blog/client/hooks/blog-hooks.tsx index 622f33df..eb8881a7 100644 --- a/packages/stack/src/plugins/blog/client/hooks/blog-hooks.tsx +++ b/packages/stack/src/plugins/blog/client/hooks/blog-hooks.tsx @@ -15,7 +15,6 @@ import type { BlogApiRouter } from "../../api/plugin"; import { useDebounce } from "./use-debounce"; import { useEffect, useRef } from "react"; import { z } from "zod"; -import { useInView } from "react-intersection-observer"; import { createPostSchema, updatePostSchema } from "../../schemas"; import { createBlogQueryKeys } from "../../query-keys"; import { usePluginOverrides } from "@btst/stack/context"; @@ -604,16 +603,13 @@ export interface UseNextPreviousPostsResult { } /** - * Hook for fetching previous and next posts relative to a given date - * Uses useInView to only fetch when the component is in view + * Hook for fetching previous and next posts relative to a given date. + * Pair with `` in the render tree for lazy loading. */ export function useNextPreviousPosts( createdAt: string | Date, options: UseNextPreviousPostsOptions = {}, -): UseNextPreviousPostsResult & { - ref: (node: Element | null) => void; - inView: boolean; -} { +): UseNextPreviousPostsResult { const { apiBaseURL, apiBasePath, headers } = usePluginOverrides("blog"); const client = createApiClient({ @@ -622,13 +618,6 @@ export function useNextPreviousPosts( }); const queries = createBlogQueryKeys(client, headers); - const { ref, inView } = useInView({ - // start a little early so the data is ready as it scrolls in - rootMargin: "200px 0px", - // run once; keep data cached after - triggerOnce: true, - }); - const dateValue = typeof createdAt === "string" ? new Date(createdAt) : createdAt; const baseQuery = queries.posts.nextPrevious(dateValue); @@ -641,7 +630,7 @@ export function useNextPreviousPosts( >({ ...baseQuery, ...SHARED_QUERY_CONFIG, - enabled: (options.enabled ?? true) && inView && !!client, + enabled: (options.enabled ?? true) && !!client, }); return { @@ -650,8 +639,6 @@ export function useNextPreviousPosts( isLoading, error, refetch, - ref, - inView, }; } @@ -682,15 +669,12 @@ export interface UseRecentPostsResult { } /** - * Hook for fetching recent posts - * Uses useInView to only fetch when the component is in view + * Hook for fetching recent posts. + * Pair with `` in the render tree for lazy loading. */ export function useRecentPosts( options: UseRecentPostsOptions = {}, -): UseRecentPostsResult & { - ref: (node: Element | null) => void; - inView: boolean; -} { +): UseRecentPostsResult { const { apiBaseURL, apiBasePath, headers } = usePluginOverrides("blog"); const client = createApiClient({ @@ -699,13 +683,6 @@ export function useRecentPosts( }); const queries = createBlogQueryKeys(client, headers); - const { ref, inView } = useInView({ - // start a little early so the data is ready as it scrolls in - rootMargin: "200px 0px", - // run once; keep data cached after - triggerOnce: true, - }); - const baseQuery = queries.posts.recent({ limit: options.limit ?? 5, excludeSlug: options.excludeSlug, @@ -719,7 +696,7 @@ export function useRecentPosts( >({ ...baseQuery, ...SHARED_QUERY_CONFIG, - enabled: (options.enabled ?? true) && inView && !!client, + enabled: (options.enabled ?? true) && !!client, }); return { @@ -727,7 +704,5 @@ export function useRecentPosts( isLoading, error, refetch, - ref, - inView, }; } diff --git a/packages/stack/src/plugins/blog/client/overrides.ts b/packages/stack/src/plugins/blog/client/overrides.ts index 921f2651..c1d543ed 100644 --- a/packages/stack/src/plugins/blog/client/overrides.ts +++ b/packages/stack/src/plugins/blog/client/overrides.ts @@ -1,5 +1,5 @@ import type { SerializedPost } from "../types"; -import type { ComponentType } from "react"; +import type { ComponentType, ReactNode } from "react"; import type { BlogLocalization } from "./localization"; /** @@ -134,4 +134,29 @@ export interface BlogPluginOverrides { * @param context - Route context */ onBeforeDraftsPageRendered?: (context: RouteContext) => boolean; + + // ============ Slot Overrides ============ + + /** + * Optional slot rendered below the blog post body. + * Use this to inject a comment thread or any custom content without + * coupling the blog plugin to the comments plugin. + * + * @example + * ```tsx + * blog: { + * postBottomSlot: (post) => ( + * + * ), + * } + * ``` + */ + postBottomSlot?: (post: SerializedPost) => ReactNode; } diff --git a/packages/stack/src/plugins/cms/client/components/shared/pagination.tsx b/packages/stack/src/plugins/cms/client/components/shared/pagination.tsx index 624730d3..ff40b77d 100644 --- a/packages/stack/src/plugins/cms/client/components/shared/pagination.tsx +++ b/packages/stack/src/plugins/cms/client/components/shared/pagination.tsx @@ -1,10 +1,9 @@ "use client"; -import { Button } from "@workspace/ui/components/button"; -import { ChevronLeft, ChevronRight } from "lucide-react"; import { usePluginOverrides } from "@btst/stack/context"; import type { CMSPluginOverrides } from "../../overrides"; import { CMS_LOCALIZATION } from "../../localization"; +import { PaginationControls } from "@workspace/ui/components/pagination-controls"; interface PaginationProps { currentPage: number; @@ -27,46 +26,19 @@ export function Pagination({ usePluginOverrides("cms"); const localization = { ...CMS_LOCALIZATION, ...customLocalization }; - const from = offset + 1; - const to = Math.min(offset + limit, total); - - if (totalPages <= 1) { - return null; - } - return ( - - - {localization.CMS_LIST_PAGINATION_SHOWING.replace( - "{from}", - String(from), - ) - .replace("{to}", String(to)) - .replace("{total}", String(total))} - - - onPageChange(currentPage - 1)} - disabled={currentPage === 1} - > - - {localization.CMS_LIST_PAGINATION_PREVIOUS} - - - {currentPage} / {totalPages} - - onPageChange(currentPage + 1)} - disabled={currentPage === totalPages} - > - {localization.CMS_LIST_PAGINATION_NEXT} - - - - + ); } diff --git a/packages/stack/src/plugins/comments/api/getters.ts b/packages/stack/src/plugins/comments/api/getters.ts new file mode 100644 index 00000000..80a2dad0 --- /dev/null +++ b/packages/stack/src/plugins/comments/api/getters.ts @@ -0,0 +1,376 @@ +import type { DBAdapter as Adapter } from "@btst/db"; +import type { + Comment, + CommentLike, + CommentListResult, + SerializedComment, +} from "../types"; +import type { z } from "zod"; +import type { + CommentListQuerySchema, + CommentCountQuerySchema, +} from "../schemas"; + +/** + * Resolve display info for a batch of authorIds using the consumer-supplied resolveUser hook. + * Deduplicates lookups — each unique authorId is resolved only once per call. + * + * @remarks **Security:** No authorization hooks are called. The caller is responsible for + * any access-control checks before invoking this function. + */ +async function resolveAuthors( + authorIds: string[], + resolveUser?: ( + authorId: string, + ) => Promise<{ name: string; avatarUrl?: string } | null>, +): Promise> { + const unique = [...new Set(authorIds)]; + const map = new Map(); + + if (!resolveUser || unique.length === 0) { + for (const id of unique) { + map.set(id, { name: "[deleted]", avatarUrl: null }); + } + return map; + } + + await Promise.all( + unique.map(async (id) => { + try { + const result = await resolveUser(id); + map.set(id, { + name: result?.name ?? "[deleted]", + avatarUrl: result?.avatarUrl ?? null, + }); + } catch { + map.set(id, { name: "[deleted]", avatarUrl: null }); + } + }), + ); + + return map; +} + +/** + * Serialize a raw Comment from the DB into a SerializedComment for the API response. + * Enriches with resolved author info and like status. + */ +function enrichComment( + comment: Comment, + authorMap: Map, + likedCommentIds: Set, + replyCount = 0, +): SerializedComment { + const author = authorMap.get(comment.authorId) ?? { + name: "[deleted]", + avatarUrl: null, + }; + return { + id: comment.id, + resourceId: comment.resourceId, + resourceType: comment.resourceType, + parentId: comment.parentId ?? null, + authorId: comment.authorId, + resolvedAuthorName: author.name, + resolvedAvatarUrl: author.avatarUrl, + body: comment.body, + status: comment.status, + likes: comment.likes, + isLikedByCurrentUser: likedCommentIds.has(comment.id), + editedAt: comment.editedAt?.toISOString() ?? null, + createdAt: comment.createdAt.toISOString(), + updatedAt: comment.updatedAt.toISOString(), + replyCount, + }; +} + +type WhereCondition = { + field: string; + value: string | number | boolean | Date | string[] | number[] | null; + operator: "eq"; +}; + +/** + * Build the base WHERE conditions from common list params (excluding status). + */ +function buildBaseConditions( + params: z.infer, +): WhereCondition[] { + const conditions: WhereCondition[] = []; + + if (params.resourceId) { + conditions.push({ + field: "resourceId", + value: params.resourceId, + operator: "eq", + }); + } + if (params.resourceType) { + conditions.push({ + field: "resourceType", + value: params.resourceType, + operator: "eq", + }); + } + if (params.parentId !== undefined) { + const parentValue = + params.parentId === null || params.parentId === "null" + ? null + : params.parentId; + conditions.push({ field: "parentId", value: parentValue, operator: "eq" }); + } + if (params.authorId) { + conditions.push({ + field: "authorId", + value: params.authorId, + operator: "eq", + }); + } + + return conditions; +} + +/** + * List comments for a resource, optionally filtered by status and parentId. + * Server-side resolves author display info and like status. + * + * When `status` is "approved" (default) and `currentUserId` is provided, the + * result also includes the current user's own pending comments so they remain + * visible after a page refresh without requiring admin access. + * + * Pure DB function — no hooks, no HTTP context. Safe for server-side use. + * + * @param adapter - The database adapter + * @param params - Filter/pagination parameters + * @param resolveUser - Optional consumer hook to resolve author display info + */ +export async function listComments( + adapter: Adapter, + params: z.infer, + resolveUser?: ( + authorId: string, + ) => Promise<{ name: string; avatarUrl?: string } | null>, +): Promise { + const limit = params.limit ?? 20; + const offset = params.offset ?? 0; + const sortDirection = params.sort ?? "asc"; + + // When authorId is provided and no explicit status filter is requested, + // return all statuses (the "my comments" mode — the caller owns the data). + // Otherwise default to "approved" to prevent leaking pending/spam to + // unauthenticated callers. + const omitStatusFilter = !!params.authorId && !params.status; + const statusFilter = omitStatusFilter ? null : (params.status ?? "approved"); + const baseConditions = buildBaseConditions(params); + + let comments: Comment[]; + let total: number; + + if ( + !omitStatusFilter && + statusFilter === "approved" && + params.currentUserId + ) { + // Fetch approved comments AND the current user's own pending comments so + // they remain visible after a page refresh (React Query cache is lost). + // Two separate queries are needed because the DB adapter only supports + // AND-chained equality conditions (no OR operator). + const [approvedRaw, ownPendingRaw] = await Promise.all([ + adapter.findMany({ + model: "comment", + where: [ + ...baseConditions, + { field: "status", value: "approved", operator: "eq" }, + ], + sortBy: { field: "createdAt", direction: sortDirection }, + }), + adapter.findMany({ + model: "comment", + where: [ + ...baseConditions, + { field: "status", value: "pending", operator: "eq" }, + { field: "authorId", value: params.currentUserId, operator: "eq" }, + ], + sortBy: { field: "createdAt", direction: sortDirection }, + }), + ]); + + // Merge — approved takes precedence if an ID somehow appears in both. + const approvedIds = new Set(approvedRaw.map((c) => c.id)); + const merged = [ + ...approvedRaw, + ...ownPendingRaw.filter((c) => !approvedIds.has(c.id)), + ]; + merged.sort((a, b) => { + const diff = a.createdAt.getTime() - b.createdAt.getTime(); + return sortDirection === "desc" ? -diff : diff; + }); + + total = merged.length; + comments = merged.slice(offset, offset + limit); + } else { + const where: WhereCondition[] = [...baseConditions]; + if (statusFilter !== null) { + where.push({ + field: "status", + value: statusFilter, + operator: "eq", + }); + } + + const [found, count] = await Promise.all([ + adapter.findMany({ + model: "comment", + limit, + offset, + where, + sortBy: { field: "createdAt", direction: sortDirection }, + }), + adapter.count({ model: "comment", where }), + ]); + comments = found; + total = count; + } + + // Resolve author display info server-side + const authorIds = comments.map((c) => c.authorId); + const authorMap = await resolveAuthors(authorIds, resolveUser); + + // Resolve like status for currentUserId (if provided) + const likedCommentIds = new Set(); + if (params.currentUserId && comments.length > 0) { + const commentIds = comments.map((c) => c.id); + // Fetch all likes by the currentUser for these comments + const likes = await Promise.all( + commentIds.map((commentId) => + adapter.findOne({ + model: "commentLike", + where: [ + { field: "commentId", value: commentId, operator: "eq" }, + { + field: "authorId", + value: params.currentUserId!, + operator: "eq", + }, + ], + }), + ), + ); + likes.forEach((like, i) => { + if (like) likedCommentIds.add(commentIds[i]!); + }); + } + + // Batch-count replies for top-level comments so the client can show the + // expand button without firing a separate request per comment. + // When currentUserId is provided, also count the user's own pending replies + // so the button appears immediately after a page refresh. + const replyCounts = new Map(); + const isTopLevelQuery = + params.parentId === null || params.parentId === "null"; + if (isTopLevelQuery && comments.length > 0) { + await Promise.all( + comments.map(async (c) => { + const approvedCount = await adapter.count({ + model: "comment", + where: [ + { field: "parentId", value: c.id, operator: "eq" }, + { field: "status", value: "approved", operator: "eq" }, + ], + }); + + let ownPendingCount = 0; + if (params.currentUserId) { + ownPendingCount = await adapter.count({ + model: "comment", + where: [ + { field: "parentId", value: c.id, operator: "eq" }, + { field: "status", value: "pending", operator: "eq" }, + { + field: "authorId", + value: params.currentUserId, + operator: "eq", + }, + ], + }); + } + + replyCounts.set(c.id, approvedCount + ownPendingCount); + }), + ); + } + + const items = comments.map((c) => + enrichComment(c, authorMap, likedCommentIds, replyCounts.get(c.id) ?? 0), + ); + + return { items, total, limit, offset }; +} + +/** + * Get a single comment by ID, enriched with author info. + * Returns null if not found. + * + * Pure DB function — no hooks, no HTTP context. + */ +export async function getCommentById( + adapter: Adapter, + id: string, + resolveUser?: ( + authorId: string, + ) => Promise<{ name: string; avatarUrl?: string } | null>, + currentUserId?: string, +): Promise { + const comment = await adapter.findOne({ + model: "comment", + where: [{ field: "id", value: id, operator: "eq" }], + }); + + if (!comment) return null; + + const authorMap = await resolveAuthors([comment.authorId], resolveUser); + + const likedCommentIds = new Set(); + if (currentUserId) { + const like = await adapter.findOne({ + model: "commentLike", + where: [ + { field: "commentId", value: id, operator: "eq" }, + { field: "authorId", value: currentUserId, operator: "eq" }, + ], + }); + if (like) likedCommentIds.add(id); + } + + return enrichComment(comment, authorMap, likedCommentIds); +} + +/** + * Count comments for a resource, optionally filtered by status. + * + * Pure DB function — no hooks, no HTTP context. + */ +export async function getCommentCount( + adapter: Adapter, + params: z.infer, +): Promise { + const whereConditions: Array<{ + field: string; + value: string | number | boolean | Date | string[] | number[] | null; + operator: "eq"; + }> = [ + { field: "resourceId", value: params.resourceId, operator: "eq" }, + { field: "resourceType", value: params.resourceType, operator: "eq" }, + ]; + + // Default to "approved" when no status is provided so that omitting the + // parameter never leaks pending/spam counts to unauthenticated callers. + const statusFilter = params.status ?? "approved"; + whereConditions.push({ + field: "status", + value: statusFilter, + operator: "eq", + }); + + return adapter.count({ model: "comment", where: whereConditions }); +} diff --git a/packages/stack/src/plugins/comments/api/index.ts b/packages/stack/src/plugins/comments/api/index.ts new file mode 100644 index 00000000..fcc9db64 --- /dev/null +++ b/packages/stack/src/plugins/comments/api/index.ts @@ -0,0 +1,21 @@ +export { + commentsBackendPlugin, + type CommentsApiRouter, + type CommentsApiContext, + type CommentsBackendOptions, +} from "./plugin"; +export { + listComments, + getCommentById, + getCommentCount, +} from "./getters"; +export { + createComment, + updateComment, + updateCommentStatus, + deleteComment, + toggleCommentLike, + type CreateCommentInput, +} from "./mutations"; +export { serializeComment } from "./serializers"; +export { COMMENTS_QUERY_KEYS } from "./query-key-defs"; diff --git a/packages/stack/src/plugins/comments/api/mutations.ts b/packages/stack/src/plugins/comments/api/mutations.ts new file mode 100644 index 00000000..a99935e6 --- /dev/null +++ b/packages/stack/src/plugins/comments/api/mutations.ts @@ -0,0 +1,188 @@ +import type { DBAdapter as Adapter } from "@btst/db"; +import type { Comment, CommentLike } from "../types"; + +/** + * Input for creating a new comment. + */ +export interface CreateCommentInput { + resourceId: string; + resourceType: string; + parentId?: string | null; + authorId: string; + body: string; + status?: "pending" | "approved" | "spam"; +} + +/** + * Create a new comment. + * + * @remarks **Security:** No authorization hooks are called. The caller is + * responsible for any access-control checks (e.g., onBeforePost) before + * invoking this function. + */ +export async function createComment( + adapter: Adapter, + input: CreateCommentInput, +): Promise { + return adapter.create({ + model: "comment", + data: { + resourceId: input.resourceId, + resourceType: input.resourceType, + parentId: input.parentId ?? null, + authorId: input.authorId, + body: input.body, + status: input.status ?? "pending", + likes: 0, + createdAt: new Date(), + updatedAt: new Date(), + }, + }); +} + +/** + * Update the body of an existing comment and set editedAt. + * + * @remarks **Security:** No authorization hooks are called. The caller is + * responsible for ensuring the requesting user owns the comment (onBeforeEdit). + */ +export async function updateComment( + adapter: Adapter, + id: string, + body: string, +): Promise { + const existing = await adapter.findOne({ + model: "comment", + where: [{ field: "id", value: id, operator: "eq" }], + }); + if (!existing) return null; + + return adapter.update({ + model: "comment", + where: [{ field: "id", value: id, operator: "eq" }], + update: { + body, + editedAt: new Date(), + updatedAt: new Date(), + }, + }); +} + +/** + * Update the status of a comment (approve, reject, spam). + * + * @remarks **Security:** No authorization hooks are called. Callers should + * ensure the requesting user has moderation privileges. + */ +export async function updateCommentStatus( + adapter: Adapter, + id: string, + status: "pending" | "approved" | "spam", +): Promise { + const existing = await adapter.findOne({ + model: "comment", + where: [{ field: "id", value: id, operator: "eq" }], + }); + if (!existing) return null; + + return adapter.update({ + model: "comment", + where: [{ field: "id", value: id, operator: "eq" }], + update: { status, updatedAt: new Date() }, + }); +} + +/** + * Delete a comment by ID. + * + * @remarks **Security:** No authorization hooks are called. Callers should + * ensure the requesting user has permission to delete this comment. + */ +export async function deleteComment( + adapter: Adapter, + id: string, +): Promise { + const existing = await adapter.findOne({ + model: "comment", + where: [{ field: "id", value: id, operator: "eq" }], + }); + if (!existing) return false; + + await adapter.delete({ + model: "comment", + where: [{ field: "id", value: id, operator: "eq" }], + }); + return true; +} + +/** + * Toggle a like on a comment for a given authorId. + * - If the user has not liked the comment: creates a commentLike row and increments the likes counter. + * - If the user has already liked the comment: deletes the commentLike row and decrements the likes counter. + * Returns the updated likes count. + * + * All reads and writes are performed inside a single transaction to prevent + * concurrent requests from causing counter drift or duplicate like rows. + * + * @remarks **Security:** No authorization hooks are called. The caller is + * responsible for ensuring the requesting user is authenticated (authorId is valid). + */ +export async function toggleCommentLike( + adapter: Adapter, + commentId: string, + authorId: string, +): Promise<{ likes: number; isLiked: boolean }> { + return adapter.transaction(async (tx) => { + const comment = await tx.findOne({ + model: "comment", + where: [{ field: "id", value: commentId, operator: "eq" }], + }); + if (!comment) { + throw new Error("Comment not found"); + } + + const existingLike = await tx.findOne({ + model: "commentLike", + where: [ + { field: "commentId", value: commentId, operator: "eq" }, + { field: "authorId", value: authorId, operator: "eq" }, + ], + }); + + let newLikes: number; + let isLiked: boolean; + + if (existingLike) { + // Unlike + await tx.delete({ + model: "commentLike", + where: [ + { field: "commentId", value: commentId, operator: "eq" }, + { field: "authorId", value: authorId, operator: "eq" }, + ], + }); + newLikes = Math.max(0, comment.likes - 1); + isLiked = false; + } else { + // Like + await tx.create({ + model: "commentLike", + data: { + commentId, + authorId, + createdAt: new Date(), + }, + }); + newLikes = comment.likes + 1; + isLiked = true; + } + + await tx.update({ + model: "comment", + where: [{ field: "id", value: commentId, operator: "eq" }], + update: { likes: newLikes, updatedAt: new Date() }, + }); + + return { likes: newLikes, isLiked }; + }); +} diff --git a/packages/stack/src/plugins/comments/api/plugin.ts b/packages/stack/src/plugins/comments/api/plugin.ts new file mode 100644 index 00000000..a8f2872f --- /dev/null +++ b/packages/stack/src/plugins/comments/api/plugin.ts @@ -0,0 +1,625 @@ +import type { DBAdapter as Adapter } from "@btst/db"; +import { defineBackendPlugin, createEndpoint } from "@btst/stack/plugins/api"; +import { z } from "zod"; +import { commentsSchema as dbSchema } from "../db"; +import type { Comment } from "../types"; +import { + CommentListQuerySchema, + CommentCountQuerySchema, + createCommentSchema, + updateCommentSchema, + updateCommentStatusSchema, +} from "../schemas"; +import { listComments, getCommentById, getCommentCount } from "./getters"; +import { + createComment, + updateComment, + updateCommentStatus, + deleteComment, + toggleCommentLike, +} from "./mutations"; +import { runHookWithShim } from "../../utils"; + +/** + * Context passed to comments API hooks + */ +export interface CommentsApiContext { + body?: unknown; + params?: unknown; + query?: unknown; + request?: Request; + headers?: Headers; + [key: string]: unknown; +} + +/** + * Configuration options for the comments backend plugin + */ +export interface CommentsBackendOptions { + /** + * When true, new comments are automatically approved (status: "approved"). + * Default: false — all comments start as "pending" until a moderator approves. + */ + autoApprove?: boolean; + + /** + * Server-side user resolution hook. Called once per unique authorId when + * serving GET /comments. Return null for deleted/unknown users (shown as "[deleted]"). + * Deduplicates lookups — each unique authorId is resolved only once per request. + */ + resolveUser?: ( + authorId: string, + ) => Promise<{ name: string; avatarUrl?: string } | null>; + + /** + * Called before the comment list or count is returned. Throw to reject. + * When this hook is absent, any request with `status` other than "approved" + * is automatically rejected with 403 on both `GET /comments` and + * `GET /comments/count` — preventing anonymous callers from reading or + * probing the pending/spam moderation queues. Configure this hook to + * authorize admin callers (e.g. check session role). + */ + onBeforeList?: ( + query: z.infer, + context: CommentsApiContext, + ) => Promise | void; + + /** + * Called before a comment is created. Must return `{ authorId: string }` — + * the server-resolved identity of the commenter. + * + * ⚠️ SECURITY REQUIRED: Derive `authorId` from the authenticated session + * (e.g. JWT / session cookie). Never trust any ID supplied by the client. + * Throw to reject the request (e.g. when the user is not authenticated). + * + * `authorId` is intentionally absent from the POST body schema. This hook + * is the only place it can be set. `commentsBackendPlugin` throws at startup + * if this hook is not provided. + */ + onBeforePost: ( + input: z.infer, + context: CommentsApiContext, + ) => Promise<{ authorId: string }> | { authorId: string }; + + /** + * Called after a comment is successfully created. + */ + onAfterPost?: ( + comment: Comment, + context: CommentsApiContext, + ) => Promise | void; + + /** + * Called before a comment body is edited. Throw an error to reject the edit. + * Use this to enforce that only the comment owner can edit (compare authorId to session). + */ + onBeforeEdit?: ( + commentId: string, + update: { body: string }, + context: CommentsApiContext, + ) => Promise | void; + + /** + * Called after a comment is successfully edited. + */ + onAfterEdit?: ( + comment: Comment, + context: CommentsApiContext, + ) => Promise | void; + + /** + * Called before a like is toggled. Throw to reject. + * + * When this hook is **absent**, any like/unlike request is automatically + * rejected with 403 — preventing unauthenticated callers from toggling likes + * on behalf of arbitrary user IDs. Configure this hook to verify `authorId` + * matches the authenticated session. + */ + onBeforeLike?: ( + commentId: string, + authorId: string, + context: CommentsApiContext, + ) => Promise | void; + + /** + * Called before a comment's status is changed. Throw to reject. + * + * When this hook is **absent**, any status-change request is automatically + * rejected with 403 — preventing unauthenticated callers from moderating + * comments. Configure this hook to verify the caller has admin/moderator + * privileges. + */ + onBeforeStatusChange?: ( + commentId: string, + status: "pending" | "approved" | "spam", + context: CommentsApiContext, + ) => Promise | void; + + /** + * Called after a comment status is changed to "approved". + */ + onAfterApprove?: ( + comment: Comment, + context: CommentsApiContext, + ) => Promise | void; + + /** + * Called before a comment is deleted. Throw to reject. + * + * When this hook is **absent**, any delete request is automatically rejected + * with 403 — preventing unauthenticated callers from deleting comments. The + * CommentCard UI hides the Delete button client-side, but that is not a + * security boundary. Configure this hook to enforce admin-only access. + */ + onBeforeDelete?: ( + commentId: string, + context: CommentsApiContext, + ) => Promise | void; + + /** + * Called after a comment is deleted. + */ + onAfterDelete?: ( + commentId: string, + context: CommentsApiContext, + ) => Promise | void; + + /** + * Called before the comment list is returned for an author-scoped query + * (i.e. when `authorId` is present in `GET /comments`). Throw to reject. + * + * When this hook is **absent**, any request that includes `authorId` is + * automatically rejected with 403 — preventing anonymous callers from + * reading or probing any user's comment history. + * + * Use this hook to verify the `authorId` matches the authenticated session: + * ```ts + * onBeforeListByAuthor: async (authorId, _query, ctx) => { + * const session = await getSession(ctx.headers) + * if (!session?.user) throw new Error("Authentication required") + * if (authorId !== session.user.id && !session.user.isAdmin) + * throw new Error("Forbidden") + * } + * ``` + */ + onBeforeListByAuthor?: ( + authorId: string, + query: z.infer, + context: CommentsApiContext, + ) => Promise | void; + + /** + * Resolve the current authenticated user's ID from the request context + * (e.g. session cookie or JWT). The resolved ID is used to include the + * user's own pending comments alongside approved ones in `GET /comments` + * responses so they remain visible after posting. + * + * Return `null` or `undefined` to indicate the request is unauthenticated. + * + * `commentsBackendPlugin` throws at startup if this hook is not provided. + * + * ```ts + * resolveCurrentUserId: async (ctx) => { + * const session = await getSession(ctx.headers) + * return session?.user?.id ?? null + * } + * ``` + */ + resolveCurrentUserId: ( + context: CommentsApiContext, + ) => Promise | string | null | undefined; +} + +export const commentsBackendPlugin = (options: CommentsBackendOptions) => { + if (!options?.onBeforePost) { + throw new Error( + "[btst/comments] onBeforePost is required. " + + "It must return { authorId: string } derived from the authenticated session. " + + "authorId is no longer accepted in the POST body — the server resolves identity exclusively via this hook.", + ); + } + if (!options?.resolveCurrentUserId) { + throw new Error( + "[btst/comments] resolveCurrentUserId is required. " + + "It must return the current user's ID derived from the authenticated session, " + + "or null/undefined when unauthenticated. " + + "The client-supplied currentUserId query parameter is never trusted — " + + "the server resolves identity exclusively via this hook.", + ); + } + + return defineBackendPlugin({ + name: "comments", + dbPlugin: dbSchema, + + api: (adapter: Adapter) => ({ + listComments: (params: z.infer) => + listComments(adapter, params, options?.resolveUser), + getCommentById: (id: string, currentUserId?: string) => + getCommentById(adapter, id, options?.resolveUser, currentUserId), + getCommentCount: (params: z.infer) => + getCommentCount(adapter, params), + }), + + routes: (adapter: Adapter) => { + // GET /comments + const listCommentsEndpoint = createEndpoint( + "/comments", + { + method: "GET", + query: CommentListQuerySchema, + }, + async (ctx) => { + const context: CommentsApiContext = { + query: ctx.query, + headers: ctx.headers, + }; + try { + // Author-scoped queries: require onBeforeListByAuthor (403 when absent). + // This is the single security gate for per-user comment history queries + // and runs before any status-filter check. + if (ctx.query.authorId) { + if (!options?.onBeforeListByAuthor) { + throw ctx.error(403, { + message: + "Forbidden: authorId filter requires onBeforeListByAuthor hook", + }); + } + await runHookWithShim( + () => + options.onBeforeListByAuthor!( + ctx.query.authorId!, + ctx.query, + context, + ), + ctx.error, + "Forbidden: Cannot list comments for this author", + ); + } + + // Restrict non-approved status filters to authorized callers only. + // Without onBeforeList, anonymous callers cannot read pending/spam queues. + if (ctx.query.status && ctx.query.status !== "approved") { + if (!options?.onBeforeList) { + throw ctx.error(403, { + message: "Forbidden: status filter requires authorization", + }); + } + await runHookWithShim( + () => options.onBeforeList!(ctx.query, context), + ctx.error, + "Forbidden: Cannot list comments with this status filter", + ); + } else if (options?.onBeforeList && !ctx.query.authorId) { + // Only call onBeforeList for non-author-scoped queries to avoid + // double-hooking when both authorId and onBeforeList are present. + await runHookWithShim( + () => options.onBeforeList!(ctx.query, context), + ctx.error, + "Forbidden: Cannot list comments", + ); + } + + // Resolve currentUserId server-side — the client-supplied query + // parameter is intentionally discarded and replaced with the + // session-verified identity from resolveCurrentUserId. + let resolvedCurrentUserId: string | undefined; + try { + const result = await options.resolveCurrentUserId(context); + resolvedCurrentUserId = result ?? undefined; + } catch { + resolvedCurrentUserId = undefined; + } + + return await listComments( + adapter, + { ...ctx.query, currentUserId: resolvedCurrentUserId }, + options?.resolveUser, + ); + } catch (error) { + throw error; + } + }, + ); + + // POST /comments + const createCommentEndpoint = createEndpoint( + "/comments", + { + method: "POST", + body: createCommentSchema, + }, + async (ctx) => { + const context: CommentsApiContext = { + body: ctx.body, + headers: ctx.headers, + }; + try { + const { authorId } = await runHookWithShim( + () => options.onBeforePost(ctx.body, context), + ctx.error, + "Unauthorized: Cannot post comment", + ); + + const status = options?.autoApprove ? "approved" : "pending"; + const comment = await createComment(adapter, { + ...ctx.body, + authorId, + status, + }); + + if (options?.onAfterPost) { + await options.onAfterPost(comment, context); + } + + // Return a fully serialized comment so the client receives + // resolvedAuthorName / resolvedAvatarUrl / isLikedByCurrentUser — + // without this the optimistic-update replacement crashes because + // those fields are undefined on the raw DB record. + const serialized = await getCommentById( + adapter, + comment.id, + options?.resolveUser, + ); + return serialized ?? comment; + } catch (error) { + throw error; + } + }, + ); + + // PATCH /comments/:id (edit body) + const updateCommentEndpoint = createEndpoint( + "/comments/:id", + { + method: "PATCH", + body: updateCommentSchema, + }, + async (ctx) => { + const { id } = ctx.params; + const context: CommentsApiContext = { + params: ctx.params, + body: ctx.body, + headers: ctx.headers, + }; + try { + // Require onBeforeEdit (403 when absent). + // Without an explicit hook the caller cannot be authenticated, so + // editing any comment body is rejected by default — matching the + // same secure-by-default pattern used for onBeforeListByAuthor. + if (!options?.onBeforeEdit) { + throw ctx.error(403, { + message: + "Forbidden: editing comments requires the onBeforeEdit hook", + }); + } + await runHookWithShim( + () => options.onBeforeEdit!(id, { body: ctx.body.body }, context), + ctx.error, + "Unauthorized: Cannot edit comment", + ); + + const updated = await updateComment(adapter, id, ctx.body.body); + if (!updated) { + throw ctx.error(404, { message: "Comment not found" }); + } + + if (options?.onAfterEdit) { + await options.onAfterEdit(updated, context); + } + + return updated; + } catch (error) { + throw error; + } + }, + ); + + // GET /comments/count + const getCommentCountEndpoint = createEndpoint( + "/comments/count", + { + method: "GET", + query: CommentCountQuerySchema, + }, + async (ctx) => { + const context: CommentsApiContext = { + query: ctx.query, + headers: ctx.headers, + }; + try { + // Mirror the same authorization guard used by GET /comments. + // Without onBeforeList, non-approved status counts are blocked so + // unauthenticated callers cannot probe the moderation queue sizes. + if (ctx.query.status && ctx.query.status !== "approved") { + if (!options?.onBeforeList) { + throw ctx.error(403, { + message: "Forbidden: status filter requires authorization", + }); + } + await runHookWithShim( + () => + options.onBeforeList!( + { ...ctx.query, status: ctx.query.status }, + context, + ), + ctx.error, + "Forbidden: Cannot count comments with this status filter", + ); + } else if (options?.onBeforeList) { + await runHookWithShim( + () => + options.onBeforeList!( + { ...ctx.query, status: ctx.query.status }, + context, + ), + ctx.error, + "Forbidden: Cannot count comments", + ); + } + + const count = await getCommentCount(adapter, ctx.query); + return { count }; + } catch (error) { + throw error; + } + }, + ); + + // POST /comments/:id/like (toggle) + const toggleLikeEndpoint = createEndpoint( + "/comments/:id/like", + { + method: "POST", + body: z.object({ authorId: z.string().min(1) }), + }, + async (ctx) => { + const { id } = ctx.params; + const context: CommentsApiContext = { + params: ctx.params, + body: ctx.body, + headers: ctx.headers, + }; + try { + // Require onBeforeLike (403 when absent) — same secure-by-default + // pattern used for onBeforeEdit, onBeforeStatusChange, and + // onBeforeDelete. The authorId in the request body is client-supplied + // and must be verified against the authenticated session; without + // this hook any caller can toggle likes on behalf of any user ID. + if (!options?.onBeforeLike) { + throw ctx.error(403, { + message: + "Forbidden: toggling likes requires the onBeforeLike hook", + }); + } + await runHookWithShim( + () => options.onBeforeLike!(id, ctx.body.authorId, context), + ctx.error, + "Unauthorized: Cannot like comment", + ); + + const result = await toggleCommentLike( + adapter, + id, + ctx.body.authorId, + ); + return result; + } catch (error) { + throw error; + } + }, + ); + + // PATCH /comments/:id/status (admin) + const updateStatusEndpoint = createEndpoint( + "/comments/:id/status", + { + method: "PATCH", + body: updateCommentStatusSchema, + }, + async (ctx) => { + const { id } = ctx.params; + const context: CommentsApiContext = { + params: ctx.params, + body: ctx.body, + headers: ctx.headers, + }; + try { + // Require onBeforeStatusChange (403 when absent) — same + // secure-by-default pattern used for onBeforeEdit and + // onBeforeListByAuthor. Moderation is an admin operation; without + // this hook any unauthenticated caller could change any comment's + // status. + if (!options?.onBeforeStatusChange) { + throw ctx.error(403, { + message: + "Forbidden: changing comment status requires the onBeforeStatusChange hook", + }); + } + await runHookWithShim( + () => options.onBeforeStatusChange!(id, ctx.body.status, context), + ctx.error, + "Unauthorized: Cannot change comment status", + ); + + const updated = await updateCommentStatus( + adapter, + id, + ctx.body.status, + ); + if (!updated) { + throw ctx.error(404, { message: "Comment not found" }); + } + + if (ctx.body.status === "approved" && options?.onAfterApprove) { + await options.onAfterApprove(updated, context); + } + + return updated; + } catch (error) { + throw error; + } + }, + ); + + // DELETE /comments/:id (admin) + const deleteCommentEndpoint = createEndpoint( + "/comments/:id", + { + method: "DELETE", + }, + async (ctx) => { + const { id } = ctx.params; + const context: CommentsApiContext = { + params: ctx.params, + headers: ctx.headers, + }; + try { + // Require onBeforeDelete (403 when absent) — same + // secure-by-default pattern used for onBeforeEdit and + // onBeforeListByAuthor. Deletion is an admin operation; without + // this hook any unauthenticated caller could delete any comment. + if (!options?.onBeforeDelete) { + throw ctx.error(403, { + message: + "Forbidden: deleting comments requires the onBeforeDelete hook", + }); + } + await runHookWithShim( + () => options.onBeforeDelete!(id, context), + ctx.error, + "Unauthorized: Cannot delete comment", + ); + + const deleted = await deleteComment(adapter, id); + if (!deleted) { + throw ctx.error(404, { message: "Comment not found" }); + } + + if (options?.onAfterDelete) { + await options.onAfterDelete(id, context); + } + + return { success: true }; + } catch (error) { + throw error; + } + }, + ); + + return { + listComments: listCommentsEndpoint, + createComment: createCommentEndpoint, + updateComment: updateCommentEndpoint, + getCommentCount: getCommentCountEndpoint, + toggleLike: toggleLikeEndpoint, + updateCommentStatus: updateStatusEndpoint, + deleteComment: deleteCommentEndpoint, + } as const; + }, + }); +}; + +export type CommentsApiRouter = ReturnType< + ReturnType["routes"] +>; diff --git a/packages/stack/src/plugins/comments/api/query-key-defs.ts b/packages/stack/src/plugins/comments/api/query-key-defs.ts new file mode 100644 index 00000000..f1c4378e --- /dev/null +++ b/packages/stack/src/plugins/comments/api/query-key-defs.ts @@ -0,0 +1,143 @@ +/** + * Internal query key constants for the Comments plugin. + * Shared between query-keys.ts (HTTP path) and any SSG/direct DB path + * to prevent key drift between loaders and prefetch calls. + */ + +export interface CommentsListDiscriminator { + resourceId: string | undefined; + resourceType: string | undefined; + parentId: string | null | undefined; + status: string | undefined; + currentUserId: string | undefined; + authorId: string | undefined; + sort: string | undefined; + limit: number; + offset: number; +} + +/** + * Builds the discriminator object for the comments list query key. + */ +export function commentsListDiscriminator(params?: { + resourceId?: string; + resourceType?: string; + parentId?: string | null; + status?: string; + currentUserId?: string; + authorId?: string; + sort?: string; + limit?: number; + offset?: number; +}): CommentsListDiscriminator { + return { + resourceId: params?.resourceId, + resourceType: params?.resourceType, + parentId: params?.parentId, + status: params?.status, + currentUserId: params?.currentUserId, + authorId: params?.authorId, + sort: params?.sort, + limit: params?.limit ?? 20, + offset: params?.offset ?? 0, + }; +} + +export interface CommentCountDiscriminator { + resourceId: string; + resourceType: string; + status: string | undefined; +} + +export function commentCountDiscriminator(params: { + resourceId: string; + resourceType: string; + status?: string; +}): CommentCountDiscriminator { + return { + resourceId: params.resourceId, + resourceType: params.resourceType, + status: params.status, + }; +} + +/** + * Discriminator for the infinite thread query (top-level comments only). + * Intentionally excludes `offset` — pages are driven by `pageParam`, not the key. + */ +export interface CommentsThreadDiscriminator { + resourceId: string | undefined; + resourceType: string | undefined; + parentId: string | null | undefined; + status: string | undefined; + currentUserId: string | undefined; + limit: number; +} + +export function commentsThreadDiscriminator(params?: { + resourceId?: string; + resourceType?: string; + parentId?: string | null; + status?: string; + currentUserId?: string; + limit?: number; +}): CommentsThreadDiscriminator { + return { + resourceId: params?.resourceId, + resourceType: params?.resourceType, + parentId: params?.parentId, + status: params?.status, + currentUserId: params?.currentUserId, + limit: params?.limit ?? 20, + }; +} + +/** Full query key builders — use with queryClient.setQueryData() */ +export const COMMENTS_QUERY_KEYS = { + /** + * Key for comments list query. + * Full key: ["comments", "list", { resourceId, resourceType, parentId, status, currentUserId, limit, offset }] + */ + commentsList: (params?: { + resourceId?: string; + resourceType?: string; + parentId?: string | null; + status?: string; + currentUserId?: string; + authorId?: string; + sort?: string; + limit?: number; + offset?: number; + }) => ["comments", "list", commentsListDiscriminator(params)] as const, + + /** + * Key for a single comment detail query. + * Full key: ["comments", "detail", id] + */ + commentDetail: (id: string) => ["comments", "detail", id] as const, + + /** + * Key for comment count query. + * Full key: ["comments", "count", { resourceId, resourceType, status }] + */ + commentCount: (params: { + resourceId: string; + resourceType: string; + status?: string; + }) => ["comments", "count", commentCountDiscriminator(params)] as const, + + /** + * Key for the infinite thread query (top-level comments, load-more). + * Full key: ["commentsThread", "list", { resourceId, resourceType, parentId, status, currentUserId, limit }] + * Offset is excluded — it is driven by `pageParam`, not baked into the key. + */ + commentsThread: (params?: { + resourceId?: string; + resourceType?: string; + parentId?: string | null; + status?: string; + currentUserId?: string; + limit?: number; + }) => + ["commentsThread", "list", commentsThreadDiscriminator(params)] as const, +}; diff --git a/packages/stack/src/plugins/comments/api/serializers.ts b/packages/stack/src/plugins/comments/api/serializers.ts new file mode 100644 index 00000000..2e153793 --- /dev/null +++ b/packages/stack/src/plugins/comments/api/serializers.ts @@ -0,0 +1,37 @@ +import type { Comment, SerializedComment } from "../types"; + +/** + * Serialize a raw Comment DB record into a SerializedComment for SSG/setQueryData. + * Note: resolvedAuthorName, resolvedAvatarUrl, and isLikedByCurrentUser are not + * available from the DB record alone — use getters.ts enrichment for those. + * This serializer is for cases where you already have a SerializedComment from + * the HTTP layer and just need a type-safe round-trip. + * + * Pure function — no DB access, no hooks. + */ +export function serializeComment(comment: Comment): Omit< + SerializedComment, + "resolvedAuthorName" | "resolvedAvatarUrl" | "isLikedByCurrentUser" +> & { + resolvedAuthorName: string; + resolvedAvatarUrl: null; + isLikedByCurrentUser: false; +} { + return { + id: comment.id, + resourceId: comment.resourceId, + resourceType: comment.resourceType, + parentId: comment.parentId ?? null, + authorId: comment.authorId, + resolvedAuthorName: "[deleted]", + resolvedAvatarUrl: null, + isLikedByCurrentUser: false, + body: comment.body, + status: comment.status, + likes: comment.likes, + editedAt: comment.editedAt?.toISOString() ?? null, + createdAt: comment.createdAt.toISOString(), + updatedAt: comment.updatedAt.toISOString(), + replyCount: 0, + }; +} diff --git a/packages/stack/src/plugins/comments/client.css b/packages/stack/src/plugins/comments/client.css new file mode 100644 index 00000000..84e5c901 --- /dev/null +++ b/packages/stack/src/plugins/comments/client.css @@ -0,0 +1,2 @@ +/* Comments Plugin Client CSS */ +/* No custom styles needed - uses shadcn/ui components */ diff --git a/packages/stack/src/plugins/comments/client/components/comment-count.tsx b/packages/stack/src/plugins/comments/client/components/comment-count.tsx new file mode 100644 index 00000000..0af4f05a --- /dev/null +++ b/packages/stack/src/plugins/comments/client/components/comment-count.tsx @@ -0,0 +1,66 @@ +"use client"; + +import { MessageSquare } from "lucide-react"; +import { useCommentCount } from "../hooks/use-comments"; + +export interface CommentCountProps { + resourceId: string; + resourceType: string; + /** Only count approved comments (default) */ + status?: "pending" | "approved" | "spam"; + apiBaseURL: string; + apiBasePath: string; + headers?: HeadersInit; + /** Optional className for the wrapper span */ + className?: string; +} + +/** + * Lightweight badge showing the comment count for a resource. + * Does not mount a full comment thread — suitable for post list cards. + * + * @example + * ```tsx + * + * ``` + */ +export function CommentCount({ + resourceId, + resourceType, + status = "approved", + apiBaseURL, + apiBasePath, + headers, + className, +}: CommentCountProps) { + const { count, isLoading } = useCommentCount( + { apiBaseURL, apiBasePath, headers }, + { resourceId, resourceType, status }, + ); + + if (isLoading) { + return ( + + + … + + ); + } + + return ( + + + {count} + + ); +} diff --git a/packages/stack/src/plugins/comments/client/components/comment-form.tsx b/packages/stack/src/plugins/comments/client/components/comment-form.tsx new file mode 100644 index 00000000..f967e7f1 --- /dev/null +++ b/packages/stack/src/plugins/comments/client/components/comment-form.tsx @@ -0,0 +1,108 @@ +"use client"; + +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"; + +export interface CommentFormProps { + /** Current user's ID — required to post */ + authorId: string; + /** Optional parent comment ID for replies */ + parentId?: string | null; + /** Initial body value (for editing) */ + initialBody?: string; + /** Label for the submit button */ + submitLabel?: string; + /** Called when form is submitted */ + onSubmit: (body: string) => Promise; + /** Called when cancel is clicked (shows Cancel button when provided) */ + onCancel?: () => void; + /** Custom input component — defaults to a plain Textarea */ + InputComponent?: ComponentType<{ + value: string; + onChange: (value: string) => void; + disabled?: boolean; + placeholder?: string; + }>; + /** Localization strings */ + localization?: Partial; +} + +export function CommentForm({ + authorId: _authorId, + initialBody = "", + submitLabel, + onSubmit, + onCancel, + InputComponent, + localization: localizationProp, +}: CommentFormProps) { + const loc = { ...COMMENTS_LOCALIZATION, ...localizationProp }; + const [body, setBody] = useState(initialBody); + const [isPending, setIsPending] = useState(false); + const [error, setError] = useState(null); + + const resolvedSubmitLabel = submitLabel ?? loc.COMMENTS_FORM_POST_COMMENT; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + if (!body.trim()) return; + setError(null); + setIsPending(true); + try { + await onSubmit(body.trim()); + setBody(""); + } catch (err) { + setError( + err instanceof Error ? err.message : loc.COMMENTS_FORM_SUBMIT_ERROR, + ); + } finally { + setIsPending(false); + } + }; + + return ( + + {InputComponent ? ( + + ) : ( + setBody(e.target.value)} + placeholder={loc.COMMENTS_FORM_PLACEHOLDER} + disabled={isPending} + rows={3} + className="resize-none" + /> + )} + + {error && {error}} + + + {onCancel && ( + + {loc.COMMENTS_FORM_CANCEL} + + )} + + {isPending ? loc.COMMENTS_FORM_POSTING : resolvedSubmitLabel} + + + + ); +} diff --git a/packages/stack/src/plugins/comments/client/components/comment-thread.tsx b/packages/stack/src/plugins/comments/client/components/comment-thread.tsx new file mode 100644 index 00000000..4deded43 --- /dev/null +++ b/packages/stack/src/plugins/comments/client/components/comment-thread.tsx @@ -0,0 +1,681 @@ +"use client"; + +import { useState, type ComponentType } from "react"; +import { WhenVisible } from "@workspace/ui/components/when-visible"; +import { + Avatar, + AvatarFallback, + AvatarImage, +} from "@workspace/ui/components/avatar"; +import { Badge } from "@workspace/ui/components/badge"; +import { Button } from "@workspace/ui/components/button"; +import { Separator } from "@workspace/ui/components/separator"; +import { Heart, MessageSquare, Pencil, Check, X, LogIn } from "lucide-react"; +import { formatDistanceToNow } from "date-fns"; +import type { SerializedComment } from "../../types"; +import { CommentForm } from "./comment-form"; +import { + useComments, + useInfiniteComments, + usePostComment, + useUpdateComment, + useDeleteComment, + useToggleLike, +} from "../hooks/use-comments"; +import { + COMMENTS_LOCALIZATION, + type CommentsLocalization, +} from "../localization"; +import { usePluginOverrides } from "@btst/stack/context"; +import type { CommentsPluginOverrides } from "../overrides"; + +/** Custom input component props */ +export interface CommentInputProps { + value: string; + onChange: (value: string) => void; + disabled?: boolean; + placeholder?: string; +} + +/** Custom renderer component props */ +export interface CommentRendererProps { + body: string; +} + +/** Override slot for custom input + renderer */ +export interface CommentComponents { + Input?: ComponentType; + Renderer?: ComponentType; +} + +export interface CommentThreadProps { + /** The resource this thread is attached to (e.g. post slug, task ID) */ + resourceId: string; + /** Discriminates resources across plugins (e.g. "blog-post", "kanban-task") */ + resourceType: string; + /** Base URL for API calls */ + apiBaseURL: string; + /** Path where the API is mounted */ + apiBasePath: string; + /** Currently authenticated user ID. Omit for read-only / unauthenticated. */ + currentUserId?: string; + /** + * URL to redirect unauthenticated users to. + * When provided and currentUserId is absent, shows a "Please login to comment" prompt. + */ + loginHref?: string; + /** Optional HTTP headers for API calls (e.g. forwarding cookies) */ + headers?: HeadersInit; + /** Swap in custom Input / Renderer components */ + components?: CommentComponents; + /** Optional className applied to the root wrapper */ + className?: string; + /** Localization strings — defaults to English */ + localization?: Partial; + /** + * Number of top-level comments to load per page. + * Clicking "Load more" fetches the next page. Default: 10. + */ + pageSize?: number; +} + +const DEFAULT_RENDERER: ComponentType = ({ body }) => ( + {body} +); + +function getInitials(name: string | null | undefined) { + if (!name) return "?"; + return name + .split(" ") + .slice(0, 2) + .map((n) => n[0]) + .join("") + .toUpperCase(); +} + +// ─── Comment Card ───────────────────────────────────────────────────────────── + +function CommentCard({ + comment, + currentUserId, + apiBaseURL, + apiBasePath, + resourceId, + resourceType, + headers, + components, + loc, + infiniteKey, + onReplyClick, +}: { + comment: SerializedComment; + currentUserId?: string; + apiBaseURL: string; + apiBasePath: string; + resourceId: string; + resourceType: string; + headers?: HeadersInit; + components?: CommentComponents; + loc: CommentsLocalization; + /** Infinite thread query key — pass for top-level comments so like optimistic + * updates target the correct InfiniteData cache entry. */ + infiniteKey?: readonly unknown[]; + onReplyClick: (parentId: string) => void; +}) { + const [isEditing, setIsEditing] = useState(false); + const Renderer = components?.Renderer ?? DEFAULT_RENDERER; + + const config = { apiBaseURL, apiBasePath, headers }; + + const updateMutation = useUpdateComment(config); + const deleteMutation = useDeleteComment(config); + const toggleLikeMutation = useToggleLike(config, { + resourceId, + resourceType, + parentId: comment.parentId, + currentUserId, + infiniteKey, + }); + + const isOwn = currentUserId && comment.authorId === currentUserId; + const isPending = comment.status === "pending"; + const isApproved = comment.status === "approved"; + + const handleEdit = async (body: string) => { + await updateMutation.mutateAsync({ id: comment.id, body }); + setIsEditing(false); + }; + + const handleDelete = async () => { + if (!window.confirm(loc.COMMENTS_DELETE_CONFIRM)) return; + await deleteMutation.mutateAsync(comment.id); + }; + + const handleLike = () => { + if (!currentUserId) return; + toggleLikeMutation.mutate({ + commentId: comment.id, + authorId: currentUserId, + }); + }; + + return ( + + + {comment.resolvedAvatarUrl && ( + + )} + + {getInitials(comment.resolvedAuthorName)} + + + + + + + {comment.resolvedAuthorName} + + + {formatDistanceToNow(new Date(comment.createdAt), { + addSuffix: true, + })} + + {comment.editedAt && ( + + {loc.COMMENTS_EDITED_BADGE} + + )} + {isPending && isOwn && ( + + {loc.COMMENTS_PENDING_BADGE} + + )} + + + {isEditing ? ( + setIsEditing(false)} + /> + ) : ( + + )} + + {!isEditing && ( + + {currentUserId && isApproved && ( + + + {comment.likes > 0 && ( + {comment.likes} + )} + + )} + + {currentUserId && !comment.parentId && isApproved && ( + onReplyClick(comment.id)} + data-testid="reply-button" + > + + {loc.COMMENTS_REPLY_BUTTON} + + )} + + {isOwn && ( + <> + {isApproved && ( + setIsEditing(true)} + data-testid="edit-button" + > + + {loc.COMMENTS_EDIT_BUTTON} + + )} + + + {loc.COMMENTS_DELETE_BUTTON} + + > + )} + + )} + + + ); +} + +// ─── Thread Inner (handles data) ────────────────────────────────────────────── + +const DEFAULT_PAGE_SIZE = 100; + +function CommentThreadInner({ + resourceId, + resourceType, + apiBaseURL, + apiBasePath, + currentUserId, + loginHref, + headers, + components, + localization: localizationProp, + pageSize: pageSizeProp, +}: CommentThreadProps) { + const overrides = usePluginOverrides< + CommentsPluginOverrides, + Partial + >("comments", {}); + const pageSize = + pageSizeProp ?? overrides.defaultCommentPageSize ?? DEFAULT_PAGE_SIZE; + const loc = { ...COMMENTS_LOCALIZATION, ...localizationProp }; + const [replyingTo, setReplyingTo] = useState(null); + const [expandedReplies, setExpandedReplies] = useState>( + new Set(), + ); + + const config = { apiBaseURL, apiBasePath, headers }; + + const { + comments, + total, + isLoading, + loadMore, + hasMore, + isLoadingMore, + queryKey: threadQueryKey, + } = useInfiniteComments(config, { + resourceId, + resourceType, + status: "approved", + parentId: null, + currentUserId, + pageSize, + }); + + const postMutation = usePostComment(config, { + resourceId, + resourceType, + currentUserId, + infiniteKey: threadQueryKey, + }); + + const handlePost = async (body: string) => { + if (!currentUserId) return; + await postMutation.mutateAsync({ + body, + parentId: null, + }); + }; + + const handleReply = async (body: string, parentId: string) => { + if (!currentUserId) return; + await postMutation.mutateAsync({ + body, + parentId, + }); + setReplyingTo(null); + setExpandedReplies((prev) => new Set(prev).add(parentId)); + }; + + return ( + + + + + {total === 0 ? loc.COMMENTS_TITLE : `${total} ${loc.COMMENTS_TITLE}`} + + + + {isLoading && ( + + {[1, 2].map((i) => ( + + + + + + + + + ))} + + )} + + {!isLoading && comments.length > 0 && ( + + {comments.map((comment) => ( + + { + setReplyingTo(replyingTo === parentId ? null : parentId); + }} + /> + + {/* Replies */} + { + setExpandedReplies((prev) => { + const next = new Set(prev); + next.has(comment.id) + ? next.delete(comment.id) + : next.add(comment.id); + return next; + }); + }} + /> + + {replyingTo === comment.id && currentUserId && ( + + handleReply(body, comment.id)} + onCancel={() => setReplyingTo(null)} + /> + + )} + + ))} + + )} + + {!isLoading && comments.length === 0 && ( + + {loc.COMMENTS_EMPTY} + + )} + + {hasMore && ( + + loadMore()} + disabled={isLoadingMore} + data-testid="load-more-comments" + > + {isLoadingMore ? loc.COMMENTS_LOADING_MORE : loc.COMMENTS_LOAD_MORE} + + + )} + + + + {currentUserId ? ( + + + + ) : ( + + + + {loc.COMMENTS_LOGIN_PROMPT} + + {loginHref && ( + + {loc.COMMENTS_LOGIN_LINK} + + )} + + )} + + ); +} + +// ─── Replies Section ─────────────────────────────────────────────────────────── + +function RepliesSection({ + parentId, + resourceId, + resourceType, + apiBaseURL, + apiBasePath, + currentUserId, + headers, + components, + loc, + expanded, + replyCount, + onToggle, +}: { + parentId: string; + resourceId: string; + resourceType: string; + apiBaseURL: string; + apiBasePath: string; + currentUserId?: string; + headers?: HeadersInit; + components?: CommentComponents; + loc: CommentsLocalization; + expanded: boolean; + /** Pre-computed from the parent comment — avoids an extra fetch on mount. */ + replyCount: number; + onToggle: () => void; +}) { + const config = { apiBaseURL, apiBasePath, headers }; + // Only fetch reply bodies once the section is expanded. + const { comments: replies } = useComments( + config, + { + resourceId, + resourceType, + parentId, + status: "approved", + currentUserId, + }, + { enabled: expanded }, + ); + + // Hide when there are no known replies — but keep rendered when already + // expanded so a freshly-posted first reply (which increments replyCount + // only after the server responds) stays visible in the same session. + if (replyCount === 0 && !expanded) return null; + + // Prefer the fetched count (accurate after optimistic inserts); fall back to + // the server-provided replyCount before the fetch completes. + const displayCount = expanded ? replies.length || replyCount : replyCount; + + return ( + + {!expanded && ( + + + {displayCount}{" "} + {displayCount === 1 + ? loc.COMMENTS_REPLIES_SINGULAR + : loc.COMMENTS_REPLIES_PLURAL} + + )} + {expanded && ( + + {replies.map((reply) => ( + {}} // No nested replies in v1 + /> + ))} + + {loc.COMMENTS_HIDE_REPLIES} + + + )} + + ); +} + +// ─── Public export: lazy-mounts on scroll into view ─────────────────────────── + +/** + * Embeddable threaded comment section. + * + * Lazy-mounts when the component scrolls into the viewport (via WhenVisible). + * Requires `currentUserId` to allow posting; shows a "Please login" prompt otherwise. + * + * @example + * ```tsx + * + * ``` + */ +function CommentThreadSkeleton() { + return ( + + {/* Header */} + + + + + + {/* Comment rows */} + {[1, 2, 3].map((i) => ( + + + + + + + + + + + + + + + + ))} + + {/* Separator */} + + + {/* Textarea placeholder */} + + + + + + + + ); +} + +export function CommentThread(props: CommentThreadProps) { + return ( + } + rootMargin="300px" + className={props.className} + > + + + ); +} diff --git a/packages/stack/src/plugins/comments/client/components/index.tsx b/packages/stack/src/plugins/comments/client/components/index.tsx new file mode 100644 index 00000000..f6b7f645 --- /dev/null +++ b/packages/stack/src/plugins/comments/client/components/index.tsx @@ -0,0 +1,11 @@ +export { + CommentThread, + type CommentThreadProps, + type CommentComponents, + type CommentInputProps, + type CommentRendererProps, +} from "./comment-thread"; +export { CommentCount, type CommentCountProps } from "./comment-count"; +export { CommentForm, type CommentFormProps } from "./comment-form"; +export { ModerationPageComponent } from "./pages/moderation-page"; +export { ResourceCommentsPageComponent } from "./pages/resource-comments-page"; diff --git a/packages/stack/src/plugins/comments/client/components/pages/moderation-page.internal.tsx b/packages/stack/src/plugins/comments/client/components/pages/moderation-page.internal.tsx new file mode 100644 index 00000000..c1167c76 --- /dev/null +++ b/packages/stack/src/plugins/comments/client/components/pages/moderation-page.internal.tsx @@ -0,0 +1,544 @@ +"use client"; + +import { useState } from "react"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@workspace/ui/components/table"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, +} from "@workspace/ui/components/dialog"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@workspace/ui/components/alert-dialog"; +import { Button } from "@workspace/ui/components/button"; +import { Badge } from "@workspace/ui/components/badge"; +import { Tabs, TabsList, TabsTrigger } from "@workspace/ui/components/tabs"; +import { Checkbox } from "@workspace/ui/components/checkbox"; +import { + Avatar, + AvatarFallback, + AvatarImage, +} from "@workspace/ui/components/avatar"; +import { CheckCircle, ShieldOff, Trash2, Eye } from "lucide-react"; +import { toast } from "sonner"; +import { formatDistanceToNow } from "date-fns"; +import { useRegisterPageAIContext } from "@btst/stack/plugins/ai-chat/client/context"; +import type { SerializedComment, CommentStatus } from "../../../types"; +import { + useSuspenseComments, + useUpdateCommentStatus, + useDeleteComment, +} from "../../hooks/use-comments"; +import { + COMMENTS_LOCALIZATION, + type CommentsLocalization, +} from "../../localization"; + +interface ModerationPageProps { + apiBaseURL: string; + apiBasePath: string; + headers?: HeadersInit; + localization?: CommentsLocalization; +} + +function getInitials(name: string | null | undefined) { + if (!name) return "?"; + return name + .split(" ") + .slice(0, 2) + .map((n) => n[0]) + .join("") + .toUpperCase(); +} + +function StatusBadge({ status }: { status: CommentStatus }) { + const variants: Record< + CommentStatus, + "secondary" | "default" | "destructive" + > = { + pending: "secondary", + approved: "default", + spam: "destructive", + }; + return {status}; +} + +export function ModerationPage({ + apiBaseURL, + apiBasePath, + headers, + localization: localizationProp, +}: ModerationPageProps) { + const loc = { ...COMMENTS_LOCALIZATION, ...localizationProp }; + const [activeTab, setActiveTab] = useState("pending"); + const [selected, setSelected] = useState>(new Set()); + const [viewComment, setViewComment] = useState( + null, + ); + const [deleteIds, setDeleteIds] = useState([]); + + const config = { apiBaseURL, apiBasePath, headers }; + + const { comments, total, refetch } = useSuspenseComments(config, { + status: activeTab, + }); + + const updateStatus = useUpdateCommentStatus(config); + const deleteMutation = useDeleteComment(config); + + // Register AI context with pending comment previews + useRegisterPageAIContext({ + routeName: "comments-moderation", + pageDescription: `${total} ${activeTab} comments in the moderation queue.\n\nTop ${activeTab} comments:\n${comments + .slice(0, 5) + .map( + (c) => + `- "${c.body.slice(0, 80)}${c.body.length > 80 ? "…" : ""}" by ${c.resolvedAuthorName} on ${c.resourceType}/${c.resourceId}`, + ) + .join("\n")}`, + suggestions: [ + "Approve all safe-looking comments", + "Flag spam comments", + "Summarize today's discussion", + ], + }); + + const toggleSelect = (id: string) => { + setSelected((prev) => { + const next = new Set(prev); + next.has(id) ? next.delete(id) : next.add(id); + return next; + }); + }; + + const toggleSelectAll = () => { + if (selected.size === comments.length) { + setSelected(new Set()); + } else { + setSelected(new Set(comments.map((c) => c.id))); + } + }; + + const handleApprove = async (id: string) => { + try { + await updateStatus.mutateAsync({ id, status: "approved" }); + toast.success(loc.COMMENTS_MODERATION_TOAST_APPROVED); + await refetch(); + } catch { + toast.error(loc.COMMENTS_MODERATION_TOAST_APPROVE_ERROR); + } + }; + + const handleSpam = async (id: string) => { + try { + await updateStatus.mutateAsync({ id, status: "spam" }); + toast.success(loc.COMMENTS_MODERATION_TOAST_SPAM); + await refetch(); + } catch { + toast.error(loc.COMMENTS_MODERATION_TOAST_SPAM_ERROR); + } + }; + + const handleDelete = async (ids: string[]) => { + try { + await Promise.all(ids.map((id) => deleteMutation.mutateAsync(id))); + toast.success( + ids.length === 1 + ? loc.COMMENTS_MODERATION_TOAST_DELETED + : loc.COMMENTS_MODERATION_TOAST_DELETED_PLURAL.replace( + "{n}", + String(ids.length), + ), + ); + setSelected(new Set()); + setDeleteIds([]); + await refetch(); + } catch { + toast.error(loc.COMMENTS_MODERATION_TOAST_DELETE_ERROR); + } + }; + + const handleBulkApprove = async () => { + const ids = [...selected]; + try { + await Promise.all( + ids.map((id) => updateStatus.mutateAsync({ id, status: "approved" })), + ); + toast.success( + loc.COMMENTS_MODERATION_TOAST_BULK_APPROVED.replace( + "{n}", + String(ids.length), + ), + ); + setSelected(new Set()); + await refetch(); + } catch { + toast.error(loc.COMMENTS_MODERATION_TOAST_BULK_APPROVE_ERROR); + } + }; + + return ( + + + {loc.COMMENTS_MODERATION_TITLE} + + {loc.COMMENTS_MODERATION_DESCRIPTION} + + + + { + setActiveTab(v as CommentStatus); + setSelected(new Set()); + }} + > + + + {loc.COMMENTS_MODERATION_TAB_PENDING} + + + {loc.COMMENTS_MODERATION_TAB_APPROVED} + + + {loc.COMMENTS_MODERATION_TAB_SPAM} + + + + + {/* Bulk actions toolbar */} + {selected.size > 0 && ( + + + {loc.COMMENTS_MODERATION_SELECTED.replace( + "{n}", + String(selected.size), + )} + + {activeTab !== "approved" && ( + + + {loc.COMMENTS_MODERATION_APPROVE_SELECTED} + + )} + setDeleteIds([...selected])} + > + + {loc.COMMENTS_MODERATION_DELETE_SELECTED} + + + )} + + {comments.length === 0 ? ( + + + + {loc.COMMENTS_MODERATION_EMPTY.replace("{status}", activeTab)} + + + ) : ( + + + + + + 0 + } + onCheckedChange={toggleSelectAll} + aria-label={loc.COMMENTS_MODERATION_SELECT_ALL} + /> + + {loc.COMMENTS_MODERATION_COL_AUTHOR} + {loc.COMMENTS_MODERATION_COL_COMMENT} + {loc.COMMENTS_MODERATION_COL_RESOURCE} + {loc.COMMENTS_MODERATION_COL_DATE} + + {loc.COMMENTS_MODERATION_COL_ACTIONS} + + + + + {comments.map((comment) => ( + + + toggleSelect(comment.id)} + aria-label={loc.COMMENTS_MODERATION_SELECT_ONE} + /> + + + + + {comment.resolvedAvatarUrl && ( + + )} + + {getInitials(comment.resolvedAuthorName)} + + + + {comment.resolvedAuthorName} + + + + + + {comment.body} + + + + + {comment.resourceType}/{comment.resourceId} + + + + {formatDistanceToNow(new Date(comment.createdAt), { + addSuffix: true, + })} + + + + setViewComment(comment)} + data-testid="view-button" + > + + + {activeTab !== "approved" && ( + handleApprove(comment.id)} + disabled={updateStatus.isPending} + data-testid="approve-button" + > + + + )} + {activeTab !== "spam" && ( + handleSpam(comment.id)} + disabled={updateStatus.isPending} + data-testid="spam-button" + > + + + )} + setDeleteIds([comment.id])} + data-testid="delete-button" + > + + + + + + ))} + + + + )} + + {/* View comment dialog */} + setViewComment(null)}> + + + {loc.COMMENTS_MODERATION_DIALOG_TITLE} + + {viewComment && ( + + + + {viewComment.resolvedAvatarUrl && ( + + )} + + {getInitials(viewComment.resolvedAuthorName)} + + + + + {viewComment.resolvedAuthorName} + + + {new Date(viewComment.createdAt).toLocaleString()} + + + + + + + + + {loc.COMMENTS_MODERATION_DIALOG_RESOURCE} + + + {viewComment.resourceType}/{viewComment.resourceId} + + + + + {loc.COMMENTS_MODERATION_DIALOG_LIKES} + + {viewComment.likes} + + {viewComment.parentId && ( + + + {loc.COMMENTS_MODERATION_DIALOG_REPLY_TO} + + {viewComment.parentId} + + )} + {viewComment.editedAt && ( + + + {loc.COMMENTS_MODERATION_DIALOG_EDITED} + + + {new Date(viewComment.editedAt).toLocaleString()} + + + )} + + + + + {loc.COMMENTS_MODERATION_DIALOG_BODY} + + + {viewComment.body} + + + + + {viewComment.status !== "approved" && ( + { + await handleApprove(viewComment.id); + setViewComment(null); + }} + disabled={updateStatus.isPending} + data-testid="dialog-approve-button" + > + + {loc.COMMENTS_MODERATION_DIALOG_APPROVE} + + )} + {viewComment.status !== "spam" && ( + { + await handleSpam(viewComment.id); + setViewComment(null); + }} + disabled={updateStatus.isPending} + > + + {loc.COMMENTS_MODERATION_DIALOG_MARK_SPAM} + + )} + { + setDeleteIds([viewComment.id]); + setViewComment(null); + }} + > + + {loc.COMMENTS_MODERATION_DIALOG_DELETE} + + + + )} + + + + {/* Delete confirmation dialog */} + 0} + onOpenChange={(open) => !open && setDeleteIds([])} + > + + + + {deleteIds.length === 1 + ? loc.COMMENTS_MODERATION_DELETE_TITLE_SINGULAR + : loc.COMMENTS_MODERATION_DELETE_TITLE_PLURAL.replace( + "{n}", + String(deleteIds.length), + )} + + + {deleteIds.length === 1 + ? loc.COMMENTS_MODERATION_DELETE_DESCRIPTION_SINGULAR + : loc.COMMENTS_MODERATION_DELETE_DESCRIPTION_PLURAL} + + + + + {loc.COMMENTS_MODERATION_DELETE_CANCEL} + + handleDelete(deleteIds)} + data-testid="confirm-delete-button" + > + {deleteMutation.isPending + ? loc.COMMENTS_MODERATION_DELETE_DELETING + : loc.COMMENTS_MODERATION_DELETE_CONFIRM} + + + + + + ); +} diff --git a/packages/stack/src/plugins/comments/client/components/pages/moderation-page.tsx b/packages/stack/src/plugins/comments/client/components/pages/moderation-page.tsx new file mode 100644 index 00000000..2b20eacf --- /dev/null +++ b/packages/stack/src/plugins/comments/client/components/pages/moderation-page.tsx @@ -0,0 +1,70 @@ +"use client"; + +import { lazy } from "react"; +import { ComposedRoute } from "@btst/stack/client/components"; +import { usePluginOverrides } from "@btst/stack/context"; +import type { CommentsPluginOverrides } from "../../overrides"; +import { COMMENTS_LOCALIZATION } from "../../localization"; +import { useRouteLifecycle } from "@workspace/ui/hooks/use-route-lifecycle"; +import { PageWrapper } from "../shared/page-wrapper"; + +const ModerationPageInternal = lazy(() => + import("./moderation-page.internal").then((m) => ({ + default: m.ModerationPage, + })), +); + +function ModerationPageSkeleton() { + return ( + + + + + + + ); +} + +export function ModerationPageComponent() { + return ( + + console.error("[btst/comments] Moderation error:", error) + } + /> + ); +} + +function ModerationPageWrapper() { + const overrides = usePluginOverrides("comments"); + const loc = { ...COMMENTS_LOCALIZATION, ...overrides.localization }; + + useRouteLifecycle({ + routeName: "moderation", + context: { + path: "/comments/moderation", + isSSR: typeof window === "undefined", + }, + overrides, + beforeRenderHook: (o, context) => { + if (o.onBeforeModerationPageRendered) { + return o.onBeforeModerationPageRendered(context); + } + return true; + }, + }); + + return ( + + + + ); +} diff --git a/packages/stack/src/plugins/comments/client/components/pages/my-comments-page.internal.tsx b/packages/stack/src/plugins/comments/client/components/pages/my-comments-page.internal.tsx new file mode 100644 index 00000000..607d2d6e --- /dev/null +++ b/packages/stack/src/plugins/comments/client/components/pages/my-comments-page.internal.tsx @@ -0,0 +1,395 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@workspace/ui/components/table"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@workspace/ui/components/alert-dialog"; +import { Button } from "@workspace/ui/components/button"; +import { Badge } from "@workspace/ui/components/badge"; +import { + Avatar, + AvatarFallback, + AvatarImage, +} from "@workspace/ui/components/avatar"; +import { Trash2, ExternalLink, LogIn, MessageSquareOff } from "lucide-react"; +import { toast } from "sonner"; +import { formatDistanceToNow } from "date-fns"; +import type { CommentsPluginOverrides } from "../../overrides"; +import { PaginationControls } from "@workspace/ui/components/pagination-controls"; +import type { SerializedComment, CommentStatus } from "../../../types"; +import { + useSuspenseComments, + useDeleteComment, +} from "../../hooks/use-comments"; +import { + COMMENTS_LOCALIZATION, + type CommentsLocalization, +} from "../../localization"; + +const PAGE_LIMIT = 20; + +interface MyCommentsPageProps { + apiBaseURL: string; + apiBasePath: string; + headers?: HeadersInit; + currentUserId?: CommentsPluginOverrides["currentUserId"]; + resourceLinks?: CommentsPluginOverrides["resourceLinks"]; + localization?: CommentsLocalization; +} + +function getInitials(name: string | null | undefined) { + if (!name) return "?"; + return name + .split(" ") + .slice(0, 2) + .map((n) => n[0]) + .join("") + .toUpperCase(); +} + +function StatusBadge({ + status, + loc, +}: { + status: CommentStatus; + loc: CommentsLocalization; +}) { + if (status === "approved") { + return ( + + {loc.COMMENTS_MY_STATUS_APPROVED} + + ); + } + if (status === "pending") { + return ( + + {loc.COMMENTS_MY_STATUS_PENDING} + + ); + } + return ( + + {loc.COMMENTS_MY_STATUS_SPAM} + + ); +} + +// ─── Resolved currentUserId hook ───────────────────────────────────────────── + +function useResolvedCurrentUserId( + raw: CommentsPluginOverrides["currentUserId"], +): string | undefined { + const [resolved, setResolved] = useState( + typeof raw === "string" ? raw : undefined, + ); + + useEffect(() => { + if (typeof raw === "function") { + void Promise.resolve(raw()).then((id) => { + setResolved(id ?? undefined); + }); + } else { + setResolved(raw ?? undefined); + } + }, [raw]); + + return resolved; +} + +// ─── Main export ────────────────────────────────────────────────────────────── + +export function MyCommentsPage({ + apiBaseURL, + apiBasePath, + headers, + currentUserId: currentUserIdProp, + resourceLinks, + localization: localizationProp, +}: MyCommentsPageProps) { + const loc = { ...COMMENTS_LOCALIZATION, ...localizationProp }; + const resolvedUserId = useResolvedCurrentUserId(currentUserIdProp); + + if (!resolvedUserId) { + return ( + + + {loc.COMMENTS_MY_LOGIN_TITLE} + + {loc.COMMENTS_MY_LOGIN_DESCRIPTION} + + + ); + } + + return ( + + ); +} + +// ─── List (suspense boundary is in ComposedRoute) ───────────────────────────── + +function MyCommentsList({ + apiBaseURL, + apiBasePath, + headers, + currentUserId, + resourceLinks, + loc, +}: { + apiBaseURL: string; + apiBasePath: string; + headers?: HeadersInit; + currentUserId: string; + resourceLinks?: CommentsPluginOverrides["resourceLinks"]; + loc: CommentsLocalization; +}) { + const [page, setPage] = useState(1); + const [deleteId, setDeleteId] = useState(null); + + const config = { apiBaseURL, apiBasePath, headers }; + const offset = (page - 1) * PAGE_LIMIT; + + const { comments, total, refetch } = useSuspenseComments(config, { + authorId: currentUserId, + sort: "desc", + limit: PAGE_LIMIT, + offset, + }); + + const deleteMutation = useDeleteComment(config); + + const totalPages = Math.max(1, Math.ceil(total / PAGE_LIMIT)); + + const handleDelete = async () => { + if (!deleteId) return; + try { + await deleteMutation.mutateAsync(deleteId); + toast.success(loc.COMMENTS_MY_TOAST_DELETED); + refetch(); + } catch { + toast.error(loc.COMMENTS_MY_TOAST_DELETE_ERROR); + } finally { + setDeleteId(null); + } + }; + + if (comments.length === 0 && page === 1) { + return ( + + + {loc.COMMENTS_MY_EMPTY_TITLE} + + {loc.COMMENTS_MY_EMPTY_DESCRIPTION} + + + ); + } + + return ( + + + + {loc.COMMENTS_MY_PAGE_TITLE} + + + {total} {loc.COMMENTS_MY_COL_COMMENT.toLowerCase()} + {total !== 1 ? "s" : ""} + + + + + + + + + {loc.COMMENTS_MY_COL_COMMENT} + + {loc.COMMENTS_MY_COL_RESOURCE} + + + {loc.COMMENTS_MY_COL_STATUS} + + + {loc.COMMENTS_MY_COL_DATE} + + + + + + {comments.map((comment) => ( + setDeleteId(comment.id)} + isDeleting={deleteMutation.isPending && deleteId === comment.id} + /> + ))} + + + + { + setPage(p); + window.scrollTo({ top: 0, behavior: "smooth" }); + }} + /> + + + !open && setDeleteId(null)} + > + + + {loc.COMMENTS_MY_DELETE_TITLE} + + {loc.COMMENTS_MY_DELETE_DESCRIPTION} + + + + + {loc.COMMENTS_MY_DELETE_CANCEL} + + + {loc.COMMENTS_MY_DELETE_CONFIRM} + + + + + + ); +} + +// ─── Row ────────────────────────────────────────────────────────────────────── + +function CommentRow({ + comment, + resourceLinks, + loc, + onDelete, + isDeleting, +}: { + comment: SerializedComment; + resourceLinks?: CommentsPluginOverrides["resourceLinks"]; + loc: CommentsLocalization; + onDelete: () => void; + isDeleting: boolean; +}) { + const resourceUrl = resourceLinks?.[comment.resourceType]?.( + comment.resourceId, + ); + + return ( + + + + {comment.resolvedAvatarUrl && ( + + )} + + {getInitials(comment.resolvedAuthorName)} + + + + + + {comment.body} + {comment.parentId && ( + + {loc.COMMENTS_MY_REPLY_INDICATOR} + + )} + + + + + + {comment.resourceType.replace(/-/g, " ")} + + {resourceUrl ? ( + + {loc.COMMENTS_MY_VIEW_LINK} + + + ) : ( + + {comment.resourceId} + + )} + + + + + + + + + {formatDistanceToNow(new Date(comment.createdAt), { addSuffix: true })} + + + + + + {loc.COMMENTS_MY_DELETE_BUTTON_SR} + + + + ); +} diff --git a/packages/stack/src/plugins/comments/client/components/pages/my-comments-page.tsx b/packages/stack/src/plugins/comments/client/components/pages/my-comments-page.tsx new file mode 100644 index 00000000..0e73baee --- /dev/null +++ b/packages/stack/src/plugins/comments/client/components/pages/my-comments-page.tsx @@ -0,0 +1,72 @@ +"use client"; + +import { lazy } from "react"; +import { ComposedRoute } from "@btst/stack/client/components"; +import { usePluginOverrides } from "@btst/stack/context"; +import type { CommentsPluginOverrides } from "../../overrides"; +import { COMMENTS_LOCALIZATION } from "../../localization"; +import { useRouteLifecycle } from "@workspace/ui/hooks/use-route-lifecycle"; +import { PageWrapper } from "../shared/page-wrapper"; + +const MyCommentsPageInternal = lazy(() => + import("./my-comments-page.internal").then((m) => ({ + default: m.MyCommentsPage, + })), +); + +function MyCommentsPageSkeleton() { + return ( + + + + + + ); +} + +export function MyCommentsPageComponent() { + return ( + + console.error("[btst/comments] My Comments error:", error) + } + /> + ); +} + +function MyCommentsPageWrapper() { + const overrides = usePluginOverrides("comments"); + const loc = { ...COMMENTS_LOCALIZATION, ...overrides.localization }; + + useRouteLifecycle({ + routeName: "myComments", + context: { + path: "/comments/my-comments", + isSSR: typeof window === "undefined", + }, + overrides, + beforeRenderHook: (o, context) => { + if (o.onBeforeMyCommentsPageRendered) { + const result = o.onBeforeMyCommentsPageRendered(context); + return result === false ? false : true; + } + return true; + }, + }); + + return ( + + + + ); +} diff --git a/packages/stack/src/plugins/comments/client/components/pages/resource-comments-page.internal.tsx b/packages/stack/src/plugins/comments/client/components/pages/resource-comments-page.internal.tsx new file mode 100644 index 00000000..36a6d000 --- /dev/null +++ b/packages/stack/src/plugins/comments/client/components/pages/resource-comments-page.internal.tsx @@ -0,0 +1,228 @@ +"use client"; + +import type { SerializedComment } from "../../../types"; +import { + useSuspenseComments, + useUpdateCommentStatus, + useDeleteComment, +} from "../../hooks/use-comments"; +import { CommentThread } from "../comment-thread"; +import { Button } from "@workspace/ui/components/button"; +import { Badge } from "@workspace/ui/components/badge"; +import { + Avatar, + AvatarFallback, + AvatarImage, +} from "@workspace/ui/components/avatar"; +import { CheckCircle, ShieldOff, Trash2 } from "lucide-react"; +import { formatDistanceToNow } from "date-fns"; +import { toast } from "sonner"; +import { + COMMENTS_LOCALIZATION, + type CommentsLocalization, +} from "../../localization"; + +interface ResourceCommentsPageProps { + resourceId: string; + resourceType: string; + apiBaseURL: string; + apiBasePath: string; + headers?: HeadersInit; + localization?: CommentsLocalization; +} + +function getInitials(name: string | null | undefined) { + if (!name) return "?"; + return name + .split(" ") + .slice(0, 2) + .map((n) => n[0]) + .join("") + .toUpperCase(); +} + +export function ResourceCommentsPage({ + resourceId, + resourceType, + apiBaseURL, + apiBasePath, + headers, + localization: localizationProp, +}: ResourceCommentsPageProps) { + const loc = { ...COMMENTS_LOCALIZATION, ...localizationProp }; + const config = { apiBaseURL, apiBasePath, headers }; + + const { + comments: pendingComments, + total: pendingTotal, + refetch, + } = useSuspenseComments(config, { + resourceId, + resourceType, + status: "pending", + }); + + const updateStatus = useUpdateCommentStatus(config); + const deleteMutation = useDeleteComment(config); + + const handleApprove = async (id: string) => { + try { + await updateStatus.mutateAsync({ id, status: "approved" }); + toast.success(loc.COMMENTS_RESOURCE_TOAST_APPROVED); + refetch(); + } catch { + toast.error(loc.COMMENTS_RESOURCE_TOAST_APPROVE_ERROR); + } + }; + + const handleSpam = async (id: string) => { + try { + await updateStatus.mutateAsync({ id, status: "spam" }); + toast.success(loc.COMMENTS_RESOURCE_TOAST_SPAM); + refetch(); + } catch { + toast.error(loc.COMMENTS_RESOURCE_TOAST_SPAM_ERROR); + } + }; + + const handleDelete = async (id: string) => { + if (!window.confirm(loc.COMMENTS_RESOURCE_DELETE_CONFIRM)) return; + try { + await deleteMutation.mutateAsync(id); + toast.success(loc.COMMENTS_RESOURCE_TOAST_DELETED); + refetch(); + } catch { + toast.error(loc.COMMENTS_RESOURCE_TOAST_DELETE_ERROR); + } + }; + + return ( + + + {loc.COMMENTS_RESOURCE_TITLE} + + {resourceType}/{resourceId} + + + + {pendingTotal > 0 && ( + + + {loc.COMMENTS_RESOURCE_PENDING_SECTION} + {pendingTotal} + + + {pendingComments.map((comment) => ( + handleApprove(comment.id)} + onSpam={() => handleSpam(comment.id)} + onDelete={() => handleDelete(comment.id)} + isUpdating={updateStatus.isPending} + isDeleting={deleteMutation.isPending} + /> + ))} + + + )} + + + + {loc.COMMENTS_RESOURCE_THREAD_SECTION} + + + + + ); +} + +function PendingCommentRow({ + comment, + loc, + onApprove, + onSpam, + onDelete, + isUpdating, + isDeleting, +}: { + comment: SerializedComment; + loc: CommentsLocalization; + onApprove: () => void; + onSpam: () => void; + onDelete: () => void; + isUpdating: boolean; + isDeleting: boolean; +}) { + return ( + + + {comment.resolvedAvatarUrl && ( + + )} + + {getInitials(comment.resolvedAuthorName)} + + + + + + {comment.resolvedAuthorName} + + + {formatDistanceToNow(new Date(comment.createdAt), { + addSuffix: true, + })} + + + + {comment.body} + + + + + {loc.COMMENTS_RESOURCE_ACTION_APPROVE} + + + + {loc.COMMENTS_RESOURCE_ACTION_SPAM} + + + + {loc.COMMENTS_RESOURCE_ACTION_DELETE} + + + + + ); +} diff --git a/packages/stack/src/plugins/comments/client/components/pages/resource-comments-page.tsx b/packages/stack/src/plugins/comments/client/components/pages/resource-comments-page.tsx new file mode 100644 index 00000000..aa1a7929 --- /dev/null +++ b/packages/stack/src/plugins/comments/client/components/pages/resource-comments-page.tsx @@ -0,0 +1,93 @@ +"use client"; + +import { lazy } from "react"; +import { ComposedRoute } from "@btst/stack/client/components"; +import { usePluginOverrides } from "@btst/stack/context"; +import type { CommentsPluginOverrides } from "../../overrides"; +import { COMMENTS_LOCALIZATION } from "../../localization"; +import { useRouteLifecycle } from "@workspace/ui/hooks/use-route-lifecycle"; +import { PageWrapper } from "../shared/page-wrapper"; + +const ResourceCommentsPageInternal = lazy(() => + import("./resource-comments-page.internal").then((m) => ({ + default: m.ResourceCommentsPage, + })), +); + +function ResourceCommentsSkeleton() { + return ( + + + + + + ); +} + +export function ResourceCommentsPageComponent({ + resourceId, + resourceType, +}: { + resourceId: string; + resourceType: string; +}) { + return ( + ( + + )} + LoadingComponent={ResourceCommentsSkeleton} + onError={(error) => + console.error("[btst/comments] Resource comments error:", error) + } + /> + ); +} + +function ResourceCommentsPageWrapper({ + resourceId, + resourceType, +}: { + resourceId: string; + resourceType: string; +}) { + const overrides = usePluginOverrides("comments"); + const loc = { ...COMMENTS_LOCALIZATION, ...overrides.localization }; + + useRouteLifecycle({ + routeName: "resourceComments", + context: { + path: `/comments/${resourceType}/${resourceId}`, + params: { resourceId, resourceType }, + isSSR: typeof window === "undefined", + }, + overrides, + beforeRenderHook: (o, context) => { + if (o.onBeforeResourceCommentsRendered) { + return o.onBeforeResourceCommentsRendered( + resourceType, + resourceId, + context, + ); + } + return true; + }, + }); + + return ( + + + + ); +} diff --git a/packages/stack/src/plugins/comments/client/components/shared/page-wrapper.tsx b/packages/stack/src/plugins/comments/client/components/shared/page-wrapper.tsx new file mode 100644 index 00000000..d734cd43 --- /dev/null +++ b/packages/stack/src/plugins/comments/client/components/shared/page-wrapper.tsx @@ -0,0 +1,32 @@ +"use client"; + +import { usePluginOverrides } from "@btst/stack/context"; +import { PageWrapper as SharedPageWrapper } from "@workspace/ui/components/page-wrapper"; +import type { CommentsPluginOverrides } from "../../overrides"; + +export function PageWrapper({ + children, + className, + testId, +}: { + children: React.ReactNode; + className?: string; + testId?: string; +}) { + const { showAttribution } = usePluginOverrides< + CommentsPluginOverrides, + Partial + >("comments", { + showAttribution: true, + }); + + return ( + + {children} + + ); +} diff --git a/packages/stack/src/plugins/comments/client/hooks/index.tsx b/packages/stack/src/plugins/comments/client/hooks/index.tsx new file mode 100644 index 00000000..8fa37820 --- /dev/null +++ b/packages/stack/src/plugins/comments/client/hooks/index.tsx @@ -0,0 +1,12 @@ +export { + useComments, + useSuspenseComments, + useInfiniteComments, + useCommentCount, + usePostComment, + useUpdateComment, + useApproveComment, + useUpdateCommentStatus, + useDeleteComment, + useToggleLike, +} from "./use-comments"; diff --git a/packages/stack/src/plugins/comments/client/hooks/use-comments.tsx b/packages/stack/src/plugins/comments/client/hooks/use-comments.tsx new file mode 100644 index 00000000..73c8b1a3 --- /dev/null +++ b/packages/stack/src/plugins/comments/client/hooks/use-comments.tsx @@ -0,0 +1,630 @@ +"use client"; + +import { + useQuery, + useInfiniteQuery, + useMutation, + useQueryClient, + useSuspenseQuery, + type InfiniteData, +} from "@tanstack/react-query"; +import { createApiClient } from "@btst/stack/plugins/client"; +import { createCommentsQueryKeys } from "../../query-keys"; +import type { CommentsApiRouter } from "../../api"; +import type { SerializedComment, CommentListResult } from "../../types"; + +interface CommentsClientConfig { + apiBaseURL: string; + apiBasePath: string; + headers?: HeadersInit; +} + +function getClient(config: CommentsClientConfig) { + return createApiClient({ + baseURL: config.apiBaseURL, + basePath: config.apiBasePath, + }); +} + +function toError(error: unknown): Error { + if (error instanceof Error) return error; + if (typeof error === "object" && error !== null) { + const obj = error as Record; + const message = + (typeof obj.message === "string" ? obj.message : null) || + JSON.stringify(error); + return new Error(message); + } + return new Error(String(error)); +} + +/** + * Fetch a paginated list of comments for a resource. + * Returns approved comments by default. + */ +export function useComments( + config: CommentsClientConfig, + params: { + resourceId?: string; + resourceType?: string; + parentId?: string | null; + status?: "pending" | "approved" | "spam"; + currentUserId?: string; + authorId?: string; + sort?: "asc" | "desc"; + limit?: number; + offset?: number; + }, + options?: { enabled?: boolean }, +) { + const client = getClient(config); + const queries = createCommentsQueryKeys(client, config.headers); + + const query = useQuery({ + ...queries.comments.list(params), + staleTime: 30_000, + retry: false, + enabled: options?.enabled ?? true, + }); + + return { + data: query.data, + comments: query.data?.items ?? [], + total: query.data?.total ?? 0, + isLoading: query.isLoading, + isFetching: query.isFetching, + error: query.error, + refetch: query.refetch, + }; +} + +/** + * useSuspenseQuery version — for use in .internal.tsx files. + */ +export function useSuspenseComments( + config: CommentsClientConfig, + params: { + resourceId?: string; + resourceType?: string; + parentId?: string | null; + status?: "pending" | "approved" | "spam"; + currentUserId?: string; + authorId?: string; + sort?: "asc" | "desc"; + limit?: number; + offset?: number; + }, +) { + const client = getClient(config); + const queries = createCommentsQueryKeys(client, config.headers); + + const { data, refetch, error, isFetching } = useSuspenseQuery({ + ...queries.comments.list(params), + staleTime: 30_000, + retry: false, + }); + + if (error && !isFetching) { + throw error; + } + + return { + comments: data?.items ?? [], + total: data?.total ?? 0, + refetch, + }; +} + +/** + * Infinite-scroll variant for the CommentThread component. + * Uses the "commentsThread" factory namespace (separate from the plain + * useComments / useSuspenseComments queries) to avoid InfiniteData shape conflicts. + * + * Mirrors the blog's usePosts pattern: spread the factory base query into + * useInfiniteQuery, drive pages via pageParam, and derive hasMore from server total. + */ +export function useInfiniteComments( + config: CommentsClientConfig, + params: { + resourceId: string; + resourceType: string; + parentId?: string | null; + status?: "pending" | "approved" | "spam"; + currentUserId?: string; + pageSize?: number; + }, + options?: { enabled?: boolean }, +) { + const pageSize = params.pageSize ?? 10; + const client = getClient(config); + const queries = createCommentsQueryKeys(client, config.headers); + + const baseQuery = queries.commentsThread.list({ + resourceId: params.resourceId, + resourceType: params.resourceType, + parentId: params.parentId ?? null, + status: params.status, + currentUserId: params.currentUserId, + limit: pageSize, + }); + + const query = useInfiniteQuery< + CommentListResult, + Error, + InfiniteData, + typeof baseQuery.queryKey, + number + >({ + ...baseQuery, + initialPageParam: 0, + getNextPageParam: (lastPage) => { + const nextOffset = lastPage.offset + lastPage.limit; + return nextOffset < lastPage.total ? nextOffset : undefined; + }, + staleTime: 30_000, + retry: false, + enabled: options?.enabled ?? true, + }); + + const comments = query.data?.pages.flatMap((p) => p.items) ?? []; + const total = query.data?.pages[0]?.total ?? 0; + + return { + comments, + total, + queryKey: baseQuery.queryKey, + isLoading: query.isLoading, + isFetching: query.isFetching, + loadMore: query.fetchNextPage, + hasMore: !!query.hasNextPage, + isLoadingMore: query.isFetchingNextPage, + error: query.error, + }; +} + +/** + * Fetch the approved comment count for a resource. + */ +export function useCommentCount( + config: CommentsClientConfig, + params: { + resourceId: string; + resourceType: string; + status?: "pending" | "approved" | "spam"; + }, +) { + const client = getClient(config); + const queries = createCommentsQueryKeys(client, config.headers); + + const query = useQuery({ + ...queries.commentCount.byResource(params), + staleTime: 60_000, + retry: false, + }); + + return { + count: query.data ?? 0, + isLoading: query.isLoading, + error: query.error, + }; +} + +/** + * Post a new comment with optimistic update. + * When autoApprove is false the optimistic entry shows as "pending" — visible + * only to the comment's own author via the `currentUserId` match in the UI. + * + * Pass `infiniteKey` (from `useInfiniteComments`) when the thread uses an + * infinite query so the optimistic update targets InfiniteData + * instead of a plain CommentListResult cache entry. + */ +export function usePostComment( + config: CommentsClientConfig, + params: { + resourceId: string; + resourceType: string; + currentUserId?: string; + /** When provided, optimistic updates target this infinite-query cache key. */ + infiniteKey?: readonly unknown[]; + }, +) { + const queryClient = useQueryClient(); + const client = getClient(config); + const queries = createCommentsQueryKeys(client, config.headers); + + // Compute the list key for a given parentId so optimistic updates always + // target the exact cache entry the component is subscribed to. + // parentId must be normalised to null (not undefined) because useComments + // passes `parentId: null` explicitly — null and undefined produce different + // discriminator objects and therefore different React Query cache keys. + const getListKey = (parentId: string | null | undefined) => { + // Top-level posts for a thread using useInfiniteComments get the infinite key. + if (params.infiniteKey && (parentId ?? null) === null) { + return params.infiniteKey; + } + return queries.comments.list({ + resourceId: params.resourceId, + resourceType: params.resourceType, + parentId: parentId ?? null, + status: "approved", + currentUserId: params.currentUserId, + }).queryKey; + }; + + const isInfinitePost = (parentId: string | null | undefined) => + !!params.infiniteKey && (parentId ?? null) === null; + + return useMutation({ + mutationFn: async (input: { body: string; parentId?: string | null }) => { + const response = await client("@post/comments", { + method: "POST", + body: { + resourceId: params.resourceId, + resourceType: params.resourceType, + parentId: input.parentId ?? null, + body: input.body, + }, + headers: config.headers, + }); + + const data = (response as { data?: unknown }).data; + if (!data) throw toError((response as { error?: unknown }).error); + return data as SerializedComment; + }, + onMutate: async (input) => { + const listKey = getListKey(input.parentId); + await queryClient.cancelQueries({ queryKey: listKey }); + + // Optimistic comment — shows to own author with "pending" badge + const optimisticId = `optimistic-${Date.now()}`; + const optimistic: SerializedComment = { + id: optimisticId, + resourceId: params.resourceId, + resourceType: params.resourceType, + parentId: input.parentId ?? null, + authorId: params.currentUserId ?? "", + resolvedAuthorName: "You", + resolvedAvatarUrl: null, + body: input.body, + status: "pending", + likes: 0, + isLikedByCurrentUser: false, + editedAt: null, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + replyCount: 0, + }; + + if (isInfinitePost(input.parentId)) { + const previous = + queryClient.getQueryData>(listKey); + + queryClient.setQueryData>( + listKey, + (old) => { + if (!old) { + return { + pages: [ + { items: [optimistic], total: 1, limit: 10, offset: 0 }, + ], + pageParams: [0], + }; + } + const lastIdx = old.pages.length - 1; + return { + ...old, + pages: old.pages.map((page, idx) => + idx === lastIdx + ? { + ...page, + items: [...page.items, optimistic], + total: page.total + 1, + } + : page, + ), + }; + }, + ); + + return { previous, isInfinite: true as const, listKey, optimisticId }; + } + + const previous = queryClient.getQueryData(listKey); + queryClient.setQueryData(listKey, (old) => { + if (!old) { + return { items: [optimistic], total: 1, limit: 20, offset: 0 }; + } + return { + ...old, + items: [...old.items, optimistic], + total: old.total + 1, + }; + }); + + return { previous, isInfinite: false as const, listKey, optimisticId }; + }, + onSuccess: (data, _input, context) => { + if (!context) return; + // Replace the optimistic item with the real server response. + // The server may return status "pending" (autoApprove: false) or "approved" + // (autoApprove: true). Either way we keep the item in the cache so the + // author continues to see their comment — with a "Pending approval" badge + // when pending. Without this, the onSettled invalidation would refetch + // only approved comments and make the pending entry disappear. + if (context.isInfinite) { + queryClient.setQueryData>( + context.listKey, + (old) => { + if (!old) return old; + return { + ...old, + pages: old.pages.map((page) => ({ + ...page, + items: page.items.map((item) => + item.id === context.optimisticId ? data : item, + ), + })), + }; + }, + ); + } else { + queryClient.setQueryData(context.listKey, (old) => { + if (!old) return old; + return { + ...old, + items: old.items.map((item) => + item.id === context.optimisticId ? data : item, + ), + }; + }); + } + }, + onError: (_err, _input, context) => { + if (context?.previous !== undefined) { + queryClient.setQueryData(context.listKey, context.previous); + } + }, + // No onSettled list invalidation — the mutation response is the ground + // truth. Invalidating would trigger a server refetch that returns only + // approved comments, erasing a pending optimistic entry from the cache. + }); +} + +/** + * Edit the body of an existing comment. + */ +export function useUpdateComment(config: CommentsClientConfig) { + const queryClient = useQueryClient(); + const client = getClient(config); + const queries = createCommentsQueryKeys(client, config.headers); + + return useMutation({ + mutationFn: async (input: { id: string; body: string }) => { + const response = await client("@patch/comments/:id", { + method: "PATCH", + params: { id: input.id }, + body: { body: input.body }, + headers: config.headers, + }); + const data = (response as { data?: unknown }).data; + if (!data) throw toError((response as { error?: unknown }).error); + return data as SerializedComment; + }, + onSettled: () => { + queryClient.invalidateQueries({ + queryKey: queries.comments.list._def, + }); + // Also invalidate the infinite thread cache so edits are reflected there. + queryClient.invalidateQueries({ queryKey: ["commentsThread"] }); + }, + }); +} + +/** + * Approve a comment (set status to "approved"). Admin use. + */ +export function useApproveComment(config: CommentsClientConfig) { + const queryClient = useQueryClient(); + const client = getClient(config); + const queries = createCommentsQueryKeys(client, config.headers); + + return useMutation({ + mutationFn: async (id: string) => { + const response = await client("@patch/comments/:id/status", { + method: "PATCH", + params: { id }, + body: { status: "approved" }, + headers: config.headers, + }); + const data = (response as { data?: unknown }).data; + if (!data) throw toError((response as { error?: unknown }).error); + return data as SerializedComment; + }, + onSettled: () => { + queryClient.invalidateQueries({ + queryKey: queries.comments.list._def, + }); + queryClient.invalidateQueries({ + queryKey: queries.commentCount.byResource._def, + }); + }, + }); +} + +/** + * Update comment status (pending / approved / spam). Admin use. + */ +export function useUpdateCommentStatus(config: CommentsClientConfig) { + const queryClient = useQueryClient(); + const client = getClient(config); + const queries = createCommentsQueryKeys(client, config.headers); + + return useMutation({ + mutationFn: async (input: { + id: string; + status: "pending" | "approved" | "spam"; + }) => { + const response = await client("@patch/comments/:id/status", { + method: "PATCH", + params: { id: input.id }, + body: { status: input.status }, + headers: config.headers, + }); + const data = (response as { data?: unknown }).data; + if (!data) throw toError((response as { error?: unknown }).error); + return data as SerializedComment; + }, + onSettled: () => { + queryClient.invalidateQueries({ + queryKey: queries.comments.list._def, + }); + queryClient.invalidateQueries({ + queryKey: queries.commentCount.byResource._def, + }); + }, + }); +} + +/** + * Delete a comment. Admin use. + */ +export function useDeleteComment(config: CommentsClientConfig) { + const queryClient = useQueryClient(); + const client = getClient(config); + const queries = createCommentsQueryKeys(client, config.headers); + + return useMutation({ + mutationFn: async (id: string) => { + const response = await client("@delete/comments/:id", { + method: "DELETE", + params: { id }, + headers: config.headers, + }); + const data = (response as { data?: unknown }).data; + if (!data) throw toError((response as { error?: unknown }).error); + return data as { success: boolean }; + }, + onSettled: () => { + queryClient.invalidateQueries({ + queryKey: queries.comments.list._def, + }); + queryClient.invalidateQueries({ + queryKey: queries.commentCount.byResource._def, + }); + // Also invalidate the infinite thread cache so deletions are reflected there. + queryClient.invalidateQueries({ queryKey: ["commentsThread"] }); + }, + }); +} + +/** + * Toggle a like on a comment with optimistic update. + * + * Pass `infiniteKey` (from `useInfiniteComments`) for top-level thread comments + * so the optimistic update targets InfiniteData instead of + * a plain CommentListResult cache entry. + */ +export function useToggleLike( + config: CommentsClientConfig, + params: { + resourceId: string; + resourceType: string; + /** parentId of the comment being liked — must match the parentId used by + * useComments so the optimistic setQueryData hits the correct cache entry. + * Pass `null` for top-level comments, or the parent comment ID for replies. */ + parentId?: string | null; + currentUserId?: string; + /** When the comment lives in an infinite thread, pass the thread's query key + * so the optimistic update targets the correct InfiniteData cache entry. */ + infiniteKey?: readonly unknown[]; + }, +) { + const queryClient = useQueryClient(); + const client = getClient(config); + const queries = createCommentsQueryKeys(client, config.headers); + + // For top-level thread comments use the infinite key; for replies (or when no + // infinite key is supplied) fall back to the regular list cache entry. + const isInfinite = !!params.infiniteKey && (params.parentId ?? null) === null; + const listKey = isInfinite + ? params.infiniteKey! + : queries.comments.list({ + resourceId: params.resourceId, + resourceType: params.resourceType, + parentId: params.parentId ?? null, + status: "approved", + currentUserId: params.currentUserId, + }).queryKey; + + function applyLikeUpdate( + commentId: string, + updater: (c: SerializedComment) => SerializedComment, + ) { + if (isInfinite) { + queryClient.setQueryData>( + listKey, + (old) => { + if (!old) return old; + return { + ...old, + pages: old.pages.map((page) => ({ + ...page, + items: page.items.map((c) => + c.id === commentId ? updater(c) : c, + ), + })), + }; + }, + ); + } else { + queryClient.setQueryData(listKey, (old) => { + if (!old) return old; + return { + ...old, + items: old.items.map((c) => (c.id === commentId ? updater(c) : c)), + }; + }); + } + } + + return useMutation({ + mutationFn: async (input: { commentId: string; authorId: string }) => { + const response = await client("@post/comments/:id/like", { + method: "POST", + params: { id: input.commentId }, + body: { authorId: input.authorId }, + headers: config.headers, + }); + const data = (response as { data?: unknown }).data; + if (!data) throw toError((response as { error?: unknown }).error); + return data as { likes: number; isLiked: boolean }; + }, + onMutate: async (input) => { + await queryClient.cancelQueries({ queryKey: listKey }); + + // Snapshot previous state for rollback. + const previous = isInfinite + ? queryClient.getQueryData>(listKey) + : queryClient.getQueryData(listKey); + + applyLikeUpdate(input.commentId, (c) => { + const wasLiked = c.isLikedByCurrentUser; + return { + ...c, + isLikedByCurrentUser: !wasLiked, + likes: wasLiked ? Math.max(0, c.likes - 1) : c.likes + 1, + }; + }); + + return { previous }; + }, + onError: (_err, _input, context) => { + if (context?.previous !== undefined) { + queryClient.setQueryData(listKey, context.previous); + } + }, + onSettled: () => { + queryClient.invalidateQueries({ queryKey: listKey }); + }, + }); +} diff --git a/packages/stack/src/plugins/comments/client/index.ts b/packages/stack/src/plugins/comments/client/index.ts new file mode 100644 index 00000000..d769af40 --- /dev/null +++ b/packages/stack/src/plugins/comments/client/index.ts @@ -0,0 +1,14 @@ +export { + commentsClientPlugin, + type CommentsClientConfig, + type CommentsClientHooks, + type LoaderContext, +} from "./plugin"; +export { + type CommentsPluginOverrides, + type RouteContext, +} from "./overrides"; +export { + COMMENTS_LOCALIZATION, + type CommentsLocalization, +} from "./localization"; diff --git a/packages/stack/src/plugins/comments/client/localization/comments-moderation.ts b/packages/stack/src/plugins/comments/client/localization/comments-moderation.ts new file mode 100644 index 00000000..1c6a7745 --- /dev/null +++ b/packages/stack/src/plugins/comments/client/localization/comments-moderation.ts @@ -0,0 +1,71 @@ +export const COMMENTS_MODERATION = { + COMMENTS_MODERATION_TITLE: "Comment Moderation", + COMMENTS_MODERATION_DESCRIPTION: + "Review and manage comments across all resources.", + + COMMENTS_MODERATION_TAB_PENDING: "Pending", + COMMENTS_MODERATION_TAB_APPROVED: "Approved", + COMMENTS_MODERATION_TAB_SPAM: "Spam", + + COMMENTS_MODERATION_SELECTED: "{n} selected", + COMMENTS_MODERATION_APPROVE_SELECTED: "Approve selected", + COMMENTS_MODERATION_DELETE_SELECTED: "Delete selected", + COMMENTS_MODERATION_EMPTY: "No {status} comments.", + + COMMENTS_MODERATION_COL_AUTHOR: "Author", + COMMENTS_MODERATION_COL_COMMENT: "Comment", + COMMENTS_MODERATION_COL_RESOURCE: "Resource", + COMMENTS_MODERATION_COL_DATE: "Date", + COMMENTS_MODERATION_COL_ACTIONS: "Actions", + COMMENTS_MODERATION_SELECT_ALL: "Select all", + COMMENTS_MODERATION_SELECT_ONE: "Select comment", + + COMMENTS_MODERATION_ACTION_VIEW: "View", + COMMENTS_MODERATION_ACTION_APPROVE: "Approve", + COMMENTS_MODERATION_ACTION_SPAM: "Mark as spam", + COMMENTS_MODERATION_ACTION_DELETE: "Delete", + + COMMENTS_MODERATION_TOAST_APPROVED: "Comment approved", + COMMENTS_MODERATION_TOAST_APPROVE_ERROR: "Failed to approve comment", + COMMENTS_MODERATION_TOAST_SPAM: "Marked as spam", + COMMENTS_MODERATION_TOAST_SPAM_ERROR: "Failed to update status", + COMMENTS_MODERATION_TOAST_DELETED: "Comment deleted", + COMMENTS_MODERATION_TOAST_DELETED_PLURAL: "{n} comments deleted", + COMMENTS_MODERATION_TOAST_DELETE_ERROR: "Failed to delete comment(s)", + COMMENTS_MODERATION_TOAST_BULK_APPROVED: "{n} comment(s) approved", + COMMENTS_MODERATION_TOAST_BULK_APPROVE_ERROR: "Failed to approve comments", + + COMMENTS_MODERATION_DIALOG_TITLE: "Comment Details", + COMMENTS_MODERATION_DIALOG_RESOURCE: "Resource", + COMMENTS_MODERATION_DIALOG_LIKES: "Likes", + COMMENTS_MODERATION_DIALOG_REPLY_TO: "Reply to", + COMMENTS_MODERATION_DIALOG_EDITED: "Edited", + COMMENTS_MODERATION_DIALOG_BODY: "Body", + COMMENTS_MODERATION_DIALOG_APPROVE: "Approve", + COMMENTS_MODERATION_DIALOG_MARK_SPAM: "Mark spam", + COMMENTS_MODERATION_DIALOG_DELETE: "Delete", + + COMMENTS_MODERATION_DELETE_TITLE_SINGULAR: "Delete comment?", + COMMENTS_MODERATION_DELETE_TITLE_PLURAL: "Delete {n} comments?", + COMMENTS_MODERATION_DELETE_DESCRIPTION_SINGULAR: + "This action cannot be undone. The comment will be permanently deleted.", + COMMENTS_MODERATION_DELETE_DESCRIPTION_PLURAL: + "This action cannot be undone. The comments will be permanently deleted.", + COMMENTS_MODERATION_DELETE_CANCEL: "Cancel", + COMMENTS_MODERATION_DELETE_CONFIRM: "Delete", + COMMENTS_MODERATION_DELETE_DELETING: "Deleting…", + + COMMENTS_RESOURCE_TITLE: "Comments", + COMMENTS_RESOURCE_PENDING_SECTION: "Pending Review", + COMMENTS_RESOURCE_THREAD_SECTION: "Thread", + COMMENTS_RESOURCE_ACTION_APPROVE: "Approve", + COMMENTS_RESOURCE_ACTION_SPAM: "Spam", + COMMENTS_RESOURCE_ACTION_DELETE: "Delete", + COMMENTS_RESOURCE_DELETE_CONFIRM: "Delete this comment?", + COMMENTS_RESOURCE_TOAST_APPROVED: "Comment approved", + COMMENTS_RESOURCE_TOAST_APPROVE_ERROR: "Failed to approve", + COMMENTS_RESOURCE_TOAST_SPAM: "Marked as spam", + COMMENTS_RESOURCE_TOAST_SPAM_ERROR: "Failed to update", + COMMENTS_RESOURCE_TOAST_DELETED: "Comment deleted", + COMMENTS_RESOURCE_TOAST_DELETE_ERROR: "Failed to delete", +}; diff --git a/packages/stack/src/plugins/comments/client/localization/comments-my.ts b/packages/stack/src/plugins/comments/client/localization/comments-my.ts new file mode 100644 index 00000000..c96c18a8 --- /dev/null +++ b/packages/stack/src/plugins/comments/client/localization/comments-my.ts @@ -0,0 +1,32 @@ +export const COMMENTS_MY = { + COMMENTS_MY_LOGIN_TITLE: "Please log in to view your comments", + COMMENTS_MY_LOGIN_DESCRIPTION: + "You need to be logged in to see your comment history.", + + COMMENTS_MY_EMPTY_TITLE: "No comments yet", + COMMENTS_MY_EMPTY_DESCRIPTION: "Comments you post will appear here.", + + COMMENTS_MY_PAGE_TITLE: "My Comments", + + COMMENTS_MY_COL_COMMENT: "Comment", + COMMENTS_MY_COL_RESOURCE: "Resource", + COMMENTS_MY_COL_STATUS: "Status", + COMMENTS_MY_COL_DATE: "Date", + + COMMENTS_MY_REPLY_INDICATOR: "↩ Reply", + COMMENTS_MY_VIEW_LINK: "View", + + COMMENTS_MY_STATUS_APPROVED: "Approved", + COMMENTS_MY_STATUS_PENDING: "Pending", + COMMENTS_MY_STATUS_SPAM: "Spam", + + COMMENTS_MY_TOAST_DELETED: "Comment deleted", + COMMENTS_MY_TOAST_DELETE_ERROR: "Failed to delete comment", + + COMMENTS_MY_DELETE_TITLE: "Delete comment?", + COMMENTS_MY_DELETE_DESCRIPTION: + "This action cannot be undone. The comment will be permanently removed.", + COMMENTS_MY_DELETE_CANCEL: "Cancel", + COMMENTS_MY_DELETE_CONFIRM: "Delete", + COMMENTS_MY_DELETE_BUTTON_SR: "Delete comment", +}; diff --git a/packages/stack/src/plugins/comments/client/localization/comments-thread.ts b/packages/stack/src/plugins/comments/client/localization/comments-thread.ts new file mode 100644 index 00000000..d53cbc44 --- /dev/null +++ b/packages/stack/src/plugins/comments/client/localization/comments-thread.ts @@ -0,0 +1,32 @@ +export const COMMENTS_THREAD = { + COMMENTS_TITLE: "Comments", + COMMENTS_EMPTY: "Be the first to comment.", + + COMMENTS_EDITED_BADGE: "(edited)", + COMMENTS_PENDING_BADGE: "Pending approval", + + COMMENTS_LIKE_ARIA: "Like", + COMMENTS_UNLIKE_ARIA: "Unlike", + COMMENTS_REPLY_BUTTON: "Reply", + COMMENTS_EDIT_BUTTON: "Edit", + COMMENTS_DELETE_BUTTON: "Delete", + COMMENTS_SAVE_EDIT: "Save", + + COMMENTS_REPLIES_SINGULAR: "reply", + COMMENTS_REPLIES_PLURAL: "replies", + COMMENTS_HIDE_REPLIES: "Hide replies", + COMMENTS_DELETE_CONFIRM: "Delete this comment?", + + COMMENTS_LOGIN_PROMPT: "Please sign in to leave a comment.", + COMMENTS_LOGIN_LINK: "Sign in", + + COMMENTS_FORM_PLACEHOLDER: "Write a comment…", + COMMENTS_FORM_CANCEL: "Cancel", + COMMENTS_FORM_POST_COMMENT: "Post comment", + COMMENTS_FORM_POST_REPLY: "Post reply", + COMMENTS_FORM_POSTING: "Posting…", + COMMENTS_FORM_SUBMIT_ERROR: "Failed to submit comment", + + COMMENTS_LOAD_MORE: "Load more comments", + COMMENTS_LOADING_MORE: "Loading…", +}; diff --git a/packages/stack/src/plugins/comments/client/localization/index.ts b/packages/stack/src/plugins/comments/client/localization/index.ts new file mode 100644 index 00000000..2d142ab9 --- /dev/null +++ b/packages/stack/src/plugins/comments/client/localization/index.ts @@ -0,0 +1,11 @@ +import { COMMENTS_THREAD } from "./comments-thread"; +import { COMMENTS_MODERATION } from "./comments-moderation"; +import { COMMENTS_MY } from "./comments-my"; + +export const COMMENTS_LOCALIZATION = { + ...COMMENTS_THREAD, + ...COMMENTS_MODERATION, + ...COMMENTS_MY, +}; + +export type CommentsLocalization = typeof COMMENTS_LOCALIZATION; diff --git a/packages/stack/src/plugins/comments/client/overrides.ts b/packages/stack/src/plugins/comments/client/overrides.ts new file mode 100644 index 00000000..843efa2e --- /dev/null +++ b/packages/stack/src/plugins/comments/client/overrides.ts @@ -0,0 +1,137 @@ +/** + * Context passed to lifecycle hooks + */ +export interface RouteContext { + /** Current route path */ + path: string; + /** Route parameters (e.g., { resourceId: "my-post", resourceType: "blog-post" }) */ + params?: Record; + /** Whether rendering on server (true) or client (false) */ + isSSR: boolean; + /** Additional context properties */ + [key: string]: unknown; +} + +import type { CommentsLocalization } from "./localization"; + +/** + * Overridable configuration and hooks for the Comments plugin. + * + * Provide these in the layout wrapping your pages via `PluginOverridesProvider`. + */ +export interface CommentsPluginOverrides { + /** + * Localization strings for all Comments plugin UI. + * Defaults to English when not provided. + */ + localization?: Partial; + /** + * Base URL for API calls (e.g., "https://example.com") + */ + apiBaseURL: string; + + /** + * Path where the API is mounted (e.g., "/api/data") + */ + apiBasePath: string; + + /** + * Optional headers for authenticated API calls (e.g., forwarding cookies) + */ + headers?: Record; + + /** + * Whether to show the "Powered by BTST" attribution on plugin pages. + * Defaults to true. + */ + showAttribution?: boolean; + + /** + * The ID of the currently authenticated user. + * + * Used by the My Comments page to scope the comment list to the current user. + * Can be a static string or an async function (useful when the user ID must + * be resolved from a session cookie at render time). + * + * When absent the My Comments page shows a "Please log in" prompt. + */ + currentUserId?: + | string + | (() => string | undefined | Promise); + + /** + * Default number of top-level comments to load per page in `CommentThread`. + * Can be overridden per-instance via the `pageSize` prop. + * Defaults to 100 when not set. + */ + defaultCommentPageSize?: number; + + /** + * Per-resource-type URL builders used to link each comment back to its + * original resource on the My Comments page. + * + * @example + * ```ts + * resourceLinks: { + * "blog-post": (slug) => `/pages/blog/${slug}`, + * "kanban-task": (id) => `/pages/kanban?task=${id}`, + * } + * ``` + * + * When a resource type has no entry the ID is shown as plain text. + */ + resourceLinks?: Record string>; + + // ============ Access Control Hooks ============ + + /** + * Called before the moderation dashboard page is rendered. + * Return false to block rendering (e.g., redirect to login or show 403). + * @param context - Route context + */ + onBeforeModerationPageRendered?: (context: RouteContext) => boolean; + + /** + * Called before the per-resource comments page is rendered. + * Return false to block rendering (e.g., for authorization). + * @param resourceType - The type of resource (e.g., "blog-post") + * @param resourceId - The ID of the resource + * @param context - Route context + */ + onBeforeResourceCommentsRendered?: ( + resourceType: string, + resourceId: string, + context: RouteContext, + ) => boolean; + + /** + * Called before the My Comments page is rendered. + * Throw to block rendering (e.g., when the user is not authenticated). + * @param context - Route context + */ + onBeforeMyCommentsPageRendered?: (context: RouteContext) => boolean | void; + + // ============ Lifecycle Hooks ============ + + /** + * Called when a route is rendered. + * @param routeName - Name of the route (e.g., 'moderation', 'resourceComments') + * @param context - Route context + */ + onRouteRender?: ( + routeName: string, + context: RouteContext, + ) => void | Promise; + + /** + * Called when a route encounters an error. + * @param routeName - Name of the route + * @param error - The error that occurred + * @param context - Route context + */ + onRouteError?: ( + routeName: string, + error: Error, + context: RouteContext, + ) => void | Promise; +} diff --git a/packages/stack/src/plugins/comments/client/plugin.tsx b/packages/stack/src/plugins/comments/client/plugin.tsx new file mode 100644 index 00000000..f1d2d7db --- /dev/null +++ b/packages/stack/src/plugins/comments/client/plugin.tsx @@ -0,0 +1,162 @@ +// NO "use client" here! This file runs on both server and client. +import { lazy } from "react"; +import { + defineClientPlugin, + isConnectionError, +} from "@btst/stack/plugins/client"; +import { createRoute } from "@btst/yar"; +import type { QueryClient } from "@tanstack/react-query"; + +// Lazy load page components for code splitting +const ModerationPageComponent = lazy(() => + import("./components/pages/moderation-page").then((m) => ({ + default: m.ModerationPageComponent, + })), +); + +const MyCommentsPageComponent = lazy(() => + import("./components/pages/my-comments-page").then((m) => ({ + default: m.MyCommentsPageComponent, + })), +); + +/** + * Context passed to loader hooks + */ +export interface LoaderContext { + /** Current route path */ + path: string; + /** Route parameters */ + params?: Record; + /** Whether rendering on server (true) or client (false) */ + isSSR: boolean; + /** Base URL for API calls */ + apiBaseURL: string; + /** Path where the API is mounted */ + apiBasePath: string; + /** Optional headers for the request */ + headers?: Headers; + /** Additional context properties */ + [key: string]: unknown; +} + +/** + * Hooks for Comments client plugin + */ +export interface CommentsClientHooks { + /** + * Called before loading the moderation page. Throw to cancel. + */ + beforeLoadModeration?: (context: LoaderContext) => Promise | void; + /** + * Called before loading the My Comments page. Throw to cancel. + */ + beforeLoadMyComments?: (context: LoaderContext) => Promise | void; + /** + * Called when a loading error occurs. + */ + onLoadError?: (error: Error, context: LoaderContext) => Promise | void; +} + +/** + * Configuration for the Comments client plugin + */ +export interface CommentsClientConfig { + /** Base URL for API calls (e.g., "http://localhost:3000") */ + apiBaseURL: string; + /** Path where the API is mounted (e.g., "/api/data") */ + apiBasePath: string; + /** Base URL of your site */ + siteBaseURL: string; + /** Path where pages are mounted (e.g., "/pages") */ + siteBasePath: string; + /** React Query client instance */ + queryClient: QueryClient; + /** Optional headers for SSR */ + headers?: Headers; + /** Optional lifecycle hooks */ + hooks?: CommentsClientHooks; +} + +function createModerationLoader(config: CommentsClientConfig) { + return async () => { + if (typeof window === "undefined") { + const { apiBasePath, apiBaseURL, headers, hooks } = config; + const context: LoaderContext = { + path: "/comments/moderation", + isSSR: true, + apiBaseURL, + apiBasePath, + headers, + }; + try { + if (hooks?.beforeLoadModeration) { + await hooks.beforeLoadModeration(context); + } + } catch (error) { + if (isConnectionError(error)) { + console.warn( + "[btst/comments] route.loader() failed — no server running at build time.", + ); + } + if (hooks?.onLoadError) { + await hooks.onLoadError(error as Error, context); + } + } + } + }; +} + +function createMyCommentsLoader(config: CommentsClientConfig) { + return async () => { + if (typeof window === "undefined") { + const { apiBasePath, apiBaseURL, headers, hooks } = config; + const context: LoaderContext = { + path: "/comments/my-comments", + isSSR: true, + apiBaseURL, + apiBasePath, + headers, + }; + try { + if (hooks?.beforeLoadMyComments) { + await hooks.beforeLoadMyComments(context); + } + } catch (error) { + if (isConnectionError(error)) { + console.warn( + "[btst/comments] route.loader() failed — no server running at build time.", + ); + } + if (hooks?.onLoadError) { + await hooks.onLoadError(error as Error, context); + } + } + } + }; +} + +/** + * Comments client plugin — registers admin moderation routes. + * + * The embeddable `CommentThread` and `CommentCount` components are standalone + * and do not require this plugin to be registered. Register them manually + * via the layout overrides pattern or use them directly in your pages. + */ +export const commentsClientPlugin = (config: CommentsClientConfig) => + defineClientPlugin({ + name: "comments", + + routes: () => ({ + moderation: createRoute("/comments/moderation", () => ({ + PageComponent: ModerationPageComponent, + loader: createModerationLoader(config), + meta: () => [{ title: "Comment Moderation" }], + })), + myComments: createRoute("/comments/my-comments", () => ({ + PageComponent: MyCommentsPageComponent, + loader: createMyCommentsLoader(config), + meta: () => [{ title: "My Comments" }], + })), + }), + }); diff --git a/packages/stack/src/plugins/comments/db.ts b/packages/stack/src/plugins/comments/db.ts new file mode 100644 index 00000000..10563800 --- /dev/null +++ b/packages/stack/src/plugins/comments/db.ts @@ -0,0 +1,77 @@ +import { createDbPlugin } from "@btst/db"; + +/** + * Comments plugin schema. + * Defines two tables: + * - comment: the main comment record (always authenticated, no anonymous) + * - commentLike: join table for per-user like deduplication + */ +export const commentsSchema = createDbPlugin("comments", { + comment: { + modelName: "comment", + fields: { + resourceId: { + type: "string", + required: true, + }, + resourceType: { + type: "string", + required: true, + }, + parentId: { + type: "string", + required: false, + }, + authorId: { + type: "string", + required: true, + }, + body: { + type: "string", + required: true, + }, + status: { + type: "string", + defaultValue: "pending", + }, + likes: { + type: "number", + defaultValue: 0, + }, + editedAt: { + type: "date", + required: false, + }, + createdAt: { + type: "date", + defaultValue: () => new Date(), + }, + updatedAt: { + type: "date", + defaultValue: () => new Date(), + }, + }, + }, + commentLike: { + modelName: "commentLike", + fields: { + commentId: { + type: "string", + required: true, + references: { + model: "comment", + field: "id", + onDelete: "cascade", + }, + }, + authorId: { + type: "string", + required: true, + }, + createdAt: { + type: "date", + defaultValue: () => new Date(), + }, + }, + }, +}); diff --git a/packages/stack/src/plugins/comments/query-keys.ts b/packages/stack/src/plugins/comments/query-keys.ts new file mode 100644 index 00000000..3af32569 --- /dev/null +++ b/packages/stack/src/plugins/comments/query-keys.ts @@ -0,0 +1,205 @@ +import { + mergeQueryKeys, + createQueryKeys, +} from "@lukemorales/query-key-factory"; +import type { CommentsApiRouter } from "./api"; +import { createApiClient } from "@btst/stack/plugins/client"; +import type { SerializedComment, CommentListResult } from "./types"; +import { + commentsListDiscriminator, + commentCountDiscriminator, + commentsThreadDiscriminator, +} from "./api/query-key-defs"; + +interface CommentsListParams { + resourceId?: string; + resourceType?: string; + parentId?: string | null; + status?: "pending" | "approved" | "spam"; + currentUserId?: string; + authorId?: string; + sort?: "asc" | "desc"; + limit?: number; + offset?: number; +} + +interface CommentCountParams { + resourceId: string; + resourceType: string; + status?: "pending" | "approved" | "spam"; +} + +function isErrorResponse( + response: unknown, +): response is { error: unknown; data?: never } { + return ( + typeof response === "object" && + response !== null && + "error" in response && + (response as Record).error !== null && + (response as Record).error !== undefined + ); +} + +function toError(error: unknown): Error { + if (error instanceof Error) return error; + if (typeof error === "object" && error !== null) { + const obj = error as Record; + const message = + (typeof obj.message === "string" ? obj.message : null) || + (typeof obj.error === "string" ? obj.error : null) || + JSON.stringify(error); + const err = new Error(message); + Object.assign(err, error); + return err; + } + return new Error(String(error)); +} + +export function createCommentsQueryKeys( + client: ReturnType>, + headers?: HeadersInit, +) { + return mergeQueryKeys( + createCommentsQueries(client, headers), + createCommentCountQueries(client, headers), + createCommentsThreadQueries(client, headers), + ); +} + +function createCommentsQueries( + client: ReturnType>, + headers?: HeadersInit, +) { + return createQueryKeys("comments", { + list: (params?: CommentsListParams) => ({ + queryKey: [commentsListDiscriminator(params)], + queryFn: async (): Promise => { + const response = await client("/comments", { + method: "GET", + query: { + resourceId: params?.resourceId, + resourceType: params?.resourceType, + parentId: params?.parentId === null ? "null" : params?.parentId, + status: params?.status, + currentUserId: params?.currentUserId, + authorId: params?.authorId, + sort: params?.sort, + limit: params?.limit ?? 20, + offset: params?.offset ?? 0, + }, + headers, + }); + + if (isErrorResponse(response)) { + throw toError((response as { error: unknown }).error); + } + + const data = (response as { data?: unknown }).data as + | CommentListResult + | undefined; + return data ?? { items: [], total: 0, limit: 20, offset: 0 }; + }, + }), + + detail: (id: string) => ({ + queryKey: [id], + queryFn: async (): Promise => { + if (!id) return null; + // Single comment fetch — reuse list with implicit filtering + // (the backend does not expose GET /comments/:id publicly, use list) + return null; + }, + }), + }); +} + +function createCommentCountQueries( + client: ReturnType>, + headers?: HeadersInit, +) { + return createQueryKeys("commentCount", { + byResource: (params: CommentCountParams) => ({ + queryKey: [commentCountDiscriminator(params)], + queryFn: async (): Promise => { + const response = await client("/comments/count", { + method: "GET", + query: { + resourceId: params.resourceId, + resourceType: params.resourceType, + status: params.status, + }, + headers, + }); + + if (isErrorResponse(response)) { + throw toError((response as { error: unknown }).error); + } + + const data = (response as { data?: unknown }).data as + | { count: number } + | undefined; + return data?.count ?? 0; + }, + }), + }); +} + +/** + * Factory for the infinite thread query key family. + * Mirrors the blog's `createPostsQueries` pattern: the key is stable (no offset), + * and pages are fetched via `pageParam` passed to the queryFn. + */ +function createCommentsThreadQueries( + client: ReturnType>, + headers?: HeadersInit, +) { + return createQueryKeys("commentsThread", { + list: (params?: { + resourceId?: string; + resourceType?: string; + parentId?: string | null; + status?: "pending" | "approved" | "spam"; + currentUserId?: string; + limit?: number; + }) => ({ + // Offset is excluded from the key — it is driven by pageParam. + queryKey: [commentsThreadDiscriminator(params)], + queryFn: async ({ + pageParam, + }: { + pageParam?: number; + } = {}): Promise => { + const response = await client("/comments", { + method: "GET", + query: { + resourceId: params?.resourceId, + resourceType: params?.resourceType, + parentId: params?.parentId === null ? "null" : params?.parentId, + status: params?.status, + currentUserId: params?.currentUserId, + limit: params?.limit ?? 20, + offset: pageParam ?? 0, + }, + headers, + }); + + if (isErrorResponse(response)) { + throw toError((response as { error: unknown }).error); + } + + const data = (response as { data?: unknown }).data as + | CommentListResult + | undefined; + return ( + data ?? { + items: [], + total: 0, + limit: params?.limit ?? 20, + offset: pageParam ?? 0, + } + ); + }, + }), + }); +} diff --git a/packages/stack/src/plugins/comments/schemas.ts b/packages/stack/src/plugins/comments/schemas.ts new file mode 100644 index 00000000..c0b3973c --- /dev/null +++ b/packages/stack/src/plugins/comments/schemas.ts @@ -0,0 +1,54 @@ +import { z } from "zod"; + +export const CommentStatusSchema = z.enum(["pending", "approved", "spam"]); + +// ============ Comment Schemas ============ + +/** + * Schema for the POST /comments request body. + * authorId is intentionally absent — the server resolves identity from the + * session inside onBeforePost and injects it. Never trust authorId from the + * client body. + */ +export const createCommentSchema = z.object({ + resourceId: z.string().min(1, "Resource ID is required"), + resourceType: z.string().min(1, "Resource type is required"), + parentId: z.string().optional().nullable(), + body: z.string().min(1, "Body is required").max(10000, "Comment too long"), +}); + +/** + * Internal schema used after the authorId has been resolved server-side. + * This is what gets passed to createComment() in mutations.ts. + */ +export const createCommentInternalSchema = createCommentSchema.extend({ + authorId: z.string().min(1, "Author ID is required"), +}); + +export const updateCommentSchema = z.object({ + body: z.string().min(1, "Body is required").max(10000, "Comment too long"), +}); + +export const updateCommentStatusSchema = z.object({ + status: CommentStatusSchema, +}); + +// ============ Query Schemas ============ + +export const CommentListQuerySchema = z.object({ + resourceId: z.string().optional(), + resourceType: z.string().optional(), + parentId: z.string().optional().nullable(), + status: CommentStatusSchema.optional(), + currentUserId: z.string().optional(), + authorId: z.string().optional(), + sort: z.enum(["asc", "desc"]).optional(), + limit: z.coerce.number().int().min(1).max(100).optional(), + offset: z.coerce.number().int().min(0).optional(), +}); + +export const CommentCountQuerySchema = z.object({ + resourceId: z.string().min(1), + resourceType: z.string().min(1), + status: CommentStatusSchema.optional(), +}); diff --git a/packages/stack/src/plugins/comments/style.css b/packages/stack/src/plugins/comments/style.css new file mode 100644 index 00000000..aca480b1 --- /dev/null +++ b/packages/stack/src/plugins/comments/style.css @@ -0,0 +1,15 @@ +@import "./client.css"; + +/* + * Comments Plugin CSS - Includes Tailwind class scanning + * + * When consumed from npm, Tailwind v4 will automatically scan this package's + * source files for Tailwind classes. Consumers only need: + * @import "@btst/stack/plugins/comments/css"; + */ + +/* Scan this package's source files for Tailwind classes */ +@source "../../../src/**/*.{ts,tsx}"; + +/* Scan UI package components (when installed as npm package the UI package will be in this dir) */ +@source "../../packages/ui/src"; diff --git a/packages/stack/src/plugins/comments/types.ts b/packages/stack/src/plugins/comments/types.ts new file mode 100644 index 00000000..006a86e4 --- /dev/null +++ b/packages/stack/src/plugins/comments/types.ts @@ -0,0 +1,73 @@ +/** + * Comment status values + */ +export type CommentStatus = "pending" | "approved" | "spam"; + +/** + * A comment record as stored in the database + */ +export type Comment = { + id: string; + resourceId: string; + resourceType: string; + parentId: string | null; + authorId: string; + body: string; + status: CommentStatus; + likes: number; + editedAt?: Date; + createdAt: Date; + updatedAt: Date; +}; + +/** + * A like record linking an author to a comment + */ +export type CommentLike = { + id: string; + commentId: string; + authorId: string; + createdAt: Date; +}; + +/** + * A comment enriched with server-resolved author info and like status. + * All dates are ISO strings (safe for serialisation over HTTP / React Query cache). + */ +export interface SerializedComment { + id: string; + resourceId: string; + resourceType: string; + parentId: string | null; + authorId: string; + /** Resolved from resolveUser(authorId). Falls back to "[deleted]" when user cannot be found. */ + resolvedAuthorName: string; + /** Resolved avatar URL or null */ + resolvedAvatarUrl: string | null; + body: string; + status: CommentStatus; + /** Denormalized counter — updated atomically on toggleLike */ + likes: number; + /** True when the currentUserId query param matches an existing commentLike row */ + isLikedByCurrentUser: boolean; + /** ISO string set when the comment body was edited; null for unedited comments */ + editedAt: string | null; + createdAt: string; + updatedAt: string; + /** + * Number of direct replies visible to the requesting user. + * Includes approved replies plus any pending replies authored by `currentUserId`. + * Always 0 for reply comments (non-null parentId). + */ + replyCount: number; +} + +/** + * Paginated list result for comments + */ +export interface CommentListResult { + items: SerializedComment[]; + total: number; + limit: number; + offset: number; +} diff --git a/packages/stack/src/plugins/kanban/client/components/pages/board-page.internal.tsx b/packages/stack/src/plugins/kanban/client/components/pages/board-page.internal.tsx index f5a0c180..26b25831 100644 --- a/packages/stack/src/plugins/kanban/client/components/pages/board-page.internal.tsx +++ b/packages/stack/src/plugins/kanban/client/components/pages/board-page.internal.tsx @@ -66,8 +66,11 @@ export function BoardPage({ boardId }: BoardPageProps) { throw error; } - const { Link: OverrideLink, navigate: overrideNavigate } = - usePluginOverrides("kanban"); + const { + Link: OverrideLink, + navigate: overrideNavigate, + taskDetailBottomSlot, + } = usePluginOverrides("kanban"); const navigate = overrideNavigate || ((path: string) => { @@ -540,33 +543,49 @@ export function BoardPage({ boardId }: BoardPageProps) { Update task details. {modalState.type === "editTask" && ( - c.id === modalState.columnId) - ?.tasks?.find((t) => t.id === modalState.taskId)} - columns={board.columns || []} - onClose={closeModal} - onSuccess={() => { - closeModal(); - refetch(); - }} - onDelete={async () => { - try { - await deleteTask(modalState.taskId); + <> + c.id === modalState.columnId) + ?.tasks?.find((t) => t.id === modalState.taskId)} + columns={board.columns || []} + onClose={closeModal} + onSuccess={() => { closeModal(); refetch(); - } catch (error) { - const message = - error instanceof Error - ? error.message - : "Failed to delete task"; - toast.error(message); - } - }} - /> + }} + onDelete={async () => { + try { + await deleteTask(modalState.taskId); + closeModal(); + refetch(); + } catch (error) { + const message = + error instanceof Error + ? error.message + : "Failed to delete task"; + toast.error(message); + } + }} + /> + {taskDetailBottomSlot && + (() => { + const task = board.columns + ?.find((c) => c.id === modalState.columnId) + ?.tasks?.find((t) => t.id === modalState.taskId); + return task ? ( + + {taskDetailBottomSlot(task)} + + ) : null; + })()} + > )} diff --git a/packages/stack/src/plugins/kanban/client/overrides.ts b/packages/stack/src/plugins/kanban/client/overrides.ts index 5f6d1200..9c90cc33 100644 --- a/packages/stack/src/plugins/kanban/client/overrides.ts +++ b/packages/stack/src/plugins/kanban/client/overrides.ts @@ -1,5 +1,6 @@ -import type { ComponentType } from "react"; +import type { ComponentType, ReactNode } from "react"; import type { KanbanLocalization } from "./localization"; +import type { SerializedTask } from "../types"; /** * User information for assignee display/selection @@ -142,4 +143,29 @@ export interface KanbanPluginOverrides { * @param context - Route context */ onBeforeNewBoardPageRendered?: (context: RouteContext) => boolean; + + // ============ Slot Overrides ============ + + /** + * Optional slot rendered at the bottom of the task detail dialog. + * Use this to inject a comment thread or any custom content without + * coupling the kanban plugin to the comments plugin. + * + * @example + * ```tsx + * kanban: { + * taskDetailBottomSlot: (task) => ( + * + * ), + * } + * ``` + */ + taskDetailBottomSlot?: (task: SerializedTask) => ReactNode; } diff --git a/packages/ui/src/components/pagination-controls.tsx b/packages/ui/src/components/pagination-controls.tsx new file mode 100644 index 00000000..1fa777f5 --- /dev/null +++ b/packages/ui/src/components/pagination-controls.tsx @@ -0,0 +1,80 @@ +"use client"; + +import { Button } from "@workspace/ui/components/button"; +import { ChevronLeft, ChevronRight } from "lucide-react"; + +export interface PaginationControlsProps { + /** Current page, 1-based */ + currentPage: number; + totalPages: number; + total: number; + limit: number; + offset: number; + onPageChange: (page: number) => void; + labels?: { + previous?: string; + next?: string; + /** Template string; use {from}, {to}, {total} as placeholders */ + showing?: string; + }; +} + +/** + * Generic Prev/Next pagination control with a "Showing X–Y of Z" label. + * Plugin-agnostic — pass localized labels as props. + * Returns null when totalPages ≤ 1. + */ +export function PaginationControls({ + currentPage, + totalPages, + total, + limit, + offset, + onPageChange, + labels, +}: PaginationControlsProps) { + const previous = labels?.previous ?? "Previous"; + const next = labels?.next ?? "Next"; + const showingTemplate = labels?.showing ?? "Showing {from}–{to} of {total}"; + + const from = offset + 1; + const to = Math.min(offset + limit, total); + + const showingText = showingTemplate + .replace("{from}", String(from)) + .replace("{to}", String(to)) + .replace("{total}", String(total)); + + if (totalPages <= 1) { + return null; + } + + return ( + + {showingText} + + onPageChange(currentPage - 1)} + disabled={currentPage === 1} + > + + {previous} + + + {currentPage} / {totalPages} + + onPageChange(currentPage + 1)} + disabled={currentPage === totalPages} + > + {next} + + + + + ); +} diff --git a/packages/ui/src/components/when-visible.tsx b/packages/ui/src/components/when-visible.tsx new file mode 100644 index 00000000..16777c8b --- /dev/null +++ b/packages/ui/src/components/when-visible.tsx @@ -0,0 +1,65 @@ +"use client"; + +import { useEffect, useRef, useState, type ReactNode } from "react"; + +export interface WhenVisibleProps { + /** Content to render once the element scrolls into view */ + children: ReactNode; + /** Optional placeholder rendered before the element enters the viewport */ + fallback?: ReactNode; + /** IntersectionObserver threshold (0–1). Defaults to 0 (any pixel visible). */ + threshold?: number; + /** Root margin passed to IntersectionObserver. Defaults to "200px" to preload slightly early. */ + rootMargin?: string; + /** Additional className applied to the sentinel wrapper div */ + className?: string; +} + +/** + * Lazy-mounts children only when the sentinel element scrolls into the viewport. + * Once mounted, children remain mounted even if the element scrolls out of view. + * + * Use this to defer expensive renders (comment threads, carousels, etc.) until + * the user actually scrolls to that section. + */ +export function WhenVisible({ + children, + fallback = null, + threshold = 0, + rootMargin = "200px", + className, +}: WhenVisibleProps) { + const [isVisible, setIsVisible] = useState(false); + const sentinelRef = useRef(null); + + useEffect(() => { + const el = sentinelRef.current; + if (!el) return; + + // If IntersectionObserver is not available (SSR/old browsers), show immediately + if (typeof IntersectionObserver === "undefined") { + setIsVisible(true); + return; + } + + const observer = new IntersectionObserver( + (entries) => { + const entry = entries[0]; + if (entry?.isIntersecting) { + setIsVisible(true); + observer.disconnect(); + } + }, + { threshold, rootMargin }, + ); + + observer.observe(el); + return () => observer.disconnect(); + }, [threshold, rootMargin]); + + return ( + + {isVisible ? children : fallback} + + ); +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 44f4c2ee..c22c0ac8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -587,9 +587,6 @@ importers: react-hook-form: specifier: '>=7.55.0' version: 7.66.1(react@19.2.0) - react-intersection-observer: - specifier: '>=9.0.0' - version: 10.0.0(react-dom@19.2.0(react@19.2.0))(react@19.2.0) react-markdown: specifier: '>=9.1.0' version: 9.1.0(@types/react@19.2.6)(react@19.2.0) @@ -8301,15 +8298,6 @@ packages: peerDependencies: react: ^19.2.0 - react-intersection-observer@10.0.0: - resolution: {integrity: sha512-JJRgcnFQoVXmbE5+GXr1OS1NDD1gHk0HyfpLcRf0575IbJz+io8yzs4mWVlfaqOQq1FiVjLvuYAdEEcrrCfveg==} - peerDependencies: - react: ^19.2.0 - react-dom: ^17.0.0 || ^18.0.0 || ^19.0.0 - peerDependenciesMeta: - react-dom: - optional: true - react-is@16.13.1: resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} @@ -19305,12 +19293,6 @@ snapshots: dependencies: react: 19.2.0 - react-intersection-observer@10.0.0(react-dom@19.2.0(react@19.2.0))(react@19.2.0): - dependencies: - react: 19.2.0 - optionalDependencies: - react-dom: 19.2.0(react@19.2.0) - react-is@16.13.1: {} react-markdown@9.1.0(@types/react@19.2.6)(react@19.2.0):
`) | + +### Blog Post Integration + +The blog plugin exposes a `postBottomSlot` override that renders below every post: + +```tsx +import { CommentThread } from "@btst/stack/plugins/comments/client/components" + +overrides={{ + blog: { + postBottomSlot: (post) => ( + + ), + } +}} +``` + +### Kanban Task Integration + +The Kanban plugin exposes a `taskDetailBottomSlot` override that renders at the bottom of the task detail dialog: + +```tsx +import { CommentThread } from "@btst/stack/plugins/comments/client/components" + +overrides={{ + kanban: { + taskDetailBottomSlot: (task) => ( + + ), + } +}} +``` + +### Comment Count Badge + +Use `CommentCount` to show the number of approved comments anywhere (e.g., in a post listing): + +```tsx +import { CommentCount } from "@btst/stack/plugins/comments/client/components" + + +``` + +## Moderation Dashboard + +The comments plugin adds a `/comments/moderation` admin route with: + +- **Tabbed views** — Pending, Approved, Spam +- **Bulk actions** — Approve, Mark as spam, Delete +- **Comment detail dialog** — View full body and metadata +- **Per-row actions** — Approve, spam, delete from the table row + +Access is controlled by the `onBeforeModerationPageRendered` hook in `CommentsPluginOverrides`. + +## Backend Configuration + +### `commentsBackendPlugin` Options + +| Option | Type | Default | Description | +|--------|------|---------|-------------| +| `autoApprove` | `boolean` | `false` | Automatically approve new comments | +| `resolveUser` | `(authorId: string) => Promise<{ name: string; avatarUrl?: string } \| null>` | — | Map author IDs to display info; returns `null` → shows `"[deleted]"` | +| `onBeforeList` | hook | — | Called before the comment list or count is returned. Throw to reject. When absent, any `status` filter other than `"approved"` is automatically rejected with 403 on both `GET /comments` and `GET /comments/count` — preventing anonymous access to, or probing of, the moderation queues. | +| `onBeforePost` | hook | **required** | Called before a comment is saved. Must return `{ authorId: string }` derived from the authenticated session. Throw to reject. Plugin throws at startup if absent. | +| `onAfterPost` | hook | — | Called after a comment is saved. | +| `onBeforeEdit` | hook | — | Called before a comment body is updated. Throw to reject. When absent, **all edit requests return 403** — preventing any unauthenticated caller from tampering with comment bodies. Configure to verify the caller owns the comment. | +| `onAfterEdit` | hook | — | Called after a comment body is updated. | +| `onBeforeLike` | hook | — | Called before a like is toggled. Throw to reject. When absent, **all like/unlike requests return 403** — preventing unauthenticated callers from toggling likes on behalf of arbitrary user IDs. Configure to verify `authorId` matches the authenticated session. | +| `onBeforeStatusChange` | hook | — | Called before moderation status is changed. Throw to reject. When absent, **all status-change requests return 403** — preventing unauthenticated callers from moderating comments. Configure to verify the caller has admin/moderator privileges. | +| `onAfterApprove` | hook | — | Called after a comment is approved. | +| `onBeforeDelete` | hook | — | Called before a comment is deleted. Throw to reject. When absent, **all delete requests return 403** — preventing unauthenticated callers from deleting comments. Configure to enforce admin-only access. | +| `onAfterDelete` | hook | — | Called after a comment is deleted. | +| `onBeforeListByAuthor` | hook | — | Called before returning comments filtered by `authorId`. Throw to reject. When absent, **any request with `authorId` returns 403** — preventing anonymous callers from reading any user's comment history. Use to verify `authorId` matches the authenticated session. | +| `resolveCurrentUserId` | hook | **required** | Resolve the current authenticated user's ID from the session. Used to safely include the user's own pending comments alongside approved ones in `GET /comments`. The client-supplied `currentUserId` query parameter is never trusted — identity is resolved exclusively via this hook. Return `null`/`undefined` for unauthenticated requests. Plugin throws at startup if absent. | + + +**`onBeforePost` and `resolveCurrentUserId` are both required.** `commentsBackendPlugin` throws at startup if either is absent. + +- `onBeforePost` must return `{ authorId: string }` derived from the session — `authorId` is intentionally absent from the POST body so clients can never forge authorship. +- `resolveCurrentUserId` must return the session-verified user ID (or `null` when unauthenticated) — the `?currentUserId=…` query parameter sent by the client is completely discarded. + + +### Server-Side API (`stack.api.comments`) + +Direct database access without HTTP, useful in Server Components, cron jobs, or AI tools: + +```ts +const items = await myStack.api.comments.listComments({ + resourceId: "my-post", + resourceType: "blog-post", + status: "approved", +}) + +const count = await myStack.api.comments.getCommentCount({ + resourceId: "my-post", + resourceType: "blog-post", +}) +``` + + +`stack().api.*` calls bypass authorization hooks. Callers are responsible for access control. + + +## React Hooks + +Import hooks from `@btst/stack/plugins/comments/client/hooks`: + +```tsx +import { + useComments, + useCommentCount, + usePostComment, + useUpdateComment, + useDeleteComment, + useToggleLike, + useUpdateCommentStatus, +} from "@btst/stack/plugins/comments/client/hooks" + +// Fetch approved comments for a resource +const { data, isLoading } = useComments({ + resourceId: "my-post", + resourceType: "blog-post", + status: "approved", +}) + +// Post a new comment (includes optimistic update) +const { mutate: postComment } = usePostComment() +postComment({ + resourceId: "my-post", + resourceType: "blog-post", + authorId: "user-123", + body: "Great post!", +}) + +// Toggle like (one per user; optimistic update) +const { mutate: toggleLike } = useToggleLike() +toggleLike({ commentId: "comment-id", authorId: "user-123" }) + +// Moderate a comment +const { mutate: updateStatus } = useUpdateCommentStatus() +updateStatus({ id: "comment-id", status: "approved" }) +``` + +## My Comments Page + +The comments plugin registers a `/comments/my-comments` route that shows the current user's full comment history — all statuses (approved, pending, spam) in a single paginated table, newest first. + +**Features:** +- All comment statuses visible to the owner in one list, each with an inline status badge +- Prev / Next pagination (20 per page) +- Resource link column — click through to the original resource when `resourceLinks` is configured (links automatically include `#comments` so the page scrolls to the comment thread) +- Delete button with confirmation dialog — calls `DELETE /comments/:id` (governed by `onBeforeDelete`) +- Login prompt when `currentUserId` is not configured + +### Setup + +Configure the overrides in your layout and the security hook in your backend: + +```tsx title="app/pages/layout.tsx" +overrides={{ + comments: { + apiBaseURL: baseURL, + apiBasePath: "/api/data", + + // Provide the current user's ID so the page can scope the query + currentUserId: session?.user?.id, + + // Map resource types to URLs so comments link back to their resource + resourceLinks: { + "blog-post": (slug) => `/pages/blog/${slug}`, + "kanban-task": (id) => `/pages/kanban?task=${id}`, + }, + + onBeforeMyCommentsPageRendered: (context) => { + if (!session?.user) throw new Error("Authentication required") + }, + } +}} +``` + +```ts title="lib/stack.ts" +commentsBackendPlugin({ + // ... + onBeforeListByAuthor: async (authorId, _query, ctx) => { + const session = await getSession(ctx.headers) + if (!session?.user) throw new Error("Authentication required") + if (authorId !== session.user.id && !session.user.isAdmin) + throw new Error("Forbidden") + }, +}) +``` + + +**`onBeforeListByAuthor` is 403 by default.** Any `GET /comments?authorId=...` request returns 403 unless `onBeforeListByAuthor` is configured. This prevents anonymous callers from reading any user's comment history. Always validate that `authorId` matches the authenticated session. + + +## API Reference + +### Client Plugin Overrides + +Configure the comments plugin behavior from your layout: + +#### `CommentsPluginOverrides` + +| Field | Type | Description | +|-------|------|-------------| +| `apiBaseURL` | `string` | Base URL for API requests | +| `apiBasePath` | `string` | Path prefix for the API | +| `currentUserId` | `string \| (() => string \| undefined \| Promise)` | Authenticated user's ID — used by the My Comments page. Supports async functions for session-based resolution. | +| `defaultCommentPageSize` | `number` | Default number of top-level comments per page for all `CommentThread` instances. Overridden per-instance by the `pageSize` prop. Defaults to `100` when not set. | +| `resourceLinks` | `Record string>` | Per-resource-type URL builders for linking comments back to their resource on the My Comments page (e.g. `{ "blog-post": (slug) => "/pages/blog/" + slug }`). The plugin appends `#comments` automatically so the page scrolls to the thread. | +| `localization` | `Partial` | Override any UI string in the plugin. Import `COMMENTS_LOCALIZATION` from `@btst/stack/plugins/comments/client` to see all available keys. | +| `onBeforeModerationPageRendered` | hook | Called before rendering the moderation dashboard. Throw to deny access. | +| `onBeforeResourceCommentsRendered` | hook | Called before rendering the per-resource comments admin view. Throw to deny access. | +| `onBeforeMyCommentsPageRendered` | hook | Called before rendering the My Comments page. Throw to deny access (e.g. when no session exists). | + +### HTTP Endpoints + +| Method | Path | Description | +|--------|------|-------------| +| `GET` | `/comments` | List comments for a resource | +| `POST` | `/comments` | Create a new comment | +| `PATCH` | `/comments/:id` | Edit a comment body | +| `GET` | `/comments/count` | Get approved comment count | +| `POST` | `/comments/:id/like` | Toggle like on a comment | +| `PATCH` | `/comments/:id/status` | Update moderation status | +| `DELETE` | `/comments/:id` | Delete a comment | + +### `SerializedComment` + +Comments returned by the API include resolved author information: + +| Field | Type | Description | +|-------|------|-------------| +| `id` | `string` | Comment ID | +| `resourceId` | `string` | Resource identifier | +| `resourceType` | `string` | Resource type | +| `parentId` | `string \| null` | Parent comment ID for replies | +| `authorId` | `string` | Author user ID | +| `resolvedAuthorName` | `string` | Display name from `resolveUser`, or `"[deleted]"` | +| `resolvedAvatarUrl` | `string \| null` | Avatar URL from `resolveUser` | +| `body` | `string` | Comment body | +| `status` | `"pending" \| "approved" \| "spam"` | Moderation status | +| `likes` | `number` | Denormalized like count | +| `isLikedByCurrentUser` | `boolean` | Whether the requesting user has liked this comment | +| `editedAt` | `string \| null` | ISO date string if the comment was edited | +| `createdAt` | `string` | ISO date string | +| `updatedAt` | `string` | ISO date string | diff --git a/docs/content/docs/plugins/kanban.mdx b/docs/content/docs/plugins/kanban.mdx index 715e031f..1149005f 100644 --- a/docs/content/docs/plugins/kanban.mdx +++ b/docs/content/docs/plugins/kanban.mdx @@ -696,6 +696,32 @@ overrides={{ | `resolveUser` | `(userId: string) => KanbanUser \| null` | Resolve user info from ID | | `searchUsers` | `(query: string, boardId?: string) => KanbanUser[]` | Search/list users for picker | +**Slot overrides:** + +| Override | Type | Description | +|----------|------|-------------| +| `taskDetailBottomSlot` | `(task: SerializedTask) => ReactNode` | Render additional content below task details — use to embed a `CommentThread` | + +```tsx +import { CommentThread } from "@btst/stack/plugins/comments/client/components" + +overrides={{ + kanban: { + // ... + taskDetailBottomSlot: (task) => ( + + ), + } +}} +``` + ## React Hooks Import hooks from `@btst/stack/plugins/kanban/client/hooks` to use in your components: diff --git a/e2e/playwright.config.ts b/e2e/playwright.config.ts index d5d4ba67..5ac8e410 100644 --- a/e2e/playwright.config.ts +++ b/e2e/playwright.config.ts @@ -98,6 +98,7 @@ export default defineConfig({ "**/*.form-builder.spec.ts", "**/*.ui-builder.spec.ts", "**/*.kanban.spec.ts", + "**/*.comments.spec.ts", "**/*.ssg.spec.ts", "**/*.page-context.spec.ts", "**/*.wealthreview.spec.ts", @@ -114,6 +115,7 @@ export default defineConfig({ "**/*.cms.spec.ts", "**/*.relations-cms.spec.ts", "**/*.form-builder.spec.ts", + "**/*.comments.spec.ts", "**/*.page-context.spec.ts", ], }, @@ -128,6 +130,7 @@ export default defineConfig({ "**/*.cms.spec.ts", "**/*.relations-cms.spec.ts", "**/*.form-builder.spec.ts", + "**/*.comments.spec.ts", "**/*.page-context.spec.ts", ], }, diff --git a/e2e/tests/smoke.comments.spec.ts b/e2e/tests/smoke.comments.spec.ts new file mode 100644 index 00000000..7e7466d0 --- /dev/null +++ b/e2e/tests/smoke.comments.spec.ts @@ -0,0 +1,1091 @@ +import { + expect, + test, + type APIRequestContext, + type Page, +} from "@playwright/test"; + +// ─── API Helpers ──────────────────────────────────────────────────────────────── + +/** Create a published blog post — used to host comment threads in load-more tests. */ +async function createBlogPost( + request: APIRequestContext, + data: { title: string; slug: string }, +) { + const response = await request.post("/api/data/posts", { + headers: { "content-type": "application/json" }, + data: { + title: data.title, + content: `Content for ${data.title}`, + excerpt: `Excerpt for ${data.title}`, + slug: data.slug, + published: true, + publishedAt: new Date().toISOString(), + image: "", + }, + }); + expect( + response.ok(), + `createBlogPost failed: ${await response.text()}`, + ).toBeTruthy(); + return response.json(); +} + +/** Create N approved comments on a resource, sequentially with predictable bodies. */ +async function createApprovedComments( + request: APIRequestContext, + resourceId: string, + resourceType: string, + count: number, + bodyPrefix = "Load More Comment", +) { + const comments = []; + for (let i = 1; i <= count; i++) { + const comment = await createComment(request, { + resourceId, + resourceType, + body: `${bodyPrefix} ${i}`, + }); + await approveComment(request, comment.id); + comments.push(comment); + } + return comments; +} + +/** + * Navigate to a blog post page, scroll to trigger the WhenVisible comment thread, + * then verify the load-more button and paginated comments behave correctly. + * + * Mirrors `testLoadMore` from smoke.blog.spec.ts. + */ +async function testLoadMoreComments( + page: Page, + postSlug: string, + totalCount: number, + options: { pageSize: number; bodyPrefix?: string }, +) { + const { pageSize, bodyPrefix = "Load More Comment" } = options; + + await page.goto(`/pages/blog/${postSlug}`, { waitUntil: "networkidle" }); + await expect(page.locator('[data-testid="post-page"]')).toBeVisible(); + + // Scroll to the bottom to trigger WhenVisible on the comment thread + await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight)); + await page.waitForTimeout(800); + + // Comment thread must be mounted + const thread = page.locator('[data-testid="comment-thread"]'); + await expect(thread).toBeVisible({ timeout: 8000 }); + + // First page of comments should be visible (comments are asc-sorted by date) + for (let i = 1; i <= pageSize; i++) { + await expect( + page.getByText(`${bodyPrefix} ${i}`, { exact: true }), + ).toBeVisible({ timeout: 5000 }); + } + + // Comments beyond the first page must NOT be visible yet + for (let i = pageSize + 1; i <= totalCount; i++) { + await expect( + page.getByText(`${bodyPrefix} ${i}`, { exact: true }), + ).not.toBeVisible(); + } + + // Load more button must be present + const loadMoreBtn = page.locator('[data-testid="load-more-comments"]'); + await expect(loadMoreBtn).toBeVisible(); + + // Click it and wait for the next page to arrive + await loadMoreBtn.click(); + await page.waitForTimeout(1000); + + // All comments must now be visible + for (let i = 1; i <= totalCount; i++) { + await expect( + page.getByText(`${bodyPrefix} ${i}`, { exact: true }), + ).toBeVisible({ timeout: 5000 }); + } + + // Load more button should be gone (no third page) + await expect(loadMoreBtn).not.toBeVisible(); +} + +async function createComment( + request: APIRequestContext, + data: { + resourceId: string; + resourceType: string; + parentId?: string | null; + body: string; + }, +) { + const response = await request.post("/api/data/comments", { + headers: { "content-type": "application/json" }, + data: { + resourceId: data.resourceId, + resourceType: data.resourceType, + parentId: data.parentId ?? null, + body: data.body, + }, + }); + expect( + response.ok(), + `createComment failed: ${await response.text()}`, + ).toBeTruthy(); + return response.json(); +} + +async function approveComment(request: APIRequestContext, id: string) { + const response = await request.patch(`/api/data/comments/${id}/status`, { + headers: { "content-type": "application/json" }, + data: { status: "approved" }, + }); + expect( + response.ok(), + `approveComment failed: ${await response.text()}`, + ).toBeTruthy(); + return response.json(); +} + +async function getCommentCount( + request: APIRequestContext, + resourceId: string, + resourceType: string, + status = "approved", +) { + const response = await request.get( + `/api/data/comments/count?resourceId=${encodeURIComponent(resourceId)}&resourceType=${encodeURIComponent(resourceType)}&status=${status}`, + ); + expect(response.ok()).toBeTruthy(); + const body = await response.json(); + return body.count as number; +} + +// ─── Tests ──────────────────────────────────────────────────────────────────── + +test.describe("Comments Plugin", () => { + test("moderation page renders", async ({ page }) => { + const errors: string[] = []; + page.on("console", (msg) => { + if (msg.type() === "error") errors.push(msg.text()); + }); + + await page.goto("/pages/comments/moderation", { + waitUntil: "networkidle", + }); + await expect(page.locator('[data-testid="moderation-page"]')).toBeVisible(); + + // Tab bar should be visible + await expect(page.locator('[data-testid="tab-pending"]')).toBeVisible(); + await expect(page.locator('[data-testid="tab-approved"]')).toBeVisible(); + await expect(page.locator('[data-testid="tab-spam"]')).toBeVisible(); + + expect(errors, `Console errors detected:\n${errors.join("\n")}`).toEqual( + [], + ); + }); + + test("post a comment — appears in pending moderation queue", async ({ + page, + request, + }) => { + const errors: string[] = []; + page.on("console", (msg) => { + if (msg.type() === "error") errors.push(msg.text()); + }); + + const resourceId = `e2e-post-${Date.now()}`; + const comment = await createComment(request, { + resourceId, + resourceType: "e2e-test", + body: "This is a test comment.", + }); + + expect(comment.status).toBe("pending"); + + // Navigate to the moderation page and verify the comment appears + await page.goto("/pages/comments/moderation", { + waitUntil: "networkidle", + }); + await expect(page.locator('[data-testid="moderation-page"]')).toBeVisible(); + + // Click the Pending tab + await page.locator('[data-testid="tab-pending"]').click(); + await page.waitForLoadState("networkidle"); + + // The comment should appear in the list + await expect(page.getByText("This is a test comment.")).toBeVisible(); + + expect(errors, `Console errors detected:\n${errors.join("\n")}`).toEqual( + [], + ); + }); + + test("approve comment via moderation dashboard — appears in approved list", async ({ + page, + request, + }) => { + const errors: string[] = []; + page.on("console", (msg) => { + if (msg.type() === "error") errors.push(msg.text()); + }); + + const resourceId = `e2e-approve-${Date.now()}`; + const comment = await createComment(request, { + resourceId, + resourceType: "e2e-test", + body: "Approvable comment.", + }); + + // Approve via API + const approved = await approveComment(request, comment.id); + expect(approved.status).toBe("approved"); + + // Navigate to moderation and switch to Approved tab + await page.goto("/pages/comments/moderation", { + waitUntil: "networkidle", + }); + await page.locator('[data-testid="tab-approved"]').click(); + await page.waitForLoadState("networkidle"); + + await expect(page.getByText("Approvable comment.")).toBeVisible(); + + expect(errors, `Console errors detected:\n${errors.join("\n")}`).toEqual( + [], + ); + }); + + test("approve a comment via moderation UI", async ({ page, request }) => { + const errors: string[] = []; + page.on("console", (msg) => { + if (msg.type() === "error") errors.push(msg.text()); + }); + + const resourceId = `e2e-ui-approve-${Date.now()}`; + await createComment(request, { + resourceId, + resourceType: "e2e-test", + body: "Approve me via UI.", + }); + + await page.goto("/pages/comments/moderation", { + waitUntil: "networkidle", + }); + await expect(page.locator('[data-testid="moderation-page"]')).toBeVisible(); + + // Ensure we're on the Pending tab + await page.locator('[data-testid="tab-pending"]').click(); + await page.waitForLoadState("networkidle"); + + // Find the approve button for our comment + const row = page + .locator('[data-testid="moderation-row"]') + .filter({ hasText: "Approve me via UI." }); + await expect(row).toBeVisible(); + + const approveBtn = row.locator('[data-testid="approve-button"]'); + await approveBtn.click(); + await page.waitForLoadState("networkidle"); + + // Switch to Approved tab and verify + await page.locator('[data-testid="tab-approved"]').click(); + await page.waitForLoadState("networkidle"); + await expect(page.getByText("Approve me via UI.")).toBeVisible(); + + expect(errors, `Console errors detected:\n${errors.join("\n")}`).toEqual( + [], + ); + }); + + test("comment count endpoint returns correct count", async ({ request }) => { + const resourceId = `e2e-count-${Date.now()}`; + + // No comments yet + const countBefore = await getCommentCount(request, resourceId, "e2e-test"); + expect(countBefore).toBe(0); + + // Post and approve a comment + const comment = await createComment(request, { + resourceId, + resourceType: "e2e-test", + body: "Count me.", + }); + await approveComment(request, comment.id); + + // Count should be 1 now + const countAfter = await getCommentCount(request, resourceId, "e2e-test"); + expect(countAfter).toBe(1); + }); + + test("like a comment — count increments", async ({ request }) => { + const resourceId = `e2e-like-${Date.now()}`; + const comment = await createComment(request, { + resourceId, + resourceType: "e2e-test", + body: "Like me.", + }); + + // Like the comment + const likeResponse = await request.post( + `/api/data/comments/${comment.id}/like`, + { + headers: { "content-type": "application/json" }, + data: { authorId: "user-liker" }, + }, + ); + expect(likeResponse.ok()).toBeTruthy(); + const likeResult = await likeResponse.json(); + expect(likeResult.isLiked).toBe(true); + expect(likeResult.likes).toBe(1); + + // Like again (toggle — should unlike) + const unlikeResponse = await request.post( + `/api/data/comments/${comment.id}/like`, + { + headers: { "content-type": "application/json" }, + data: { authorId: "user-liker" }, + }, + ); + expect(unlikeResponse.ok()).toBeTruthy(); + const unlikeResult = await unlikeResponse.json(); + expect(unlikeResult.isLiked).toBe(false); + expect(unlikeResult.likes).toBe(0); + }); + + test("reply to a comment — nested under parent", async ({ request }) => { + const resourceId = `e2e-reply-${Date.now()}`; + const parent = await createComment(request, { + resourceId, + resourceType: "e2e-test", + body: "Parent comment.", + }); + + const reply = await createComment(request, { + resourceId, + resourceType: "e2e-test", + parentId: parent.id, + body: "Reply to parent.", + }); + + expect(reply.parentId).toBe(parent.id); + expect(reply.status).toBe("pending"); + }); + + test("unauthenticated placeholder shown when blog post has no currentUserId", async ({ + page, + request, + }) => { + const errors: string[] = []; + page.on("console", (msg) => { + if (msg.type() === "error") errors.push(msg.text()); + }); + + // Create and approve a blog post comment so the thread renders + const resourceId = `e2e-auth-${Date.now()}`; + const comment = await createComment(request, { + resourceId, + resourceType: "blog-post", + body: "Public comment on blog post.", + }); + await approveComment(request, comment.id); + + // Create a blog post (rely on the existing blog post list) + // Just navigate to the blog list and check if a post has the login prompt + // (The layout wires CommentThread without currentUserId) + // Navigate to a blog post — the slot should show the login prompt + await page.goto("/pages/blog", { waitUntil: "networkidle" }); + const postLink = page + .locator("a") + .filter({ hasText: /read more|view post/i }) + .first(); + const hasPost = await postLink.isVisible().catch(() => false); + if (!hasPost) { + test.skip(); // No blog posts in the test db + return; + } + await postLink.click(); + await page.waitForLoadState("networkidle"); + + // Scroll down to trigger WhenVisible + await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight)); + await page.waitForTimeout(800); + + // Login prompt should be visible (no currentUserId in the test layout) + const loginPrompt = page.locator('[data-testid="login-prompt"]'); + await expect(loginPrompt).toBeVisible({ timeout: 5000 }); + + expect(errors, `Console errors detected:\n${errors.join("\n")}`).toEqual( + [], + ); + }); + + test("posting a comment via UI renders the comment card without error", async ({ + page, + request, + }) => { + // Regression test: POST /comments previously returned a raw Comment (no + // resolvedAuthorName), causing getInitials() to crash on the optimistic- + // update replacement. This test posts via the UI and verifies the comment + // card renders with no error boundary. + + const errors: string[] = []; + page.on("console", (msg) => { + if (msg.type() === "error") errors.push(msg.text()); + }); + + // Use a unique resourceId so the thread starts empty and the comment we + // post is the very first one (exercises the "create cache from scratch" path). + const resourceId = `e2e-ui-post-${Date.now()}`; + const resourceType = "e2e-test"; + + // Seed one approved comment so the thread is already rendered and the + // CommentThread component is mounted before we post. + const seed = await createComment(request, { + resourceId, + resourceType, + body: "Seed comment — thread is visible.", + }); + await approveComment(request, seed.id); + + // Navigate to the moderation page which embeds a CommentThread per-resource; + // use the direct resource-comments admin route instead of a blog page so we + // don't depend on specific blog posts existing in the test DB. + await page.goto( + `/pages/comments/moderation/resource?resourceId=${encodeURIComponent(resourceId)}&resourceType=${encodeURIComponent(resourceType)}`, + { waitUntil: "networkidle" }, + ); + + // If the route doesn't exist (some example apps may not expose it), fall + // back to verifying the API response contains resolvedAuthorName. + const hasThread = await page + .locator('[data-testid="comment-form"]') + .isVisible() + .catch(() => false); + + if (!hasThread) { + // Verify the API fix independently: POST must return resolvedAuthorName. + const comment = await createComment(request, { + resourceId, + resourceType, + body: "API regression check.", + }); + expect( + typeof comment.resolvedAuthorName, + "POST /comments must return resolvedAuthorName", + ).toBe("string"); + expect(comment.resolvedAuthorName.length).toBeGreaterThan(0); + return; + } + + // Type and submit a new comment via the browser form. + const textarea = page.locator('[data-testid="comment-form"] textarea'); + await textarea.fill("Hello from browser UI — regression test."); + await page + .locator('[data-testid="comment-form"] button[type="submit"]') + .click(); + + // The optimistic comment card should appear immediately. + await expect( + page.locator('[data-testid="comment-card"]').filter({ + hasText: "Hello from browser UI — regression test.", + }), + ).toBeVisible({ timeout: 5000 }); + + // No error boundary should have triggered. + await expect(page.getByText("Something went wrong")).not.toBeVisible(); + + // Console should be clean (no "Cannot read properties of undefined"). + const criticalErrors = errors.filter( + (e) => + e.includes("Cannot read properties of undefined") || + e.includes("getInitials"), + ); + expect( + criticalErrors, + `Critical console errors:\n${criticalErrors.join("\n")}`, + ).toEqual([]); + }); + + test("POST /comments response includes resolvedAuthorName (no undefined crash)", async ({ + request, + }) => { + // Regression test: the POST response previously returned a raw DB Comment + // that lacked resolvedAuthorName, causing getInitials() to crash when the + // optimistic-update replacement ran on the client. + const resourceId = `e2e-post-serialized-${Date.now()}`; + + const comment = await createComment(request, { + resourceId, + resourceType: "e2e-test", + body: "Serialized response check.", + }); + + // The response must include the enriched fields — not just the raw DB record. + expect( + typeof comment.resolvedAuthorName, + "POST /comments must return resolvedAuthorName", + ).toBe("string"); + expect( + comment.resolvedAuthorName.length, + "resolvedAuthorName must not be empty", + ).toBeGreaterThan(0); + expect( + "resolvedAvatarUrl" in comment, + "POST /comments must return resolvedAvatarUrl", + ).toBe(true); + expect( + "isLikedByCurrentUser" in comment, + "POST /comments must return isLikedByCurrentUser", + ).toBe(true); + }); + + test("resolved author name is returned for comments", async ({ request }) => { + const resourceId = `e2e-author-${Date.now()}`; + + const comment = await createComment(request, { + resourceId, + resourceType: "e2e-test", + body: "Comment with resolved author.", + }); + + // Approve it so it shows in the list + await approveComment(request, comment.id); + + // Fetch the comment list and verify resolvedAuthorName is populated + const listResponse = await request.get( + `/api/data/comments?resourceId=${encodeURIComponent(resourceId)}&resourceType=e2e-test&status=approved`, + ); + expect(listResponse.ok()).toBeTruthy(); + const list = await listResponse.json(); + const found = list.items.find((c: { id: string }) => c.id === comment.id); + expect(found).toBeDefined(); + // resolvedAuthorName should be a non-empty string (from resolveUser or "[deleted]" fallback) + expect(typeof found.resolvedAuthorName).toBe("string"); + expect(found.resolvedAuthorName.length).toBeGreaterThan(0); + }); +}); + +// ─── Own pending comments visibility ──────────────────────────────────────────── +// +// These tests cover the business rule: a user should always see their own +// pending (awaiting-moderation) comments and replies, even after a page +// refresh clears the React Query cache. The fix is server-side — GET /comments +// with `currentUserId` returns approved + own-pending in a single response. +// +// The example app's onBeforePost hook returns authorId "olliethedev" for every +// POST, so we use that as currentUserId in the query string to simulate the +// logged-in user fetching their own pending content. + +test.describe("Own pending comments — visible after refresh (server-side fix)", () => { + // Shared authorId used by the example app's onBeforePost hook + const CURRENT_USER_ID = "olliethedev"; + + test("own pending top-level comment is included when currentUserId matches author", async ({ + request, + }) => { + const resourceId = `e2e-own-pending-${Date.now()}`; + const resourceType = "e2e-test"; + + // POST creates a pending comment (autoApprove: false) + const comment = await createComment(request, { + resourceId, + resourceType, + body: "My pending comment — should survive refresh.", + }); + expect(comment.status).toBe("pending"); + + // Simulates a page-refresh fetch: status defaults to "approved" but + // currentUserId is provided — own pending comments must be included. + const response = await request.get( + `/api/data/comments?resourceId=${encodeURIComponent(resourceId)}&resourceType=${encodeURIComponent(resourceType)}¤tUserId=${CURRENT_USER_ID}`, + ); + expect(response.ok()).toBeTruthy(); + const body = await response.json(); + + const found = body.items.find((c: { id: string }) => c.id === comment.id); + expect( + found, + "Own pending comment must appear in the response with currentUserId", + ).toBeDefined(); + expect(found.status).toBe("pending"); + }); + + test("pending comment is NOT returned when currentUserId is absent", async ({ + request, + }) => { + const resourceId = `e2e-no-pending-${Date.now()}`; + const resourceType = "e2e-test"; + + const comment = await createComment(request, { + resourceId, + resourceType, + body: "Invisible pending comment — no currentUserId.", + }); + expect(comment.status).toBe("pending"); + + // Fetch without currentUserId — only approved comments should be returned + const response = await request.get( + `/api/data/comments?resourceId=${encodeURIComponent(resourceId)}&resourceType=${encodeURIComponent(resourceType)}`, + ); + expect(response.ok()).toBeTruthy(); + const body = await response.json(); + + const found = body.items.find((c: { id: string }) => c.id === comment.id); + expect( + found, + "Pending comment must NOT appear without currentUserId", + ).toBeUndefined(); + }); + + test("another user's pending comment is NOT included even with currentUserId", async ({ + request, + }) => { + const resourceId = `e2e-other-pending-${Date.now()}`; + const resourceType = "e2e-test"; + + // Comment is authored by "olliethedev" (from onBeforePost hook) + const comment = await createComment(request, { + resourceId, + resourceType, + body: "Comment by the real author.", + }); + expect(comment.status).toBe("pending"); + + // A *different* userId should not see this pending comment + const response = await request.get( + `/api/data/comments?resourceId=${encodeURIComponent(resourceId)}&resourceType=${encodeURIComponent(resourceType)}¤tUserId=some-other-user`, + ); + expect(response.ok()).toBeTruthy(); + const body = await response.json(); + + const found = body.items.find((c: { id: string }) => c.id === comment.id); + expect( + found, + "Pending comment from another author must NOT appear for a different currentUserId", + ).toBeUndefined(); + }); + + test("replyCount on parent includes own pending reply when currentUserId is provided", async ({ + request, + }) => { + const resourceId = `e2e-replycount-${Date.now()}`; + const resourceType = "e2e-test"; + + // Create and approve parent so it appears in the top-level list + const parent = await createComment(request, { + resourceId, + resourceType, + body: "Parent comment for reply-count test.", + }); + await approveComment(request, parent.id); + + // Post a pending reply + await createComment(request, { + resourceId, + resourceType, + parentId: parent.id, + body: "My pending reply — should increment replyCount.", + }); + + // Fetch top-level comments WITH currentUserId + const withUserResponse = await request.get( + `/api/data/comments?resourceId=${encodeURIComponent(resourceId)}&resourceType=${encodeURIComponent(resourceType)}&parentId=null¤tUserId=${CURRENT_USER_ID}`, + ); + expect(withUserResponse.ok()).toBeTruthy(); + const withUserBody = await withUserResponse.json(); + const parentItem = withUserBody.items.find( + (c: { id: string }) => c.id === parent.id, + ); + expect(parentItem).toBeDefined(); + expect( + parentItem.replyCount, + "replyCount must include own pending reply when currentUserId is provided", + ).toBe(1); + }); + + test("replyCount is 0 for a pending reply when currentUserId is absent", async ({ + request, + }) => { + const resourceId = `e2e-replycount-nouser-${Date.now()}`; + const resourceType = "e2e-test"; + + const parent = await createComment(request, { + resourceId, + resourceType, + body: "Parent for replyCount-without-user test.", + }); + await approveComment(request, parent.id); + + // Pending reply — not approved, not counted without currentUserId + await createComment(request, { + resourceId, + resourceType, + parentId: parent.id, + body: "Pending reply — invisible without currentUserId.", + }); + + // Fetch without currentUserId + const response = await request.get( + `/api/data/comments?resourceId=${encodeURIComponent(resourceId)}&resourceType=${encodeURIComponent(resourceType)}&parentId=null`, + ); + expect(response.ok()).toBeTruthy(); + const body = await response.json(); + const parentItem = body.items.find( + (c: { id: string }) => c.id === parent.id, + ); + expect(parentItem).toBeDefined(); + expect( + parentItem.replyCount, + "replyCount must be 0 when reply is pending and currentUserId is absent", + ).toBe(0); + }); + + test("own pending reply appears in replies list when currentUserId is provided", async ({ + request, + }) => { + const resourceId = `e2e-pending-reply-list-${Date.now()}`; + const resourceType = "e2e-test"; + + const parent = await createComment(request, { + resourceId, + resourceType, + body: "Parent comment.", + }); + await approveComment(request, parent.id); + + // Post a pending reply + const reply = await createComment(request, { + resourceId, + resourceType, + parentId: parent.id, + body: "My pending reply — must survive refresh.", + }); + expect(reply.status).toBe("pending"); + + // Simulates the RepliesSection fetch after a page refresh: + // status defaults to approved but currentUserId causes own-pending to be included + const response = await request.get( + `/api/data/comments?resourceId=${encodeURIComponent(resourceId)}&resourceType=${encodeURIComponent(resourceType)}&parentId=${encodeURIComponent(parent.id)}¤tUserId=${CURRENT_USER_ID}`, + ); + expect(response.ok()).toBeTruthy(); + const body = await response.json(); + + const found = body.items.find((c: { id: string }) => c.id === reply.id); + expect( + found, + "Own pending reply must appear in the replies list with currentUserId", + ).toBeDefined(); + expect(found.status).toBe("pending"); + }); + + test("own pending reply does NOT appear in replies list without currentUserId", async ({ + request, + }) => { + const resourceId = `e2e-pending-reply-hidden-${Date.now()}`; + const resourceType = "e2e-test"; + + const parent = await createComment(request, { + resourceId, + resourceType, + body: "Parent comment.", + }); + await approveComment(request, parent.id); + + const reply = await createComment(request, { + resourceId, + resourceType, + parentId: parent.id, + body: "Pending reply — hidden without currentUserId.", + }); + expect(reply.status).toBe("pending"); + + // Fetch without currentUserId — only approved replies returned + const response = await request.get( + `/api/data/comments?resourceId=${encodeURIComponent(resourceId)}&resourceType=${encodeURIComponent(resourceType)}&parentId=${encodeURIComponent(parent.id)}`, + ); + expect(response.ok()).toBeTruthy(); + const body = await response.json(); + + const found = body.items.find((c: { id: string }) => c.id === reply.id); + expect( + found, + "Pending reply must NOT appear in the list without currentUserId", + ).toBeUndefined(); + }); + + test("pending-badge is shown for own pending comment in the UI", async ({ + page, + request, + }) => { + // Seeds an approved comment so the thread renders, then posts via the UI + // and verifies the "Pending approval" badge appears on the new comment card. + const resourceId = `e2e-badge-${Date.now()}`; + const resourceType = "e2e-test"; + + const seed = await createComment(request, { + resourceId, + resourceType, + body: "Seed — ensures thread is mounted.", + }); + await approveComment(request, seed.id); + + await page.goto( + `/pages/comments/moderation/resource?resourceId=${encodeURIComponent(resourceId)}&resourceType=${encodeURIComponent(resourceType)}`, + { waitUntil: "networkidle" }, + ); + + const hasThread = await page + .locator('[data-testid="comment-form"]') + .isVisible() + .catch(() => false); + + if (!hasThread) { + // Resource-comments route not available in this example app — skip UI portion + test.skip(); + return; + } + + const textarea = page.locator('[data-testid="comment-form"] textarea'); + await textarea.fill("My new pending comment."); + await page + .locator('[data-testid="comment-form"] button[type="submit"]') + .click(); + + // The pending badge must appear on the newly posted comment card + const newCard = page + .locator('[data-testid="comment-card"]') + .filter({ hasText: "My new pending comment." }); + await expect(newCard).toBeVisible({ timeout: 5000 }); + await expect( + newCard.locator('[data-testid="pending-badge"]'), + ).toBeVisible(); + }); +}); + +// ─── My Comments Page ──────────────────────────────────────────────────────── +// +// The example app's onBeforePost returns authorId "olliethedev" for every POST, +// and the layout wires currentUserId: "olliethedev". All tests in this block +// rely on that fixture so they can verify comments appear on the my-comments page. + +test.describe("My Comments Page", () => { + const AUTHOR_ID = "olliethedev"; + + test("page renders without console errors", async ({ page }) => { + const errors: string[] = []; + page.on("console", (msg) => { + if (msg.type() === "error") errors.push(msg.text()); + }); + + await page.goto("/pages/comments/my-comments", { + waitUntil: "networkidle", + }); + + // Either the list or the empty-state element must be visible + const hasPage = await page + .locator('[data-testid="my-comments-page"]') + .isVisible() + .catch(() => false); + const hasEmpty = await page + .locator('[data-testid="my-comments-empty"]') + .isVisible() + .catch(() => false); + expect( + hasPage || hasEmpty, + "Expected my-comments-page or my-comments-empty to be visible", + ).toBe(true); + + expect(errors, `Console errors detected:\n${errors.join("\n")}`).toEqual( + [], + ); + }); + + test("populated state — comment created by current user appears in list", async ({ + page, + request, + }) => { + const errors: string[] = []; + page.on("console", (msg) => { + if (msg.type() === "error") errors.push(msg.text()); + }); + + // Create a comment — the example app's onBeforePost assigns authorId "olliethedev" + const uniqueBody = `My comment e2e ${Date.now()}`; + await createComment(request, { + resourceId: `e2e-mycomments-${Date.now()}`, + resourceType: "e2e-test", + body: uniqueBody, + }); + + await page.goto("/pages/comments/my-comments", { + waitUntil: "networkidle", + }); + + await expect( + page.locator('[data-testid="my-comments-list"]'), + ).toBeVisible(); + + // The comment body should appear somewhere in the list (possibly on page 1) + await expect(page.getByText(uniqueBody)).toBeVisible(); + + expect(errors, `Console errors detected:\n${errors.join("\n")}`).toEqual( + [], + ); + }); + + test("delete from list — comment disappears after confirmation", async ({ + page, + request, + }) => { + const errors: string[] = []; + page.on("console", (msg) => { + if (msg.type() === "error") errors.push(msg.text()); + }); + + const uniqueBody = `Delete me e2e ${Date.now()}`; + await createComment(request, { + resourceId: `e2e-delete-mycomments-${Date.now()}`, + resourceType: "e2e-test", + body: uniqueBody, + }); + + await page.goto("/pages/comments/my-comments", { + waitUntil: "networkidle", + }); + + // Find the row containing our comment + const row = page + .locator('[data-testid="my-comment-row"]') + .filter({ hasText: uniqueBody }); + await expect(row).toBeVisible(); + + // Click the delete button on that row + await row.locator('[data-testid="my-comment-delete-button"]').click(); + + // Confirm the AlertDialog + await page.locator("button", { hasText: "Delete" }).last().click(); + await page.waitForLoadState("networkidle"); + + // Row should no longer be visible + await expect(page.getByText(uniqueBody)).not.toBeVisible({ timeout: 5000 }); + + expect(errors, `Console errors detected:\n${errors.join("\n")}`).toEqual( + [], + ); + }); + + test("API security — GET /comments?authorId=unknown returns 403", async ({ + request, + }) => { + // The example app's onBeforeListByAuthor only allows "olliethedev" + const response = await request.get( + `/api/data/comments?authorId=unknown-user-12345`, + ); + expect( + response.status(), + "Expected 403 when onBeforeListByAuthor is absent or rejects", + ).toBe(403); + }); + + test("API — GET /comments?authorId=olliethedev returns comments", async ({ + request, + }) => { + // Seed a comment so we have at least one + await createComment(request, { + resourceId: `e2e-api-author-${Date.now()}`, + resourceType: "e2e-test", + body: "Author filter API test", + }); + + const response = await request.get( + `/api/data/comments?authorId=${encodeURIComponent(AUTHOR_ID)}`, + ); + expect(response.ok(), "Expected 200 for own-author query").toBeTruthy(); + const body = await response.json(); + expect(Array.isArray(body.items)).toBe(true); + // All returned comments must belong to the requested author + for (const item of body.items) { + expect(item.authorId).toBe(AUTHOR_ID); + } + }); +}); + +// ─── Load More ──────────────────────────────────────────────────────────────── +// +// These tests verify the comment thread pagination that powers the "Load more +// comments" button. They mirror the blog smoke tests for load-more: an API +// contract test validates server-side limit/offset, and a UI test exercises +// the full click-to-fetch cycle in the browser. +// +// The example app layouts set defaultCommentPageSize: 5 so that pagination +// triggers after 5 comments — mirroring the blog's 10-per-page default. + +test.describe("Comment thread — load more", () => { + test("API pagination contract: limit/offset return correct slices", async ({ + request, + }) => { + const resourceId = `e2e-pagination-${Date.now()}`; + const resourceType = "e2e-test"; + + // Create and approve 7 top-level comments + await createApprovedComments(request, resourceId, resourceType, 7); + + // First page: 5 items, total = 7 + const page1 = await request.get( + `/api/data/comments?resourceId=${encodeURIComponent(resourceId)}&resourceType=${encodeURIComponent(resourceType)}&parentId=null&status=approved&limit=5&offset=0`, + ); + expect(page1.ok()).toBeTruthy(); + const body1 = await page1.json(); + expect(body1.items).toHaveLength(5); + expect(body1.total).toBe(7); + expect(body1.limit).toBe(5); + expect(body1.offset).toBe(0); + + // Second page: 2 items, total still = 7 + const page2 = await request.get( + `/api/data/comments?resourceId=${encodeURIComponent(resourceId)}&resourceType=${encodeURIComponent(resourceType)}&parentId=null&status=approved&limit=5&offset=5`, + ); + expect(page2.ok()).toBeTruthy(); + const body2 = await page2.json(); + expect(body2.items).toHaveLength(2); + expect(body2.total).toBe(7); + expect(body2.limit).toBe(5); + expect(body2.offset).toBe(5); + + // Third page (beyond end): 0 items + const page3 = await request.get( + `/api/data/comments?resourceId=${encodeURIComponent(resourceId)}&resourceType=${encodeURIComponent(resourceType)}&parentId=null&status=approved&limit=5&offset=10`, + ); + expect(page3.ok()).toBeTruthy(); + const body3 = await page3.json(); + expect(body3.items).toHaveLength(0); + expect(body3.total).toBe(7); + }); + + test("load more button on blog post page", async ({ page, request }) => { + const errors: string[] = []; + page.on("console", (msg) => { + if (msg.type() === "error") errors.push(msg.text()); + }); + + const slug = `e2e-lm-comments-${Date.now()}`; + + // Create a published blog post to host the comment thread + await createBlogPost(request, { + title: "Load More Comments Test Post", + slug, + }); + + // Create 7 approved comments so two pages are needed (pageSize = 5) + await createApprovedComments(request, slug, "blog-post", 7); + + await testLoadMoreComments(page, slug, 7, { + pageSize: 5, + bodyPrefix: "Load More Comment", + }); + + expect(errors, `Console errors detected:\n${errors.join("\n")}`).toEqual( + [], + ); + }); +}); diff --git a/examples/nextjs/app/api/data/[[...all]]/route.ts b/examples/nextjs/app/api/data/[[...all]]/route.ts index 8f4d4e31..d60f8ef4 100644 --- a/examples/nextjs/app/api/data/[[...all]]/route.ts +++ b/examples/nextjs/app/api/data/[[...all]]/route.ts @@ -3,4 +3,5 @@ import { handler } from "@/lib/stack" export const GET = handler export const POST = handler export const PUT = handler +export const PATCH = handler export const DELETE = handler diff --git a/examples/nextjs/app/globals.css b/examples/nextjs/app/globals.css index d37c0845..580ce0b6 100644 --- a/examples/nextjs/app/globals.css +++ b/examples/nextjs/app/globals.css @@ -23,6 +23,7 @@ /* Import Kanban plugin styles */ @import "@btst/stack/plugins/kanban/css"; +@import "@btst/stack/plugins/comments/css"; @custom-variant dark (&:is(.dark *)); diff --git a/examples/nextjs/app/pages/layout.tsx b/examples/nextjs/app/pages/layout.tsx index 98117fbe..4b9d974f 100644 --- a/examples/nextjs/app/pages/layout.tsx +++ b/examples/nextjs/app/pages/layout.tsx @@ -16,6 +16,8 @@ import type { FormBuilderPluginOverrides } from "@btst/stack/plugins/form-builde import type { UIBuilderPluginOverrides } from "@btst/stack/plugins/ui-builder/client" import { defaultComponentRegistry } from "@btst/stack/plugins/ui-builder/client" import type { KanbanPluginOverrides } from "@btst/stack/plugins/kanban/client" +import type { CommentsPluginOverrides } from "@btst/stack/plugins/comments/client" +import { CommentThread } from "@btst/stack/plugins/comments/client/components" import { resolveUser, searchUsers } from "@/lib/mock-users" // Get base URL - works on both server and client @@ -80,6 +82,7 @@ type PluginOverrides = { "form-builder": FormBuilderPluginOverrides, "ui-builder": UIBuilderPluginOverrides, kanban: KanbanPluginOverrides, + comments: CommentsPluginOverrides, } export default function ExampleLayout({ @@ -111,6 +114,18 @@ export default function ExampleLayout({ refresh: () => router.refresh(), uploadImage: mockUploadFile, Image: NextImageWrapper, + // Wire comments into the bottom of each blog post + postBottomSlot: (post) => ( + + ), // Lifecycle Hooks - called during route rendering onRouteRender: async (routeName, context) => { console.log(`[${context.isSSR ? 'SSR' : 'CSR'}] onRouteRender: Route rendered:`, routeName, context.path); @@ -266,6 +281,17 @@ export default function ExampleLayout({ // User resolution for assignees resolveUser, searchUsers, + // Wire comments into the bottom of each task detail dialog + taskDetailBottomSlot: (task) => ( + + ), // Lifecycle hooks onRouteRender: async (routeName, context) => { console.log(`[${context.isSSR ? 'SSR' : 'CSR'}] Kanban route:`, routeName, context.path); @@ -281,6 +307,24 @@ export default function ExampleLayout({ console.log(`[${context.isSSR ? 'SSR' : 'CSR'}] onBeforeBoardPageRendered:`, boardId); return true; }, + }, + comments: { + apiBaseURL: baseURL, + apiBasePath: "/api/data", + // In production: derive from your auth session + currentUserId: "olliethedev", + defaultCommentPageSize: 5, + resourceLinks: { + "blog-post": (slug) => `/pages/blog/${slug}`, + }, + onBeforeModerationPageRendered: (context) => { + console.log(`[${context.isSSR ? 'SSR' : 'CSR'}] onBeforeModerationPageRendered`); + return true; // In production: check admin role + }, + onBeforeMyCommentsPageRendered: (context) => { + console.log(`[${context.isSSR ? 'SSR' : 'CSR'}] onBeforeMyCommentsPageRendered`); + return true; // In production: check authenticated session + }, } }} > diff --git a/examples/nextjs/lib/stack-client.tsx b/examples/nextjs/lib/stack-client.tsx index 02e2e282..5dcccd8c 100644 --- a/examples/nextjs/lib/stack-client.tsx +++ b/examples/nextjs/lib/stack-client.tsx @@ -7,6 +7,7 @@ import { formBuilderClientPlugin } from "@btst/stack/plugins/form-builder/client import { uiBuilderClientPlugin, defaultComponentRegistry } from "@btst/stack/plugins/ui-builder/client" import { routeDocsClientPlugin } from "@btst/stack/plugins/route-docs/client" import { kanbanClientPlugin } from "@btst/stack/plugins/kanban/client" +import { commentsClientPlugin } from "@btst/stack/plugins/comments/client" import { QueryClient } from "@tanstack/react-query" // Get base URL function - works on both server and client @@ -175,6 +176,15 @@ export const getStackClient = ( }, }, }), + // Comments plugin — registers the /comments/moderation admin route + comments: commentsClientPlugin({ + apiBaseURL: baseURL, + apiBasePath: "/api/data", + siteBaseURL: baseURL, + siteBasePath: "/pages", + queryClient: queryClient, + headers: options?.headers, + }), } }) } diff --git a/examples/nextjs/lib/stack.ts b/examples/nextjs/lib/stack.ts index 0af9829e..12ff4937 100644 --- a/examples/nextjs/lib/stack.ts +++ b/examples/nextjs/lib/stack.ts @@ -8,6 +8,7 @@ import { cmsBackendPlugin } from "@btst/stack/plugins/cms/api" import { formBuilderBackendPlugin } from "@btst/stack/plugins/form-builder/api" import { openApiBackendPlugin } from "@btst/stack/plugins/open-api/api" import { kanbanBackendPlugin } from "@btst/stack/plugins/kanban/api" +import { commentsBackendPlugin } from "@btst/stack/plugins/comments/api" import { UI_BUILDER_CONTENT_TYPE } from "@btst/stack/plugins/ui-builder" import { openai } from "@ai-sdk/openai" import { tool } from "ai" @@ -360,6 +361,62 @@ Keep all responses concise. Do not discuss the technology stack or internal tool description: "API documentation for the Next.js example application", theme: "kepler", }), + // Comments plugin for threaded discussions + comments: commentsBackendPlugin({ + autoApprove: false, + resolveUser: async (authorId) => { + // In production: look up your auth system's user by authorId + return { name: `User ${authorId}` } + }, + onBeforeList: async (query, ctx) => { + // Restrict pending/spam queues to admin sessions. + // Without this check a no-op hook would bypass the built-in 403 guard. + if (query.status && query.status !== "approved") { + // In production: replace with a real session/role check, e.g.: + // const session = await getSession(ctx.headers) + // if (!session?.user?.isAdmin) throw new Error("Admin access required") + console.log("onBeforeList: non-approved status filter — ensure admin check in production") + } + }, + onBeforePost: async (input, ctx) => { + // In production: verify the session and return the authenticated user's ID + // The authorId is no longer trusted from the client body — it is injected here + console.log("onBeforePost: new comment on", input.resourceType, input.resourceId) + return { authorId: "olliethedev" } // In production: return { authorId: session.user.id } + }, + onAfterPost: async (comment, ctx) => { + console.log("Comment created:", comment.id, "status:", comment.status) + }, + onBeforeLike: async (commentId, authorId, ctx) => { + // In production: verify authorId matches the authenticated session + console.log("onBeforeLike: user", authorId, "toggling like on comment", commentId) + }, + onBeforeStatusChange: async (commentId, status, ctx) => { + // In production: verify the caller has admin/moderator role + console.log("onBeforeStatusChange: comment", commentId, "->", status) + }, + onAfterApprove: async (comment, ctx) => { + console.log("Comment approved:", comment.id) + }, + onBeforeDelete: async (commentId, ctx) => { + // In production: verify the caller has admin/moderator role + console.log("onBeforeDelete: comment", commentId) + }, + onAfterDelete: async (commentId, ctx) => { + console.log("Comment deleted:", commentId) + }, + onBeforeListByAuthor: async (authorId, query, ctx) => { + // In production: verify authorId matches the authenticated session + // const session = await getSession(ctx.headers) + // if (!session?.user) throw new Error("Authentication required") + // if (authorId !== session.user.id && !session.user.isAdmin) throw new Error("Forbidden") + if (authorId !== "olliethedev") throw new Error("Forbidden") + }, + resolveCurrentUserId: async (ctx) => { + // In production: return session?.user?.id ?? null + return "olliethedev" + }, + }), // Kanban plugin for project management boards kanban: kanbanBackendPlugin({ onBeforeListBoards: async (filter, context) => { diff --git a/examples/react-router/app/app.css b/examples/react-router/app/app.css index 50a5a83a..67e1dbbe 100644 --- a/examples/react-router/app/app.css +++ b/examples/react-router/app/app.css @@ -18,6 +18,7 @@ /* Import Kanban plugin styles */ @import "@btst/stack/plugins/kanban/css"; +@import "@btst/stack/plugins/comments/css"; @custom-variant dark (&:is(.dark *)); diff --git a/examples/react-router/app/lib/stack-client.tsx b/examples/react-router/app/lib/stack-client.tsx index d25807ac..12ede79c 100644 --- a/examples/react-router/app/lib/stack-client.tsx +++ b/examples/react-router/app/lib/stack-client.tsx @@ -3,6 +3,7 @@ import { blogClientPlugin } from "@btst/stack/plugins/blog/client" import { aiChatClientPlugin } from "@btst/stack/plugins/ai-chat/client" import { cmsClientPlugin } from "@btst/stack/plugins/cms/client" import { formBuilderClientPlugin } from "@btst/stack/plugins/form-builder/client" +import { commentsClientPlugin } from "@btst/stack/plugins/comments/client" import { uiBuilderClientPlugin, defaultComponentRegistry } from "@btst/stack/plugins/ui-builder/client" import { routeDocsClientPlugin } from "@btst/stack/plugins/route-docs/client" import { kanbanClientPlugin } from "@btst/stack/plugins/kanban/client" @@ -133,6 +134,14 @@ export const getStackClient = (queryClient: QueryClient) => { description: "Manage your projects with kanban boards", }, }), + // Comments plugin — registers the /comments/moderation admin route + comments: commentsClientPlugin({ + apiBaseURL: baseURL, + apiBasePath: "/api/data", + siteBaseURL: baseURL, + siteBasePath: "/pages", + queryClient: queryClient, + }), } }) } diff --git a/examples/react-router/app/lib/stack.ts b/examples/react-router/app/lib/stack.ts index ab248e8b..301b5787 100644 --- a/examples/react-router/app/lib/stack.ts +++ b/examples/react-router/app/lib/stack.ts @@ -6,6 +6,7 @@ import { cmsBackendPlugin } from "@btst/stack/plugins/cms/api" import { formBuilderBackendPlugin } from "@btst/stack/plugins/form-builder/api" import { openApiBackendPlugin } from "@btst/stack/plugins/open-api/api" import { kanbanBackendPlugin } from "@btst/stack/plugins/kanban/api" +import { commentsBackendPlugin } from "@btst/stack/plugins/comments/api" import { UI_BUILDER_CONTENT_TYPE } from "@btst/stack/plugins/ui-builder" import { openai } from "@ai-sdk/openai" @@ -154,6 +155,52 @@ const { handler, dbSchema } = stack({ console.log("Board created:", board.id, board.name); }, }), + // Comments plugin for threaded discussions + comments: commentsBackendPlugin({ + autoApprove: false, + resolveUser: async (authorId) => { + // In production: look up your auth system's user by authorId + return { name: `User ${authorId}` } + }, + onBeforeList: async (query, ctx) => { + // Restrict pending/spam queues to admin sessions. + // Without this check a no-op hook would bypass the built-in 403 guard. + if (query.status && query.status !== "approved") { + // In production: replace with a real session/role check, e.g.: + // const session = await getSession(ctx.headers) + // if (!session?.user?.isAdmin) throw new Error("Admin access required") + console.log("onBeforeList: non-approved status filter — ensure admin check in production") + } + }, + onBeforePost: async (input, ctx) => { + // In production: verify the session and return the authenticated user's ID + console.log("onBeforePost: new comment on", input.resourceType, input.resourceId) + return { authorId: "olliethedev" } // In production: return { authorId: session.user.id } + }, + onBeforeLike: async (commentId, authorId, ctx) => { + // In production: verify authorId matches the authenticated session + console.log("onBeforeLike: user", authorId, "toggling like on comment", commentId) + }, + onBeforeStatusChange: async (commentId, status, ctx) => { + // In production: verify the caller has admin/moderator role + console.log("onBeforeStatusChange: comment", commentId, "->", status) + }, + onBeforeDelete: async (commentId, ctx) => { + // In production: verify the caller has admin/moderator role + console.log("onBeforeDelete: comment", commentId) + }, + onBeforeListByAuthor: async (authorId, query, ctx) => { + // In production: verify authorId matches the authenticated session + // const session = await getSession(ctx.headers) + // if (!session?.user) throw new Error("Authentication required") + // if (authorId !== session.user.id && !session.user.isAdmin) throw new Error("Forbidden") + if (authorId !== "olliethedev") throw new Error("Forbidden") + }, + resolveCurrentUserId: async (ctx) => { + // In production: return session?.user?.id ?? null + return "olliethedev" + }, + }), }, adapter: (db) => createMemoryAdapter(db)({}) }) diff --git a/examples/react-router/app/routes/pages/_layout.tsx b/examples/react-router/app/routes/pages/_layout.tsx index 225e752e..22fc738d 100644 --- a/examples/react-router/app/routes/pages/_layout.tsx +++ b/examples/react-router/app/routes/pages/_layout.tsx @@ -8,6 +8,8 @@ import { ChatLayout } from "@btst/stack/plugins/ai-chat/client" import type { CMSPluginOverrides } from "@btst/stack/plugins/cms/client" import type { FormBuilderPluginOverrides } from "@btst/stack/plugins/form-builder/client" import type { KanbanPluginOverrides } from "@btst/stack/plugins/kanban/client" +import type { CommentsPluginOverrides } from "@btst/stack/plugins/comments/client" +import { CommentThread } from "@btst/stack/plugins/comments/client/components" import { resolveUser, searchUsers } from "../../lib/mock-users" // Get base URL function - works on both server and client @@ -39,6 +41,7 @@ async function mockUploadFile(file: File): Promise { cms: CMSPluginOverrides, "form-builder": FormBuilderPluginOverrides, kanban: KanbanPluginOverrides, + comments: CommentsPluginOverrides, } export default function Layout() { @@ -88,6 +91,18 @@ export default function Layout() { console.log(`[${context.isSSR ? 'SSR' : 'CSR'}] onBeforePostPageRendered: checking access for`, slug, context.path); return true; }, + // Wire comments into the bottom of each blog post + postBottomSlot: (post) => ( + + ), }, "ai-chat": { mode: "authenticated", @@ -202,10 +217,39 @@ export default function Layout() { // User resolution for assignees resolveUser, searchUsers, + // Wire comments into task detail dialogs + taskDetailBottomSlot: (task) => ( + + ), // Lifecycle hooks onRouteRender: async (routeName, context) => { console.log(`[${context.isSSR ? 'SSR' : 'CSR'}] Kanban route:`, routeName, context.path); }, + }, + comments: { + apiBaseURL: baseURL, + apiBasePath: "/api/data", + // In production: derive from your auth session + currentUserId: "olliethedev", + defaultCommentPageSize: 5, + resourceLinks: { + "blog-post": (slug) => `/pages/blog/${slug}`, + }, + onBeforeModerationPageRendered: (context) => { + console.log(`[${context.isSSR ? 'SSR' : 'CSR'}] onBeforeModerationPageRendered`); + return true; // In production: check admin role + }, + onBeforeMyCommentsPageRendered: (context) => { + console.log(`[${context.isSSR ? 'SSR' : 'CSR'}] onBeforeMyCommentsPageRendered`); + return true; // In production: check authenticated session + }, } }} > diff --git a/examples/tanstack/src/lib/stack-client.tsx b/examples/tanstack/src/lib/stack-client.tsx index 5cb52761..043ce077 100644 --- a/examples/tanstack/src/lib/stack-client.tsx +++ b/examples/tanstack/src/lib/stack-client.tsx @@ -6,6 +6,7 @@ import { formBuilderClientPlugin } from "@btst/stack/plugins/form-builder/client import { uiBuilderClientPlugin, defaultComponentRegistry } from "@btst/stack/plugins/ui-builder/client" import { routeDocsClientPlugin } from "@btst/stack/plugins/route-docs/client" import { kanbanClientPlugin } from "@btst/stack/plugins/kanban/client" +import { commentsClientPlugin } from "@btst/stack/plugins/comments/client" import { QueryClient } from "@tanstack/react-query" // Get base URL function - works on both server and client @@ -133,6 +134,14 @@ export const getStackClient = (queryClient: QueryClient) => { description: "Manage your projects with kanban boards", }, }), + // Comments plugin — registers the /comments/moderation admin route + comments: commentsClientPlugin({ + apiBaseURL: baseURL, + apiBasePath: "/api/data", + siteBaseURL: baseURL, + siteBasePath: "/pages", + queryClient: queryClient, + }), } }) } diff --git a/examples/tanstack/src/lib/stack.ts b/examples/tanstack/src/lib/stack.ts index ac4b0be1..f82dccc6 100644 --- a/examples/tanstack/src/lib/stack.ts +++ b/examples/tanstack/src/lib/stack.ts @@ -6,6 +6,7 @@ import { cmsBackendPlugin } from "@btst/stack/plugins/cms/api" import { formBuilderBackendPlugin } from "@btst/stack/plugins/form-builder/api" import { openApiBackendPlugin } from "@btst/stack/plugins/open-api/api" import { kanbanBackendPlugin } from "@btst/stack/plugins/kanban/api" +import { commentsBackendPlugin } from "@btst/stack/plugins/comments/api" import { UI_BUILDER_CONTENT_TYPE } from "@btst/stack/plugins/ui-builder" import { openai } from "@ai-sdk/openai" @@ -153,6 +154,52 @@ const { handler, dbSchema } = stack({ console.log("Board created:", board.id, board.name); }, }), + // Comments plugin for threaded discussions + comments: commentsBackendPlugin({ + autoApprove: false, + resolveUser: async (authorId) => { + // In production: look up your auth system's user by authorId + return { name: `User ${authorId}` } + }, + onBeforeList: async (query, ctx) => { + // Restrict pending/spam queues to admin sessions. + // Without this check a no-op hook would bypass the built-in 403 guard. + if (query.status && query.status !== "approved") { + // In production: replace with a real session/role check, e.g.: + // const session = await getSession(ctx.headers) + // if (!session?.user?.isAdmin) throw new Error("Admin access required") + console.log("onBeforeList: non-approved status filter — ensure admin check in production") + } + }, + onBeforePost: async (input, ctx) => { + // In production: verify the session and return the authenticated user's ID + console.log("onBeforePost: new comment on", input.resourceType, input.resourceId) + return { authorId: "olliethedev" } // In production: return { authorId: session.user.id } + }, + onBeforeLike: async (commentId, authorId, ctx) => { + // In production: verify authorId matches the authenticated session + console.log("onBeforeLike: user", authorId, "toggling like on comment", commentId) + }, + onBeforeStatusChange: async (commentId, status, ctx) => { + // In production: verify the caller has admin/moderator role + console.log("onBeforeStatusChange: comment", commentId, "->", status) + }, + onBeforeDelete: async (commentId, ctx) => { + // In production: verify the caller has admin/moderator role + console.log("onBeforeDelete: comment", commentId) + }, + onBeforeListByAuthor: async (authorId, query, ctx) => { + // In production: verify authorId matches the authenticated session + // const session = await getSession(ctx.headers) + // if (!session?.user) throw new Error("Authentication required") + // if (authorId !== session.user.id && !session.user.isAdmin) throw new Error("Forbidden") + if (authorId !== "olliethedev") throw new Error("Forbidden") + }, + resolveCurrentUserId: async (ctx) => { + // In production: return session?.user?.id ?? null + return "olliethedev" + }, + }), }, adapter: (db) => createMemoryAdapter(db)({}) }) diff --git a/examples/tanstack/src/routes/api/data/$.ts b/examples/tanstack/src/routes/api/data/$.ts index fba2a048..ca1bfb81 100644 --- a/examples/tanstack/src/routes/api/data/$.ts +++ b/examples/tanstack/src/routes/api/data/$.ts @@ -14,6 +14,9 @@ export const Route = createFileRoute("/api/data/$")({ PUT: async ({ request }) => { return handler(request) }, + PATCH: async ({ request }) => { + return handler(request) + }, DELETE: async ({ request }) => { return handler(request) }, diff --git a/examples/tanstack/src/routes/pages/route.tsx b/examples/tanstack/src/routes/pages/route.tsx index cc2bac81..ef2507bd 100644 --- a/examples/tanstack/src/routes/pages/route.tsx +++ b/examples/tanstack/src/routes/pages/route.tsx @@ -8,6 +8,8 @@ import { ChatLayout } from "@btst/stack/plugins/ai-chat/client" import type { CMSPluginOverrides } from "@btst/stack/plugins/cms/client" import type { FormBuilderPluginOverrides } from "@btst/stack/plugins/form-builder/client" import type { KanbanPluginOverrides } from "@btst/stack/plugins/kanban/client" +import type { CommentsPluginOverrides } from "@btst/stack/plugins/comments/client" +import { CommentThread } from "@btst/stack/plugins/comments/client/components" import { resolveUser, searchUsers } from "../../lib/mock-users" import { Link, useRouter, Outlet, createFileRoute } from "@tanstack/react-router" @@ -40,6 +42,7 @@ type PluginOverrides = { cms: CMSPluginOverrides, "form-builder": FormBuilderPluginOverrides, kanban: KanbanPluginOverrides, + comments: CommentsPluginOverrides, } export const Route = createFileRoute('/pages')({ @@ -97,6 +100,18 @@ function Layout() { console.log(`[${context.isSSR ? 'SSR' : 'CSR'}] onBeforePostPageRendered: checking access for`, slug, context.path); return true; }, + // Wire comments into the bottom of each blog post + postBottomSlot: (post) => ( + + ), }, "ai-chat": { mode: "authenticated", @@ -211,10 +226,39 @@ function Layout() { // User resolution for assignees resolveUser, searchUsers, + // Wire comments into task detail dialogs + taskDetailBottomSlot: (task) => ( + + ), // Lifecycle hooks onRouteRender: async (routeName, context) => { console.log(`[${context.isSSR ? 'SSR' : 'CSR'}] Kanban route:`, routeName, context.path); }, + }, + comments: { + apiBaseURL: baseURL, + apiBasePath: "/api/data", + // In production: derive from your auth session + currentUserId: "olliethedev", + defaultCommentPageSize: 5, + resourceLinks: { + "blog-post": (slug) => `/pages/blog/${slug}`, + }, + onBeforeModerationPageRendered: (context) => { + console.log(`[${context.isSSR ? 'SSR' : 'CSR'}] onBeforeModerationPageRendered`); + return true; // In production: check admin role + }, + onBeforeMyCommentsPageRendered: (context) => { + console.log(`[${context.isSSR ? 'SSR' : 'CSR'}] onBeforeMyCommentsPageRendered`); + return true; // In production: check authenticated session + }, } }} > diff --git a/examples/tanstack/src/styles/globals.css b/examples/tanstack/src/styles/globals.css index 57c5835a..59c07329 100644 --- a/examples/tanstack/src/styles/globals.css +++ b/examples/tanstack/src/styles/globals.css @@ -7,6 +7,7 @@ @import "@btst/stack/plugins/ai-chat/css"; @import "@btst/stack/plugins/ui-builder/css"; @import "@btst/stack/plugins/kanban/css"; +@import "@btst/stack/plugins/comments/css"; @custom-variant dark (&:is(.dark *)); diff --git a/packages/stack/build.config.ts b/packages/stack/build.config.ts index 693ba112..0994dac2 100644 --- a/packages/stack/build.config.ts +++ b/packages/stack/build.config.ts @@ -104,6 +104,12 @@ export default defineBuildConfig({ "./src/plugins/kanban/client/components/index.tsx", "./src/plugins/kanban/client/hooks/index.tsx", "./src/plugins/kanban/query-keys.ts", + // comments plugin entries + "./src/plugins/comments/api/index.ts", + "./src/plugins/comments/client/index.ts", + "./src/plugins/comments/client/components/index.tsx", + "./src/plugins/comments/client/hooks/index.tsx", + "./src/plugins/comments/query-keys.ts", "./src/components/auto-form/index.ts", "./src/components/stepped-auto-form/index.ts", "./src/components/multi-select/index.ts", diff --git a/packages/stack/package.json b/packages/stack/package.json index 0636a5b3..b5600589 100644 --- a/packages/stack/package.json +++ b/packages/stack/package.json @@ -1,6 +1,6 @@ { "name": "@btst/stack", - "version": "2.7.0", + "version": "2.8.0", "description": "A composable, plugin-based library for building full-stack applications.", "repository": { "type": "git", @@ -363,6 +363,57 @@ } }, "./plugins/kanban/css": "./dist/plugins/kanban/style.css", + "./plugins/comments/api": { + "import": { + "types": "./dist/plugins/comments/api/index.d.ts", + "default": "./dist/plugins/comments/api/index.mjs" + }, + "require": { + "types": "./dist/plugins/comments/api/index.d.cts", + "default": "./dist/plugins/comments/api/index.cjs" + } + }, + "./plugins/comments/client": { + "import": { + "types": "./dist/plugins/comments/client/index.d.ts", + "default": "./dist/plugins/comments/client/index.mjs" + }, + "require": { + "types": "./dist/plugins/comments/client/index.d.cts", + "default": "./dist/plugins/comments/client/index.cjs" + } + }, + "./plugins/comments/client/components": { + "import": { + "types": "./dist/plugins/comments/client/components/index.d.ts", + "default": "./dist/plugins/comments/client/components/index.mjs" + }, + "require": { + "types": "./dist/plugins/comments/client/components/index.d.cts", + "default": "./dist/plugins/comments/client/components/index.cjs" + } + }, + "./plugins/comments/client/hooks": { + "import": { + "types": "./dist/plugins/comments/client/hooks/index.d.ts", + "default": "./dist/plugins/comments/client/hooks/index.mjs" + }, + "require": { + "types": "./dist/plugins/comments/client/hooks/index.d.cts", + "default": "./dist/plugins/comments/client/hooks/index.cjs" + } + }, + "./plugins/comments/query-keys": { + "import": { + "types": "./dist/plugins/comments/query-keys.d.ts", + "default": "./dist/plugins/comments/query-keys.mjs" + }, + "require": { + "types": "./dist/plugins/comments/query-keys.d.cts", + "default": "./dist/plugins/comments/query-keys.cjs" + } + }, + "./plugins/comments/css": "./dist/plugins/comments/style.css", "./plugins/route-docs/client": { "import": { "types": "./dist/plugins/route-docs/client/index.d.ts", @@ -544,6 +595,21 @@ "plugins/kanban/client/hooks": [ "./dist/plugins/kanban/client/hooks/index.d.ts" ], + "plugins/comments/api": [ + "./dist/plugins/comments/api/index.d.ts" + ], + "plugins/comments/client": [ + "./dist/plugins/comments/client/index.d.ts" + ], + "plugins/comments/client/components": [ + "./dist/plugins/comments/client/components/index.d.ts" + ], + "plugins/comments/client/hooks": [ + "./dist/plugins/comments/client/hooks/index.d.ts" + ], + "plugins/comments/query-keys": [ + "./dist/plugins/comments/query-keys.d.ts" + ], "plugins/route-docs/client": [ "./dist/plugins/route-docs/client/index.d.ts" ], @@ -600,7 +666,6 @@ "react-dom": "^18.0.0 || ^19.0.0", "react-error-boundary": ">=4.0.0", "react-hook-form": ">=7.55.0", - "react-intersection-observer": ">=9.0.0", "react-markdown": ">=9.1.0", "rehype-highlight": ">=7.0.0", "rehype-katex": ">=7.0.0", diff --git a/packages/stack/registry/btst-blog.json b/packages/stack/registry/btst-blog.json index e8ebe3d5..f3f6ca66 100644 --- a/packages/stack/registry/btst-blog.json +++ b/packages/stack/registry/btst-blog.json @@ -10,7 +10,6 @@ "@milkdown/kit", "date-fns", "highlight.js", - "react-intersection-observer", "react-markdown", "rehype-highlight", "rehype-katex", @@ -122,12 +121,24 @@ "content": "import {\n\tCard,\n\tCardContent,\n\tCardFooter,\n\tCardHeader,\n} from \"@/components/ui/card\";\nimport { Skeleton } from \"@/components/ui/skeleton\";\n\nexport function PostCardSkeleton() {\n\treturn (\n\t\t\n\t\t\t\n\t\t\t\t\n\t\t\t\n\t\t\t\n\t\t\t\t\n\t\t\t\t\n\t\t\t\n\t\t\t\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\n\t\t\t\t\n\t\t\t\n\t\t\n\t);\n}\n", "target": "src/components/btst/blog/client/components/loading/post-card-skeleton.tsx" }, + { + "path": "btst/blog/client/components/loading/post-navigation-skeleton.tsx", + "type": "registry:component", + "content": "import { Skeleton } from \"@/components/ui/skeleton\";\n\nexport function PostNavigationSkeleton() {\n\treturn (\n\t\t\n\t\t\t\n\t\t\t\n\t\t\n\t);\n}\n", + "target": "src/components/btst/blog/client/components/loading/post-navigation-skeleton.tsx" + }, { "path": "btst/blog/client/components/loading/post-page-skeleton.tsx", "type": "registry:component", "content": "import { PageHeaderSkeleton } from \"./page-header-skeleton\";\nimport { PageLayout } from \"@/components/ui/page-layout\";\nimport { Skeleton } from \"@/components/ui/skeleton\";\n\nexport function PostPageSkeleton() {\n\treturn (\n\t\t\n\t\t\t\n\t\t\t\t\n\t\t\t\n\t\t\t\n\t\t\n\t);\n}\n\nfunction PostSkeleton() {\n\treturn (\n\t\t\n\t\t\t{/* Title + Meta + Tags */}\n\t\t\t\n\t\t\t\t{/* Title */}\n\t\t\t\t\n\n\t\t\t\t{/* Meta: avatar, author, date */}\n\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\n\n\t\t\t\t{/* Tags */}\n\t\t\t\t\n\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{/* Hero / Cover image */}\n\t\t\t\n\n\t\t\t{/* Content blocks */}\n\t\t\t\n\t\t\t\t\n\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\nfunction ContentBlockSkeleton() {\n\treturn (\n\t\t\n\t\t\t{/* Section heading */}\n\t\t\t\n\t\t\t{/* Paragraph lines */}\n\t\t\t\n\t\t\t\t\n\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\nfunction ImageBlockSkeleton() {\n\treturn ;\n}\n\nfunction CodeBlockSkeleton() {\n\treturn ;\n}\n", "target": "src/components/btst/blog/client/components/loading/post-page-skeleton.tsx" }, + { + "path": "btst/blog/client/components/loading/recent-posts-carousel-skeleton.tsx", + "type": "registry:component", + "content": "import { Skeleton } from \"@/components/ui/skeleton\";\nimport { PostCardSkeleton } from \"./post-card-skeleton\";\n\nexport function RecentPostsCarouselSkeleton() {\n\treturn (\n\t\t\n\t\t\t\n\t\t\t\t\n\t\t\t\t\n\t\t\t\n\t\t\t\n\t\t\t\t{[1, 2, 3].map((i) => (\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/blog/client/components/loading/recent-posts-carousel-skeleton.tsx" + }, { "path": "btst/blog/client/components/pages/404-page.tsx", "type": "registry:page", @@ -179,7 +190,7 @@ { "path": "btst/blog/client/components/pages/post-page.internal.tsx", "type": "registry:component", - "content": "\"use client\";\n\nimport { usePluginOverrides, useBasePath } from \"@btst/stack/context\";\nimport { formatDate } from \"date-fns\";\nimport {\n\tuseSuspensePost,\n\tuseNextPreviousPosts,\n\tuseRecentPosts,\n} from \"@btst/stack/plugins/blog/client/hooks\";\nimport { EmptyList } from \"../shared/empty-list\";\nimport { MarkdownContent } from \"../shared/markdown-content\";\nimport { PageHeader } from \"../shared/page-header\";\nimport { PageWrapper } from \"../shared/page-wrapper\";\nimport type { BlogPluginOverrides } from \"../../overrides\";\nimport { DefaultImage, DefaultLink } from \"../shared/defaults\";\nimport { BLOG_LOCALIZATION } from \"../../localization\";\nimport { PostNavigation } from \"../shared/post-navigation\";\nimport { RecentPostsCarousel } from \"../shared/recent-posts-carousel\";\nimport { Badge } from \"@/components/ui/badge\";\nimport { useRouteLifecycle } from \"@/hooks/use-route-lifecycle\";\nimport { OnThisPage, OnThisPageSelect } from \"../shared/on-this-page\";\nimport type { SerializedPost } from \"../../../types\";\nimport { useRegisterPageAIContext } from \"@btst/stack/plugins/ai-chat/client/context\";\n\n// Internal component with actual page content\nexport function PostPage({ slug }: { slug: string }) {\n\tconst overrides = usePluginOverrides<\n\t\tBlogPluginOverrides,\n\t\tPartial\n\t>(\"blog\", {\n\t\tImage: DefaultImage,\n\t\tlocalization: BLOG_LOCALIZATION,\n\t});\n\tconst { Image, localization } = overrides;\n\n\t// Call lifecycle hooks\n\tuseRouteLifecycle({\n\t\trouteName: \"post\",\n\t\tcontext: {\n\t\t\tpath: `/blog/${slug}`,\n\t\t\tparams: { slug },\n\t\t\tisSSR: typeof window === \"undefined\",\n\t\t},\n\t\toverrides,\n\t\tbeforeRenderHook: (overrides, context) => {\n\t\t\tif (overrides.onBeforePostPageRendered) {\n\t\t\t\treturn overrides.onBeforePostPageRendered(slug, context);\n\t\t\t}\n\t\t\treturn true;\n\t\t},\n\t});\n\n\tconst { post } = useSuspensePost(slug ?? \"\");\n\n\tconst { previousPost, nextPost, ref } = useNextPreviousPosts(\n\t\tpost?.createdAt ?? new Date(),\n\t\t{\n\t\t\tenabled: !!post,\n\t\t},\n\t);\n\n\tconst { recentPosts, ref: recentPostsRef } = useRecentPosts({\n\t\tlimit: 5,\n\t\texcludeSlug: slug,\n\t\tenabled: !!post,\n\t});\n\n\t// Register page AI context so the chat can summarize and discuss this post\n\tuseRegisterPageAIContext(\n\t\tpost\n\t\t\t? {\n\t\t\t\t\trouteName: \"blog-post\",\n\t\t\t\t\tpageDescription:\n\t\t\t\t\t\t`Blog post: \"${post.title}\"\\nAuthor: ${post.authorId ?? \"Unknown\"}\\n\\n${post.content ?? \"\"}`.slice(\n\t\t\t\t\t\t\t0,\n\t\t\t\t\t\t\t16000,\n\t\t\t\t\t\t),\n\t\t\t\t\tsuggestions: [\n\t\t\t\t\t\t\"Summarize this post\",\n\t\t\t\t\t\t\"What are the key takeaways?\",\n\t\t\t\t\t\t\"Explain this in simpler terms\",\n\t\t\t\t\t],\n\t\t\t\t}\n\t\t\t: null,\n\t);\n\n\tif (!slug || !post) {\n\t\treturn (\n\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\n\t\t\t\t\t\n\n\t\t\t\t\t}\n\t\t\t\t\t/>\n\n\t\t\t\t\t{post.image && (\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\n\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\n\n\t\t\t\t\t\n\t\t\t\t\t\t\n\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\n\t\t\n\t);\n}\n\nfunction PostHeaderTop({ post }: { post: SerializedPost }) {\n\tconst { Link } = usePluginOverrides<\n\t\tBlogPluginOverrides,\n\t\tPartial\n\t>(\"blog\", {\n\t\tLink: DefaultLink,\n\t});\n\tconst basePath = useBasePath();\n\treturn (\n\t\t\n\t\t\t\n\t\t\t\t{formatDate(post.createdAt, \"MMMM d, yyyy\")}\n\t\t\t\n\t\t\t{post.tags && post.tags.length > 0 && (\n\t\t\t\t<>\n\t\t\t\t\t{post.tags.map((tag) => (\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t{tag.name}\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", + "content": "\"use client\";\n\nimport { usePluginOverrides, useBasePath } from \"@btst/stack/context\";\nimport { formatDate } from \"date-fns\";\nimport {\n\tuseSuspensePost,\n\tuseNextPreviousPosts,\n\tuseRecentPosts,\n} from \"@btst/stack/plugins/blog/client/hooks\";\nimport { EmptyList } from \"../shared/empty-list\";\nimport { MarkdownContent } from \"../shared/markdown-content\";\nimport { PageHeader } from \"../shared/page-header\";\nimport { PageWrapper } from \"../shared/page-wrapper\";\nimport type { BlogPluginOverrides } from \"../../overrides\";\nimport { DefaultImage, DefaultLink } from \"../shared/defaults\";\nimport { BLOG_LOCALIZATION } from \"../../localization\";\nimport { PostNavigation } from \"../shared/post-navigation\";\nimport { RecentPostsCarousel } from \"../shared/recent-posts-carousel\";\nimport { Badge } from \"@/components/ui/badge\";\nimport { useRouteLifecycle } from \"@/hooks/use-route-lifecycle\";\nimport { OnThisPage, OnThisPageSelect } from \"../shared/on-this-page\";\nimport type { SerializedPost } from \"../../../types\";\nimport { useRegisterPageAIContext } from \"@btst/stack/plugins/ai-chat/client/context\";\nimport { WhenVisible } from \"@/components/ui/when-visible\";\nimport { PostNavigationSkeleton } from \"../loading/post-navigation-skeleton\";\nimport { RecentPostsCarouselSkeleton } from \"../loading/recent-posts-carousel-skeleton\";\n\n// Internal component with actual page content\nexport function PostPage({ slug }: { slug: string }) {\n\tconst overrides = usePluginOverrides<\n\t\tBlogPluginOverrides,\n\t\tPartial\n\t>(\"blog\", {\n\t\tImage: DefaultImage,\n\t\tlocalization: BLOG_LOCALIZATION,\n\t});\n\tconst { Image, localization } = overrides;\n\n\t// Call lifecycle hooks\n\tuseRouteLifecycle({\n\t\trouteName: \"post\",\n\t\tcontext: {\n\t\t\tpath: `/blog/${slug}`,\n\t\t\tparams: { slug },\n\t\t\tisSSR: typeof window === \"undefined\",\n\t\t},\n\t\toverrides,\n\t\tbeforeRenderHook: (overrides, context) => {\n\t\t\tif (overrides.onBeforePostPageRendered) {\n\t\t\t\treturn overrides.onBeforePostPageRendered(slug, context);\n\t\t\t}\n\t\t\treturn true;\n\t\t},\n\t});\n\n\tconst { post } = useSuspensePost(slug ?? \"\");\n\n\tconst { previousPost, nextPost } = useNextPreviousPosts(\n\t\tpost?.createdAt ?? new Date(),\n\t\t{\n\t\t\tenabled: !!post,\n\t\t},\n\t);\n\n\tconst { recentPosts } = useRecentPosts({\n\t\tlimit: 5,\n\t\texcludeSlug: slug,\n\t\tenabled: !!post,\n\t});\n\n\t// Register page AI context so the chat can summarize and discuss this post\n\tuseRegisterPageAIContext(\n\t\tpost\n\t\t\t? {\n\t\t\t\t\trouteName: \"blog-post\",\n\t\t\t\t\tpageDescription:\n\t\t\t\t\t\t`Blog post: \"${post.title}\"\\nAuthor: ${post.authorId ?? \"Unknown\"}\\n\\n${post.content ?? \"\"}`.slice(\n\t\t\t\t\t\t\t0,\n\t\t\t\t\t\t\t16000,\n\t\t\t\t\t\t),\n\t\t\t\t\tsuggestions: [\n\t\t\t\t\t\t\"Summarize this post\",\n\t\t\t\t\t\t\"What are the key takeaways?\",\n\t\t\t\t\t\t\"Explain this in simpler terms\",\n\t\t\t\t\t],\n\t\t\t\t}\n\t\t\t: null,\n\t);\n\n\tif (!slug || !post) {\n\t\treturn (\n\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\n\t\t\t\t\t\n\n\t\t\t\t\t}\n\t\t\t\t\t/>\n\n\t\t\t\t\t{post.image && (\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\n\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\n\n\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\n\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\n\n\t\t\t\t\t\t{overrides.postBottomSlot && (\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t{overrides.postBottomSlot(post)}\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\t\n\t\t\t\n\t\t\n\t);\n}\n\nfunction PostHeaderTop({ post }: { post: SerializedPost }) {\n\tconst { Link } = usePluginOverrides<\n\t\tBlogPluginOverrides,\n\t\tPartial\n\t>(\"blog\", {\n\t\tLink: DefaultLink,\n\t});\n\tconst basePath = useBasePath();\n\treturn (\n\t\t\n\t\t\t\n\t\t\t\t{formatDate(post.createdAt, \"MMMM d, yyyy\")}\n\t\t\t\n\t\t\t{post.tags && post.tags.length > 0 && (\n\t\t\t\t<>\n\t\t\t\t\t{post.tags.map((tag) => (\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t{tag.name}\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", "target": "src/components/btst/blog/client/components/pages/post-page.internal.tsx" }, { @@ -269,7 +280,7 @@ { "path": "btst/blog/client/components/shared/post-navigation.tsx", "type": "registry:component", - "content": "\"use client\";\n\nimport { usePluginOverrides, useBasePath } from \"@btst/stack/context\";\nimport { Button } from \"@/components/ui/button\";\nimport { ChevronLeft, ChevronRight } from \"lucide-react\";\nimport type { BlogPluginOverrides } from \"../../overrides\";\nimport { DefaultLink } from \"./defaults\";\nimport type { SerializedPost } from \"../../../types\";\n\ninterface PostNavigationProps {\n\tpreviousPost: SerializedPost | null;\n\tnextPost: SerializedPost | null;\n\tref?: (node: Element | null) => void;\n}\n\nexport function PostNavigation({\n\tpreviousPost,\n\tnextPost,\n\tref,\n}: PostNavigationProps) {\n\tconst { Link } = usePluginOverrides<\n\t\tBlogPluginOverrides,\n\t\tPartial\n\t>(\"blog\", {\n\t\tLink: DefaultLink,\n\t});\n\tconst basePath = useBasePath();\n\tconst blogPath = `${basePath}/blog`;\n\n\treturn (\n\t\t<>\n\t\t\t{/* Ref div to trigger intersection observer when scrolled into view */}\n\t\t\t{ref && }\n\n\t\t\t{/* Only show navigation buttons if posts are available */}\n\t\t\t{(previousPost || nextPost) && (\n\t\t\t\t<>\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\t{previousPost ? (\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\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\tPrevious\n\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\t\t{previousPost.title}\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\t\t\n\t\t\t\t\t\t)}\n\n\t\t\t\t\t\t{nextPost ? (\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\n\t\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t\tNext\n\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\t\t{nextPost.title}\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\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\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", + "content": "\"use client\";\n\nimport { usePluginOverrides, useBasePath } from \"@btst/stack/context\";\nimport { Button } from \"@/components/ui/button\";\nimport { ChevronLeft, ChevronRight } from \"lucide-react\";\nimport type { BlogPluginOverrides } from \"../../overrides\";\nimport { DefaultLink } from \"./defaults\";\nimport type { SerializedPost } from \"../../../types\";\n\ninterface PostNavigationProps {\n\tpreviousPost: SerializedPost | null;\n\tnextPost: SerializedPost | null;\n}\n\nexport function PostNavigation({\n\tpreviousPost,\n\tnextPost,\n}: PostNavigationProps) {\n\tconst { Link } = usePluginOverrides<\n\t\tBlogPluginOverrides,\n\t\tPartial\n\t>(\"blog\", {\n\t\tLink: DefaultLink,\n\t});\n\tconst basePath = useBasePath();\n\tconst blogPath = `${basePath}/blog`;\n\n\treturn (\n\t\t<>\n\t\t\t{/* Only show navigation buttons if posts are available */}\n\t\t\t{(previousPost || nextPost) && (\n\t\t\t\t<>\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\t{previousPost ? (\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\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\tPrevious\n\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\t\t{previousPost.title}\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\t\t\n\t\t\t\t\t\t)}\n\n\t\t\t\t\t\t{nextPost ? (\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\n\t\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t\tNext\n\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\t\t{nextPost.title}\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\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\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", "target": "src/components/btst/blog/client/components/shared/post-navigation.tsx" }, { @@ -281,7 +292,7 @@ { "path": "btst/blog/client/components/shared/recent-posts-carousel.tsx", "type": "registry:component", - "content": "\"use client\";\n\nimport { useBasePath, usePluginOverrides } from \"@btst/stack/context\";\nimport type { BlogPluginOverrides } from \"../../overrides\";\nimport type { SerializedPost } from \"../../../types\";\nimport {\n\tCarousel,\n\tCarouselContent,\n\tCarouselItem,\n\tCarouselNext,\n\tCarouselPrevious,\n} from \"@/components/ui/carousel\";\nimport { PostCard as DefaultPostCard } from \"./post-card\";\nimport { DefaultLink } from \"./defaults\";\nimport { BLOG_LOCALIZATION } from \"../../localization\";\n\ninterface RecentPostsCarouselProps {\n\tposts: SerializedPost[];\n\tref?: (node: Element | null) => void;\n}\n\nexport function RecentPostsCarousel({ posts, ref }: RecentPostsCarouselProps) {\n\tconst { PostCard, Link, localization } = usePluginOverrides<\n\t\tBlogPluginOverrides,\n\t\tPartial\n\t>(\"blog\", {\n\t\tPostCard: DefaultPostCard,\n\t\tLink: DefaultLink,\n\t\tlocalization: BLOG_LOCALIZATION,\n\t});\n\tconst PostCardComponent = PostCard || DefaultPostCard;\n\tconst basePath = useBasePath();\n\treturn (\n\t\t\n\t\t\t{/* Ref div to trigger intersection observer when scrolled into view */}\n\t\t\t{ref && }\n\n\t\t\t{posts && posts.length > 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\t\n\t\t\t\t\t\t\t\t{localization.BLOG_POST_KEEP_READING}\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.BLOG_POST_VIEW_ALL}\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\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t{posts.map((post) => (\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\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\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 { useBasePath, usePluginOverrides } from \"@btst/stack/context\";\nimport type { BlogPluginOverrides } from \"../../overrides\";\nimport type { SerializedPost } from \"../../../types\";\nimport {\n\tCarousel,\n\tCarouselContent,\n\tCarouselItem,\n\tCarouselNext,\n\tCarouselPrevious,\n} from \"@/components/ui/carousel\";\nimport { PostCard as DefaultPostCard } from \"./post-card\";\nimport { DefaultLink } from \"./defaults\";\nimport { BLOG_LOCALIZATION } from \"../../localization\";\n\ninterface RecentPostsCarouselProps {\n\tposts: SerializedPost[];\n}\n\nexport function RecentPostsCarousel({ posts }: RecentPostsCarouselProps) {\n\tconst { PostCard, Link, localization } = usePluginOverrides<\n\t\tBlogPluginOverrides,\n\t\tPartial\n\t>(\"blog\", {\n\t\tPostCard: DefaultPostCard,\n\t\tLink: DefaultLink,\n\t\tlocalization: BLOG_LOCALIZATION,\n\t});\n\tconst PostCardComponent = PostCard || DefaultPostCard;\n\tconst basePath = useBasePath();\n\treturn (\n\t\t\n\t\t\t{posts && posts.length > 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\t\n\t\t\t\t\t\t\t\t{localization.BLOG_POST_KEEP_READING}\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.BLOG_POST_VIEW_ALL}\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\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t{posts.map((post) => (\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\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\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/blog/client/components/shared/recent-posts-carousel.tsx" }, { @@ -341,7 +352,7 @@ { "path": "btst/blog/client/overrides.ts", "type": "registry:lib", - "content": "import type { SerializedPost } from \"../types\";\nimport type { ComponentType } from \"react\";\nimport type { BlogLocalization } from \"./localization\";\n\n/**\n * Context passed to lifecycle hooks\n */\nexport interface RouteContext {\n\t/** Current route path */\n\tpath: string;\n\t/** Route parameters (e.g., { slug: \"my-post\" }) */\n\tparams?: Record;\n\t/** Whether rendering on server (true) or client (false) */\n\tisSSR: boolean;\n\t/** Additional context properties */\n\t[key: string]: any;\n}\n\n/**\n * Overridable components and functions for the Blog plugin\n *\n * External consumers can provide their own implementations of these\n * to customize the behavior for their framework (Next.js, React Router, etc.)\n */\nexport interface BlogPluginOverrides {\n\t/**\n\t * Link component for navigation\n\t */\n\tLink?: ComponentType & Record>;\n\t/**\n\t * Post card component for displaying a post\n\t */\n\tPostCard?: ComponentType<{\n\t\tpost: SerializedPost;\n\t}>;\n\t/**\n\t * Navigation function for programmatic navigation\n\t */\n\tnavigate: (path: string) => void | Promise;\n\t/**\n\t * Refresh function to invalidate server-side cache (e.g., Next.js router.refresh())\n\t */\n\trefresh?: () => void | Promise;\n\t/**\n\t * Image component for displaying images\n\t */\n\tImage?: ComponentType<\n\t\tReact.ImgHTMLAttributes & Record\n\t>;\n\t/**\n\t * Function used to upload an image and return its URL.\n\t */\n\tuploadImage: (file: File) => Promise;\n\t/**\n\t * Localization object for the blog plugin\n\t */\n\tlocalization?: BlogLocalization;\n\t/**\n\t * API base URL\n\t */\n\tapiBaseURL: string;\n\t/**\n\t * API base path\n\t */\n\tapiBasePath: string;\n\t/**\n\t * Whether to show the attribution\n\t */\n\tshowAttribution?: boolean;\n\t/**\n\t * Optional headers to pass with API requests (e.g., for SSR auth)\n\t */\n\theaders?: HeadersInit;\n\n\t// Lifecycle Hooks (optional)\n\t/**\n\t * Called when a route is rendered\n\t * @param routeName - Name of the route (e.g., 'posts', 'post', 'newPost')\n\t * @param context - Route context with path, params, etc.\n\t */\n\tonRouteRender?: (\n\t\trouteName: string,\n\t\tcontext: RouteContext,\n\t) => void | Promise;\n\n\t/**\n\t * Called when a route encounters an error\n\t * @param routeName - Name of the route\n\t * @param error - The error that occurred\n\t * @param context - Route context\n\t */\n\tonRouteError?: (\n\t\trouteName: string,\n\t\terror: Error,\n\t\tcontext: RouteContext,\n\t) => void | Promise;\n\n\t/**\n\t * Called before the posts list page is rendered\n\t * Return false to prevent rendering (e.g., for authorization)\n\t * @param context - Route context\n\t */\n\tonBeforePostsPageRendered?: (context: RouteContext) => boolean;\n\n\t/**\n\t * Called before a single post page is rendered\n\t * Return false to prevent rendering (e.g., for authorization)\n\t * @param slug - The post slug\n\t * @param context - Route context\n\t */\n\tonBeforePostPageRendered?: (slug: string, context: RouteContext) => boolean;\n\n\t/**\n\t * Called before the new post page is rendered\n\t * Return false to prevent rendering (e.g., for authorization)\n\t * @param context - Route context\n\t */\n\tonBeforeNewPostPageRendered?: (context: RouteContext) => boolean;\n\n\t/**\n\t * Called before the edit post page is rendered\n\t * Return false to prevent rendering (e.g., for authorization)\n\t * @param slug - The post slug being edited\n\t * @param context - Route context\n\t */\n\tonBeforeEditPostPageRendered?: (\n\t\tslug: string,\n\t\tcontext: RouteContext,\n\t) => boolean;\n\n\t/**\n\t * Called before the drafts page is rendered\n\t * Return false to prevent rendering (e.g., for authorization)\n\t * @param context - Route context\n\t */\n\tonBeforeDraftsPageRendered?: (context: RouteContext) => boolean;\n}\n", + "content": "import type { SerializedPost } from \"../types\";\nimport type { ComponentType, ReactNode } from \"react\";\nimport type { BlogLocalization } from \"./localization\";\n\n/**\n * Context passed to lifecycle hooks\n */\nexport interface RouteContext {\n\t/** Current route path */\n\tpath: string;\n\t/** Route parameters (e.g., { slug: \"my-post\" }) */\n\tparams?: Record;\n\t/** Whether rendering on server (true) or client (false) */\n\tisSSR: boolean;\n\t/** Additional context properties */\n\t[key: string]: any;\n}\n\n/**\n * Overridable components and functions for the Blog plugin\n *\n * External consumers can provide their own implementations of these\n * to customize the behavior for their framework (Next.js, React Router, etc.)\n */\nexport interface BlogPluginOverrides {\n\t/**\n\t * Link component for navigation\n\t */\n\tLink?: ComponentType & Record>;\n\t/**\n\t * Post card component for displaying a post\n\t */\n\tPostCard?: ComponentType<{\n\t\tpost: SerializedPost;\n\t}>;\n\t/**\n\t * Navigation function for programmatic navigation\n\t */\n\tnavigate: (path: string) => void | Promise;\n\t/**\n\t * Refresh function to invalidate server-side cache (e.g., Next.js router.refresh())\n\t */\n\trefresh?: () => void | Promise;\n\t/**\n\t * Image component for displaying images\n\t */\n\tImage?: ComponentType<\n\t\tReact.ImgHTMLAttributes & Record\n\t>;\n\t/**\n\t * Function used to upload an image and return its URL.\n\t */\n\tuploadImage: (file: File) => Promise;\n\t/**\n\t * Localization object for the blog plugin\n\t */\n\tlocalization?: BlogLocalization;\n\t/**\n\t * API base URL\n\t */\n\tapiBaseURL: string;\n\t/**\n\t * API base path\n\t */\n\tapiBasePath: string;\n\t/**\n\t * Whether to show the attribution\n\t */\n\tshowAttribution?: boolean;\n\t/**\n\t * Optional headers to pass with API requests (e.g., for SSR auth)\n\t */\n\theaders?: HeadersInit;\n\n\t// Lifecycle Hooks (optional)\n\t/**\n\t * Called when a route is rendered\n\t * @param routeName - Name of the route (e.g., 'posts', 'post', 'newPost')\n\t * @param context - Route context with path, params, etc.\n\t */\n\tonRouteRender?: (\n\t\trouteName: string,\n\t\tcontext: RouteContext,\n\t) => void | Promise;\n\n\t/**\n\t * Called when a route encounters an error\n\t * @param routeName - Name of the route\n\t * @param error - The error that occurred\n\t * @param context - Route context\n\t */\n\tonRouteError?: (\n\t\trouteName: string,\n\t\terror: Error,\n\t\tcontext: RouteContext,\n\t) => void | Promise;\n\n\t/**\n\t * Called before the posts list page is rendered\n\t * Return false to prevent rendering (e.g., for authorization)\n\t * @param context - Route context\n\t */\n\tonBeforePostsPageRendered?: (context: RouteContext) => boolean;\n\n\t/**\n\t * Called before a single post page is rendered\n\t * Return false to prevent rendering (e.g., for authorization)\n\t * @param slug - The post slug\n\t * @param context - Route context\n\t */\n\tonBeforePostPageRendered?: (slug: string, context: RouteContext) => boolean;\n\n\t/**\n\t * Called before the new post page is rendered\n\t * Return false to prevent rendering (e.g., for authorization)\n\t * @param context - Route context\n\t */\n\tonBeforeNewPostPageRendered?: (context: RouteContext) => boolean;\n\n\t/**\n\t * Called before the edit post page is rendered\n\t * Return false to prevent rendering (e.g., for authorization)\n\t * @param slug - The post slug being edited\n\t * @param context - Route context\n\t */\n\tonBeforeEditPostPageRendered?: (\n\t\tslug: string,\n\t\tcontext: RouteContext,\n\t) => boolean;\n\n\t/**\n\t * Called before the drafts page is rendered\n\t * Return false to prevent rendering (e.g., for authorization)\n\t * @param context - Route context\n\t */\n\tonBeforeDraftsPageRendered?: (context: RouteContext) => boolean;\n\n\t// ============ Slot Overrides ============\n\n\t/**\n\t * Optional slot rendered below the blog post body.\n\t * Use this to inject a comment thread or any custom content without\n\t * coupling the blog plugin to the comments plugin.\n\t *\n\t * @example\n\t * ```tsx\n\t * blog: {\n\t * postBottomSlot: (post) => (\n\t * \n\t * ),\n\t * }\n\t * ```\n\t */\n\tpostBottomSlot?: (post: SerializedPost) => ReactNode;\n}\n", "target": "src/components/btst/blog/client/overrides.ts" }, { @@ -362,6 +373,12 @@ "content": "\"use client\";\n\nimport { cn } from \"@/lib/utils\";\n\nexport interface PageLayoutProps {\n\tchildren: React.ReactNode;\n\tclassName?: string;\n\t\"data-testid\"?: string;\n}\n\n/**\n * Shared page layout component providing consistent container styling\n * for plugin pages. Used by blog, CMS, and other plugins.\n */\nexport function PageLayout({\n\tchildren,\n\tclassName,\n\t\"data-testid\": dataTestId,\n}: PageLayoutProps) {\n\treturn (\n\t\t\n\t\t\t{children}\n\t\t\n\t);\n}\n", "target": "src/components/ui/page-layout.tsx" }, + { + "path": "ui/components/when-visible.tsx", + "type": "registry:component", + "content": "\"use client\";\n\nimport { useEffect, useRef, useState, type ReactNode } from \"react\";\n\nexport interface WhenVisibleProps {\n\t/** Content to render once the element scrolls into view */\n\tchildren: ReactNode;\n\t/** Optional placeholder rendered before the element enters the viewport */\n\tfallback?: ReactNode;\n\t/** IntersectionObserver threshold (0–1). Defaults to 0 (any pixel visible). */\n\tthreshold?: number;\n\t/** Root margin passed to IntersectionObserver. Defaults to \"200px\" to preload slightly early. */\n\trootMargin?: string;\n\t/** Additional className applied to the sentinel wrapper div */\n\tclassName?: string;\n}\n\n/**\n * Lazy-mounts children only when the sentinel element scrolls into the viewport.\n * Once mounted, children remain mounted even if the element scrolls out of view.\n *\n * Use this to defer expensive renders (comment threads, carousels, etc.) until\n * the user actually scrolls to that section.\n */\nexport function WhenVisible({\n\tchildren,\n\tfallback = null,\n\tthreshold = 0,\n\trootMargin = \"200px\",\n\tclassName,\n}: WhenVisibleProps) {\n\tconst [isVisible, setIsVisible] = useState(false);\n\tconst sentinelRef = useRef(null);\n\n\tuseEffect(() => {\n\t\tconst el = sentinelRef.current;\n\t\tif (!el) return;\n\n\t\t// If IntersectionObserver is not available (SSR/old browsers), show immediately\n\t\tif (typeof IntersectionObserver === \"undefined\") {\n\t\t\tsetIsVisible(true);\n\t\t\treturn;\n\t\t}\n\n\t\tconst observer = new IntersectionObserver(\n\t\t\t(entries) => {\n\t\t\t\tconst entry = entries[0];\n\t\t\t\tif (entry?.isIntersecting) {\n\t\t\t\t\tsetIsVisible(true);\n\t\t\t\t\tobserver.disconnect();\n\t\t\t\t}\n\t\t\t},\n\t\t\t{ threshold, rootMargin },\n\t\t);\n\n\t\tobserver.observe(el);\n\t\treturn () => observer.disconnect();\n\t}, [threshold, rootMargin]);\n\n\treturn (\n\t\t\n\t\t\t{isVisible ? children : fallback}\n\t\t\n\t);\n}\n", + "target": "src/components/ui/when-visible.tsx" + }, { "path": "ui/components/empty.tsx", "type": "registry:component", diff --git a/packages/stack/registry/btst-cms.json b/packages/stack/registry/btst-cms.json index fbe81220..ea31783d 100644 --- a/packages/stack/registry/btst-cms.json +++ b/packages/stack/registry/btst-cms.json @@ -157,7 +157,7 @@ { "path": "btst/cms/client/components/shared/pagination.tsx", "type": "registry:component", - "content": "\"use client\";\n\nimport { Button } from \"@/components/ui/button\";\nimport { ChevronLeft, ChevronRight } from \"lucide-react\";\nimport { usePluginOverrides } from \"@btst/stack/context\";\nimport type { CMSPluginOverrides } from \"../../overrides\";\nimport { CMS_LOCALIZATION } from \"../../localization\";\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(\"cms\");\n\tconst localization = { ...CMS_LOCALIZATION, ...customLocalization };\n\n\tconst from = offset + 1;\n\tconst to = Math.min(offset + limit, total);\n\n\tif (totalPages <= 1) {\n\t\treturn null;\n\t}\n\n\treturn (\n\t\t\n\t\t\t\n\t\t\t\t{localization.CMS_LIST_PAGINATION_SHOWING.replace(\n\t\t\t\t\t\"{from}\",\n\t\t\t\t\tString(from),\n\t\t\t\t)\n\t\t\t\t\t.replace(\"{to}\", String(to))\n\t\t\t\t\t.replace(\"{total}\", String(total))}\n\t\t\t\n\t\t\t\n\t\t\t\t onPageChange(currentPage - 1)}\n\t\t\t\t\tdisabled={currentPage === 1}\n\t\t\t\t>\n\t\t\t\t\t\n\t\t\t\t\t{localization.CMS_LIST_PAGINATION_PREVIOUS}\n\t\t\t\t\n\t\t\t\t\n\t\t\t\t\t{currentPage} / {totalPages}\n\t\t\t\t\n\t\t\t\t onPageChange(currentPage + 1)}\n\t\t\t\t\tdisabled={currentPage === totalPages}\n\t\t\t\t>\n\t\t\t\t\t{localization.CMS_LIST_PAGINATION_NEXT}\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 { usePluginOverrides } from \"@btst/stack/context\";\nimport type { CMSPluginOverrides } from \"../../overrides\";\nimport { CMS_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(\"cms\");\n\tconst localization = { ...CMS_LOCALIZATION, ...customLocalization };\n\n\treturn (\n\t\t\n\t);\n}\n", "target": "src/components/btst/cms/client/components/shared/pagination.tsx" }, { @@ -256,6 +256,12 @@ "content": "\"use client\";\n\nimport { PageLayout } from \"./page-layout\";\nimport { StackAttribution } from \"./stack-attribution\";\n\nexport interface PageWrapperProps {\n\tchildren: React.ReactNode;\n\tclassName?: string;\n\ttestId?: string;\n\t/**\n\t * Whether to show the \"Powered by BTST\" attribution.\n\t * Defaults to true.\n\t */\n\tshowAttribution?: boolean;\n}\n\n/**\n * Shared page wrapper component providing consistent layout and optional attribution\n * for plugin pages. Used by blog, CMS, and other plugins.\n *\n * @example\n * ```tsx\n * \n * \n * My Page\n * \n * \n * ```\n */\nexport function PageWrapper({\n\tchildren,\n\tclassName,\n\ttestId,\n\tshowAttribution = true,\n}: PageWrapperProps) {\n\treturn (\n\t\t<>\n\t\t\t\n\t\t\t\t{children}\n\t\t\t\n\n\t\t\t{showAttribution && }\n\t\t>\n\t);\n}\n", "target": "src/components/ui/page-wrapper.tsx" }, + { + "path": "ui/components/pagination-controls.tsx", + "type": "registry:component", + "content": "\"use client\";\n\nimport { Button } from \"@/components/ui/button\";\nimport { ChevronLeft, ChevronRight } from \"lucide-react\";\n\nexport interface PaginationControlsProps {\n\t/** Current page, 1-based */\n\tcurrentPage: number;\n\ttotalPages: number;\n\ttotal: number;\n\tlimit: number;\n\toffset: number;\n\tonPageChange: (page: number) => void;\n\tlabels?: {\n\t\tprevious?: string;\n\t\tnext?: string;\n\t\t/** Template string; use {from}, {to}, {total} as placeholders */\n\t\tshowing?: string;\n\t};\n}\n\n/**\n * Generic Prev/Next pagination control with a \"Showing X–Y of Z\" label.\n * Plugin-agnostic — pass localized labels as props.\n * Returns null when totalPages ≤ 1.\n */\nexport function PaginationControls({\n\tcurrentPage,\n\ttotalPages,\n\ttotal,\n\tlimit,\n\toffset,\n\tonPageChange,\n\tlabels,\n}: PaginationControlsProps) {\n\tconst previous = labels?.previous ?? \"Previous\";\n\tconst next = labels?.next ?? \"Next\";\n\tconst showingTemplate = labels?.showing ?? \"Showing {from}–{to} of {total}\";\n\n\tconst from = offset + 1;\n\tconst to = Math.min(offset + limit, total);\n\n\tconst showingText = showingTemplate\n\t\t.replace(\"{from}\", String(from))\n\t\t.replace(\"{to}\", String(to))\n\t\t.replace(\"{total}\", String(total));\n\n\tif (totalPages <= 1) {\n\t\treturn null;\n\t}\n\n\treturn (\n\t\t\n\t\t\t{showingText}\n\t\t\t\n\t\t\t\t onPageChange(currentPage - 1)}\n\t\t\t\t\tdisabled={currentPage === 1}\n\t\t\t\t>\n\t\t\t\t\t\n\t\t\t\t\t{previous}\n\t\t\t\t\n\t\t\t\t\n\t\t\t\t\t{currentPage} / {totalPages}\n\t\t\t\t\n\t\t\t\t onPageChange(currentPage + 1)}\n\t\t\t\t\tdisabled={currentPage === totalPages}\n\t\t\t\t>\n\t\t\t\t\t{next}\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/ui/pagination-controls.tsx" + }, { "path": "ui/hooks/use-route-lifecycle.ts", "type": "registry:hook", diff --git a/packages/stack/registry/btst-comments.json b/packages/stack/registry/btst-comments.json new file mode 100644 index 00000000..20413a9d --- /dev/null +++ b/packages/stack/registry/btst-comments.json @@ -0,0 +1,152 @@ +{ + "name": "btst-comments", + "type": "registry:block", + "title": "Comments Plugin Pages", + "description": "Ejectable page components for the @btst/stack comments plugin. Customize the UI layer while keeping data-fetching in @btst/stack.", + "author": "BTST ", + "dependencies": [ + "@btst/stack", + "date-fns" + ], + "registryDependencies": [ + "alert-dialog", + "avatar", + "badge", + "button", + "checkbox", + "dialog", + "separator", + "table", + "tabs", + "textarea" + ], + "files": [ + { + "path": "btst/comments/types.ts", + "type": "registry:lib", + "content": "/**\n * Comment status values\n */\nexport type CommentStatus = \"pending\" | \"approved\" | \"spam\";\n\n/**\n * A comment record as stored in the database\n */\nexport type Comment = {\n\tid: string;\n\tresourceId: string;\n\tresourceType: string;\n\tparentId: string | null;\n\tauthorId: string;\n\tbody: string;\n\tstatus: CommentStatus;\n\tlikes: number;\n\teditedAt?: Date;\n\tcreatedAt: Date;\n\tupdatedAt: Date;\n};\n\n/**\n * A like record linking an author to a comment\n */\nexport type CommentLike = {\n\tid: string;\n\tcommentId: string;\n\tauthorId: string;\n\tcreatedAt: Date;\n};\n\n/**\n * A comment enriched with server-resolved author info and like status.\n * All dates are ISO strings (safe for serialisation over HTTP / React Query cache).\n */\nexport interface SerializedComment {\n\tid: string;\n\tresourceId: string;\n\tresourceType: string;\n\tparentId: string | null;\n\tauthorId: string;\n\t/** Resolved from resolveUser(authorId). Falls back to \"[deleted]\" when user cannot be found. */\n\tresolvedAuthorName: string;\n\t/** Resolved avatar URL or null */\n\tresolvedAvatarUrl: string | null;\n\tbody: string;\n\tstatus: CommentStatus;\n\t/** Denormalized counter — updated atomically on toggleLike */\n\tlikes: number;\n\t/** True when the currentUserId query param matches an existing commentLike row */\n\tisLikedByCurrentUser: boolean;\n\t/** ISO string set when the comment body was edited; null for unedited comments */\n\teditedAt: string | null;\n\tcreatedAt: string;\n\tupdatedAt: string;\n\t/**\n\t * Number of direct replies visible to the requesting user.\n\t * Includes approved replies plus any pending replies authored by `currentUserId`.\n\t * Always 0 for reply comments (non-null parentId).\n\t */\n\treplyCount: number;\n}\n\n/**\n * Paginated list result for comments\n */\nexport interface CommentListResult {\n\titems: SerializedComment[];\n\ttotal: number;\n\tlimit: number;\n\toffset: number;\n}\n", + "target": "src/components/btst/comments/types.ts" + }, + { + "path": "btst/comments/schemas.ts", + "type": "registry:lib", + "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\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\tcurrentUserId: z.string().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\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/client/components/comment-count.tsx", + "type": "registry:component", + "content": "\"use client\";\n\nimport { MessageSquare } from \"lucide-react\";\nimport { useCommentCount } from \"@btst/stack/plugins/comments/client/hooks\";\n\nexport interface CommentCountProps {\n\tresourceId: string;\n\tresourceType: string;\n\t/** Only count approved comments (default) */\n\tstatus?: \"pending\" | \"approved\" | \"spam\";\n\tapiBaseURL: string;\n\tapiBasePath: string;\n\theaders?: HeadersInit;\n\t/** Optional className for the wrapper span */\n\tclassName?: string;\n}\n\n/**\n * Lightweight badge showing the comment count for a resource.\n * Does not mount a full comment thread — suitable for post list cards.\n *\n * @example\n * ```tsx\n * \n * ```\n */\nexport function CommentCount({\n\tresourceId,\n\tresourceType,\n\tstatus = \"approved\",\n\tapiBaseURL,\n\tapiBasePath,\n\theaders,\n\tclassName,\n}: CommentCountProps) {\n\tconst { count, isLoading } = useCommentCount(\n\t\t{ apiBaseURL, apiBasePath, headers },\n\t\t{ resourceId, resourceType, status },\n\t);\n\n\tif (isLoading) {\n\t\treturn (\n\t\t\t\n\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{count}\n\t\t\n\t);\n}\n", + "target": "src/components/btst/comments/client/components/comment-count.tsx" + }, + { + "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\t\t{isPending ? loc.COMMENTS_FORM_POSTING : resolvedSubmitLabel}\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 { 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 { Heart, MessageSquare, Pencil, Check, X, LogIn } from \"lucide-react\";\nimport { formatDistanceToNow } from \"date-fns\";\nimport type { SerializedComment } from \"../../types\";\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}\n\nconst DEFAULT_RENDERER: ComponentType = ({ body }) => (\n\t{body}\n);\n\nfunction getInitials(name: string | null | undefined) {\n\tif (!name) return \"?\";\n\treturn name\n\t\t.split(\" \")\n\t\t.slice(0, 2)\n\t\t.map((n) => n[0])\n\t\t.join(\"\")\n\t\t.toUpperCase();\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}: {\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}) {\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{currentUserId && !comment.parentId && isApproved && (\n\t\t\t\t\t\t\t onReplyClick(comment.id)}\n\t\t\t\t\t\t\t\tdata-testid=\"reply-button\"\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{loc.COMMENTS_REPLY_BUTTON}\n\t\t\t\t\t\t\t\n\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{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;\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}: 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 loc = { ...COMMENTS_LOCALIZATION, ...localizationProp };\n\tconst [replyingTo, setReplyingTo] = useState(null);\n\tconst [expandedReplies, setExpandedReplies] = useState>(\n\t\tnew Set(),\n\t);\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\tpageSize,\n\t});\n\n\tconst postMutation = usePostComment(config, {\n\t\tresourceId,\n\t\tresourceType,\n\t\tcurrentUserId,\n\t\tinfiniteKey: threadQueryKey,\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});\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/>\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\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/>\n\n\t\t\t\t\t\t\t{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\n\n\t\t\t{currentUserId ? (\n\t\t\t\t\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\n\t\t\t\t\t\t{loc.COMMENTS_LOGIN_PROMPT}\n\t\t\t\t\t\n\t\t\t\t\t{loginHref && (\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t{loc.COMMENTS_LOGIN_LINK}\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}: {\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}) {\n\tconst config = { apiBaseURL, apiBasePath, headers };\n\t// Only fetch reply bodies once the section is expanded.\n\tconst { comments: replies } = 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},\n\t\t{ enabled: expanded },\n\t);\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 ? replies.length || replyCount : replyCount;\n\n\treturn (\n\t\t\n\t\t\t{!expanded && (\n\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t{displayCount}{\" \"}\n\t\t\t\t\t{displayCount === 1\n\t\t\t\t\t\t? loc.COMMENTS_REPLIES_SINGULAR\n\t\t\t\t\t\t: loc.COMMENTS_REPLIES_PLURAL}\n\t\t\t\t\n\t\t\t)}\n\t\t\t{expanded && (\n\t\t\t\t\n\t\t\t\t\t{replies.map((reply) => (\n\t\t\t\t\t\t {}} // No nested replies in v1\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{loc.COMMENTS_HIDE_REPLIES}\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\trootMargin=\"300px\"\n\t\t\tclassName={props.className}\n\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\tuseSuspenseComments,\n\tuseUpdateCommentStatus,\n\tuseDeleteComment,\n} from \"@btst/stack/plugins/comments/client/hooks\";\nimport {\n\tCOMMENTS_LOCALIZATION,\n\ttype CommentsLocalization,\n} from \"../../localization\";\n\ninterface ModerationPageProps {\n\tapiBaseURL: string;\n\tapiBasePath: string;\n\theaders?: HeadersInit;\n\tlocalization?: CommentsLocalization;\n}\n\nfunction getInitials(name: string | null | undefined) {\n\tif (!name) return \"?\";\n\treturn name\n\t\t.split(\" \")\n\t\t.slice(0, 2)\n\t\t.map((n) => n[0])\n\t\t.join(\"\")\n\t\t.toUpperCase();\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 [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, refetch } = useSuspenseComments(config, {\n\t\tstatus: activeTab,\n\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\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 0\n\t\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\t\tonCheckedChange={toggleSelectAll}\n\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/>\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t{loc.COMMENTS_MODERATION_COL_AUTHOR}\n\t\t\t\t\t\t\t\t{loc.COMMENTS_MODERATION_COL_COMMENT}\n\t\t\t\t\t\t\t\t{loc.COMMENTS_MODERATION_COL_RESOURCE}\n\t\t\t\t\t\t\t\t{loc.COMMENTS_MODERATION_COL_DATE}\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t{loc.COMMENTS_MODERATION_COL_ACTIONS}\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\t\n\t\t\t\t\t\t\t{comments.map((comment) => (\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 toggleSelect(comment.id)}\n\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/>\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\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.resolvedAvatarUrl && (\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{getInitials(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\t\n\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\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\n\t\t\t\t\t\t\t\t\t\t\t{comment.body}\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\n\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\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{formatDistanceToNow(new Date(comment.createdAt), {\n\t\t\t\t\t\t\t\t\t\t\taddSuffix: true,\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\n\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\tdata-testid=\"view-button\"\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\n\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 handleApprove(comment.id)}\n\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\tdata-testid=\"approve-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\t{activeTab !== \"spam\" && (\n\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\tdisabled={updateStatus.isPending}\n\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>\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\t setDeleteIds([comment.id])}\n\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>\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\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", + "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", + "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, useEffect } 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\";\n\nconst PAGE_LIMIT = 20;\n\ninterface MyCommentsPageProps {\n\tapiBaseURL: string;\n\tapiBasePath: string;\n\theaders?: HeadersInit;\n\tcurrentUserId?: CommentsPluginOverrides[\"currentUserId\"];\n\tresourceLinks?: CommentsPluginOverrides[\"resourceLinks\"];\n\tlocalization?: CommentsLocalization;\n}\n\nfunction getInitials(name: string | null | undefined) {\n\tif (!name) return \"?\";\n\treturn name\n\t\t.split(\" \")\n\t\t.slice(0, 2)\n\t\t.map((n) => n[0])\n\t\t.join(\"\")\n\t\t.toUpperCase();\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// ─── Resolved currentUserId hook ─────────────────────────────────────────────\n\nfunction 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()).then((id) => {\n\t\t\t\tsetResolved(id ?? undefined);\n\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// ─── Main export ──────────────────────────────────────────────────────────────\n\nexport function MyCommentsPage({\n\tapiBaseURL,\n\tapiBasePath,\n\theaders,\n\tcurrentUserId: currentUserIdProp,\n\tresourceLinks,\n\tlocalization: localizationProp,\n}: MyCommentsPageProps) {\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 MyCommentsList({\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 resourceUrl = resourceLinks?.[comment.resourceType]?.(\n\t\tcomment.resourceId,\n\t);\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", + "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 MyCommentsPageInternal = lazy(() =>\n\timport(\"./my-comments-page.internal\").then((m) => ({\n\t\tdefault: m.MyCommentsPage,\n\t})),\n);\n\nfunction MyCommentsPageSkeleton() {\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 MyCommentsPageComponent() {\n\treturn (\n\t\t\n\t\t\t\tconsole.error(\"[btst/comments] My Comments error:\", error)\n\t\t\t}\n\t\t/>\n\t);\n}\n\nfunction MyCommentsPageWrapper() {\n\tconst overrides = usePluginOverrides(\"comments\");\n\tconst loc = { ...COMMENTS_LOCALIZATION, ...overrides.localization };\n\n\tuseRouteLifecycle({\n\t\trouteName: \"myComments\",\n\t\tcontext: {\n\t\t\tpath: \"/comments/my-comments\",\n\t\t\tisSSR: typeof window === \"undefined\",\n\t\t},\n\t\toverrides,\n\t\tbeforeRenderHook: (o, context) => {\n\t\t\tif (o.onBeforeMyCommentsPageRendered) {\n\t\t\t\tconst result = o.onBeforeMyCommentsPageRendered(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\";\n\ninterface ResourceCommentsPageProps {\n\tresourceId: string;\n\tresourceType: string;\n\tapiBaseURL: string;\n\tapiBasePath: string;\n\theaders?: HeadersInit;\n\tlocalization?: CommentsLocalization;\n}\n\nfunction getInitials(name: string | null | undefined) {\n\tif (!name) return \"?\";\n\treturn name\n\t\t.split(\" \")\n\t\t.slice(0, 2)\n\t\t.map((n) => n[0])\n\t\t.join(\"\")\n\t\t.toUpperCase();\n}\n\nexport function ResourceCommentsPage({\n\tresourceId,\n\tresourceType,\n\tapiBaseURL,\n\tapiBasePath,\n\theaders,\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", + "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\";\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\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" + }, + { + "path": "btst/comments/client/components/shared/page-wrapper.tsx", + "type": "registry:component", + "content": "\"use client\";\n\nimport { usePluginOverrides } from \"@btst/stack/context\";\nimport { PageWrapper as SharedPageWrapper } from \"@/components/ui/page-wrapper\";\nimport type { CommentsPluginOverrides } from \"../../overrides\";\n\nexport function PageWrapper({\n\tchildren,\n\tclassName,\n\ttestId,\n}: {\n\tchildren: React.ReactNode;\n\tclassName?: string;\n\ttestId?: string;\n}) {\n\tconst { showAttribution } = usePluginOverrides<\n\t\tCommentsPluginOverrides,\n\t\tPartial\n\t>(\"comments\", {\n\t\tshowAttribution: true,\n\t});\n\n\treturn (\n\t\t\n\t\t\t{children}\n\t\t\n\t);\n}\n", + "target": "src/components/btst/comments/client/components/shared/page-wrapper.tsx" + }, + { + "path": "btst/comments/client/localization/comments-moderation.ts", + "type": "registry:lib", + "content": "export const COMMENTS_MODERATION = {\n\tCOMMENTS_MODERATION_TITLE: \"Comment Moderation\",\n\tCOMMENTS_MODERATION_DESCRIPTION:\n\t\t\"Review and manage comments across all resources.\",\n\n\tCOMMENTS_MODERATION_TAB_PENDING: \"Pending\",\n\tCOMMENTS_MODERATION_TAB_APPROVED: \"Approved\",\n\tCOMMENTS_MODERATION_TAB_SPAM: \"Spam\",\n\n\tCOMMENTS_MODERATION_SELECTED: \"{n} selected\",\n\tCOMMENTS_MODERATION_APPROVE_SELECTED: \"Approve selected\",\n\tCOMMENTS_MODERATION_DELETE_SELECTED: \"Delete selected\",\n\tCOMMENTS_MODERATION_EMPTY: \"No {status} comments.\",\n\n\tCOMMENTS_MODERATION_COL_AUTHOR: \"Author\",\n\tCOMMENTS_MODERATION_COL_COMMENT: \"Comment\",\n\tCOMMENTS_MODERATION_COL_RESOURCE: \"Resource\",\n\tCOMMENTS_MODERATION_COL_DATE: \"Date\",\n\tCOMMENTS_MODERATION_COL_ACTIONS: \"Actions\",\n\tCOMMENTS_MODERATION_SELECT_ALL: \"Select all\",\n\tCOMMENTS_MODERATION_SELECT_ONE: \"Select comment\",\n\n\tCOMMENTS_MODERATION_ACTION_VIEW: \"View\",\n\tCOMMENTS_MODERATION_ACTION_APPROVE: \"Approve\",\n\tCOMMENTS_MODERATION_ACTION_SPAM: \"Mark as spam\",\n\tCOMMENTS_MODERATION_ACTION_DELETE: \"Delete\",\n\n\tCOMMENTS_MODERATION_TOAST_APPROVED: \"Comment approved\",\n\tCOMMENTS_MODERATION_TOAST_APPROVE_ERROR: \"Failed to approve comment\",\n\tCOMMENTS_MODERATION_TOAST_SPAM: \"Marked as spam\",\n\tCOMMENTS_MODERATION_TOAST_SPAM_ERROR: \"Failed to update status\",\n\tCOMMENTS_MODERATION_TOAST_DELETED: \"Comment deleted\",\n\tCOMMENTS_MODERATION_TOAST_DELETED_PLURAL: \"{n} comments deleted\",\n\tCOMMENTS_MODERATION_TOAST_DELETE_ERROR: \"Failed to delete comment(s)\",\n\tCOMMENTS_MODERATION_TOAST_BULK_APPROVED: \"{n} comment(s) approved\",\n\tCOMMENTS_MODERATION_TOAST_BULK_APPROVE_ERROR: \"Failed to approve comments\",\n\n\tCOMMENTS_MODERATION_DIALOG_TITLE: \"Comment Details\",\n\tCOMMENTS_MODERATION_DIALOG_RESOURCE: \"Resource\",\n\tCOMMENTS_MODERATION_DIALOG_LIKES: \"Likes\",\n\tCOMMENTS_MODERATION_DIALOG_REPLY_TO: \"Reply to\",\n\tCOMMENTS_MODERATION_DIALOG_EDITED: \"Edited\",\n\tCOMMENTS_MODERATION_DIALOG_BODY: \"Body\",\n\tCOMMENTS_MODERATION_DIALOG_APPROVE: \"Approve\",\n\tCOMMENTS_MODERATION_DIALOG_MARK_SPAM: \"Mark spam\",\n\tCOMMENTS_MODERATION_DIALOG_DELETE: \"Delete\",\n\n\tCOMMENTS_MODERATION_DELETE_TITLE_SINGULAR: \"Delete comment?\",\n\tCOMMENTS_MODERATION_DELETE_TITLE_PLURAL: \"Delete {n} comments?\",\n\tCOMMENTS_MODERATION_DELETE_DESCRIPTION_SINGULAR:\n\t\t\"This action cannot be undone. The comment will be permanently deleted.\",\n\tCOMMENTS_MODERATION_DELETE_DESCRIPTION_PLURAL:\n\t\t\"This action cannot be undone. The comments will be permanently deleted.\",\n\tCOMMENTS_MODERATION_DELETE_CANCEL: \"Cancel\",\n\tCOMMENTS_MODERATION_DELETE_CONFIRM: \"Delete\",\n\tCOMMENTS_MODERATION_DELETE_DELETING: \"Deleting…\",\n\n\tCOMMENTS_RESOURCE_TITLE: \"Comments\",\n\tCOMMENTS_RESOURCE_PENDING_SECTION: \"Pending Review\",\n\tCOMMENTS_RESOURCE_THREAD_SECTION: \"Thread\",\n\tCOMMENTS_RESOURCE_ACTION_APPROVE: \"Approve\",\n\tCOMMENTS_RESOURCE_ACTION_SPAM: \"Spam\",\n\tCOMMENTS_RESOURCE_ACTION_DELETE: \"Delete\",\n\tCOMMENTS_RESOURCE_DELETE_CONFIRM: \"Delete this comment?\",\n\tCOMMENTS_RESOURCE_TOAST_APPROVED: \"Comment approved\",\n\tCOMMENTS_RESOURCE_TOAST_APPROVE_ERROR: \"Failed to approve\",\n\tCOMMENTS_RESOURCE_TOAST_SPAM: \"Marked as spam\",\n\tCOMMENTS_RESOURCE_TOAST_SPAM_ERROR: \"Failed to update\",\n\tCOMMENTS_RESOURCE_TOAST_DELETED: \"Comment deleted\",\n\tCOMMENTS_RESOURCE_TOAST_DELETE_ERROR: \"Failed to delete\",\n};\n", + "target": "src/components/btst/comments/client/localization/comments-moderation.ts" + }, + { + "path": "btst/comments/client/localization/comments-my.ts", + "type": "registry:lib", + "content": "export const COMMENTS_MY = {\n\tCOMMENTS_MY_LOGIN_TITLE: \"Please log in to view your comments\",\n\tCOMMENTS_MY_LOGIN_DESCRIPTION:\n\t\t\"You need to be logged in to see your comment history.\",\n\n\tCOMMENTS_MY_EMPTY_TITLE: \"No comments yet\",\n\tCOMMENTS_MY_EMPTY_DESCRIPTION: \"Comments you post will appear here.\",\n\n\tCOMMENTS_MY_PAGE_TITLE: \"My Comments\",\n\n\tCOMMENTS_MY_COL_COMMENT: \"Comment\",\n\tCOMMENTS_MY_COL_RESOURCE: \"Resource\",\n\tCOMMENTS_MY_COL_STATUS: \"Status\",\n\tCOMMENTS_MY_COL_DATE: \"Date\",\n\n\tCOMMENTS_MY_REPLY_INDICATOR: \"↩ Reply\",\n\tCOMMENTS_MY_VIEW_LINK: \"View\",\n\n\tCOMMENTS_MY_STATUS_APPROVED: \"Approved\",\n\tCOMMENTS_MY_STATUS_PENDING: \"Pending\",\n\tCOMMENTS_MY_STATUS_SPAM: \"Spam\",\n\n\tCOMMENTS_MY_TOAST_DELETED: \"Comment deleted\",\n\tCOMMENTS_MY_TOAST_DELETE_ERROR: \"Failed to delete comment\",\n\n\tCOMMENTS_MY_DELETE_TITLE: \"Delete comment?\",\n\tCOMMENTS_MY_DELETE_DESCRIPTION:\n\t\t\"This action cannot be undone. The comment will be permanently removed.\",\n\tCOMMENTS_MY_DELETE_CANCEL: \"Cancel\",\n\tCOMMENTS_MY_DELETE_CONFIRM: \"Delete\",\n\tCOMMENTS_MY_DELETE_BUTTON_SR: \"Delete comment\",\n};\n", + "target": "src/components/btst/comments/client/localization/comments-my.ts" + }, + { + "path": "btst/comments/client/localization/comments-thread.ts", + "type": "registry:lib", + "content": "export const COMMENTS_THREAD = {\n\tCOMMENTS_TITLE: \"Comments\",\n\tCOMMENTS_EMPTY: \"Be the first to comment.\",\n\n\tCOMMENTS_EDITED_BADGE: \"(edited)\",\n\tCOMMENTS_PENDING_BADGE: \"Pending approval\",\n\n\tCOMMENTS_LIKE_ARIA: \"Like\",\n\tCOMMENTS_UNLIKE_ARIA: \"Unlike\",\n\tCOMMENTS_REPLY_BUTTON: \"Reply\",\n\tCOMMENTS_EDIT_BUTTON: \"Edit\",\n\tCOMMENTS_DELETE_BUTTON: \"Delete\",\n\tCOMMENTS_SAVE_EDIT: \"Save\",\n\n\tCOMMENTS_REPLIES_SINGULAR: \"reply\",\n\tCOMMENTS_REPLIES_PLURAL: \"replies\",\n\tCOMMENTS_HIDE_REPLIES: \"Hide replies\",\n\tCOMMENTS_DELETE_CONFIRM: \"Delete this comment?\",\n\n\tCOMMENTS_LOGIN_PROMPT: \"Please sign in to leave a comment.\",\n\tCOMMENTS_LOGIN_LINK: \"Sign in\",\n\n\tCOMMENTS_FORM_PLACEHOLDER: \"Write a comment…\",\n\tCOMMENTS_FORM_CANCEL: \"Cancel\",\n\tCOMMENTS_FORM_POST_COMMENT: \"Post comment\",\n\tCOMMENTS_FORM_POST_REPLY: \"Post reply\",\n\tCOMMENTS_FORM_POSTING: \"Posting…\",\n\tCOMMENTS_FORM_SUBMIT_ERROR: \"Failed to submit comment\",\n\n\tCOMMENTS_LOAD_MORE: \"Load more comments\",\n\tCOMMENTS_LOADING_MORE: \"Loading…\",\n};\n", + "target": "src/components/btst/comments/client/localization/comments-thread.ts" + }, + { + "path": "btst/comments/client/localization/index.ts", + "type": "registry:lib", + "content": "import { COMMENTS_THREAD } from \"./comments-thread\";\nimport { COMMENTS_MODERATION } from \"./comments-moderation\";\nimport { COMMENTS_MY } from \"./comments-my\";\n\nexport const COMMENTS_LOCALIZATION = {\n\t...COMMENTS_THREAD,\n\t...COMMENTS_MODERATION,\n\t...COMMENTS_MY,\n};\n\nexport type CommentsLocalization = typeof COMMENTS_LOCALIZATION;\n", + "target": "src/components/btst/comments/client/localization/index.ts" + }, + { + "path": "btst/comments/client/overrides.ts", + "type": "registry:lib", + "content": "/**\n * Context passed to lifecycle hooks\n */\nexport interface RouteContext {\n\t/** Current route path */\n\tpath: string;\n\t/** Route parameters (e.g., { resourceId: \"my-post\", resourceType: \"blog-post\" }) */\n\tparams?: Record;\n\t/** Whether rendering on server (true) or client (false) */\n\tisSSR: boolean;\n\t/** Additional context properties */\n\t[key: string]: unknown;\n}\n\nimport type { CommentsLocalization } from \"./localization\";\n\n/**\n * Overridable configuration and hooks for the Comments plugin.\n *\n * Provide these in the layout wrapping your pages via `PluginOverridesProvider`.\n */\nexport interface CommentsPluginOverrides {\n\t/**\n\t * Localization strings for all Comments plugin UI.\n\t * Defaults to English when not provided.\n\t */\n\tlocalization?: Partial;\n\t/**\n\t * Base URL for API calls (e.g., \"https://example.com\")\n\t */\n\tapiBaseURL: string;\n\n\t/**\n\t * Path where the API is mounted (e.g., \"/api/data\")\n\t */\n\tapiBasePath: string;\n\n\t/**\n\t * Optional headers for authenticated API calls (e.g., forwarding cookies)\n\t */\n\theaders?: Record;\n\n\t/**\n\t * Whether to show the \"Powered by BTST\" attribution on plugin pages.\n\t * Defaults to true.\n\t */\n\tshowAttribution?: boolean;\n\n\t/**\n\t * The ID of the currently authenticated user.\n\t *\n\t * Used by the My Comments page to scope the comment list to the current user.\n\t * Can be a static string or an async function (useful when the user ID must\n\t * be resolved from a session cookie at render time).\n\t *\n\t * When absent the My Comments page shows a \"Please log in\" prompt.\n\t */\n\tcurrentUserId?:\n\t\t| string\n\t\t| (() => string | undefined | Promise);\n\n\t/**\n\t * Default number of top-level comments to load per page in `CommentThread`.\n\t * Can be overridden per-instance via the `pageSize` prop.\n\t * Defaults to 100 when not set.\n\t */\n\tdefaultCommentPageSize?: number;\n\n\t/**\n\t * Per-resource-type URL builders used to link each comment back to its\n\t * original resource on the My Comments page.\n\t *\n\t * @example\n\t * ```ts\n\t * resourceLinks: {\n\t * \"blog-post\": (slug) => `/pages/blog/${slug}`,\n\t * \"kanban-task\": (id) => `/pages/kanban?task=${id}`,\n\t * }\n\t * ```\n\t *\n\t * When a resource type has no entry the ID is shown as plain text.\n\t */\n\tresourceLinks?: Record string>;\n\n\t// ============ Access Control Hooks ============\n\n\t/**\n\t * Called before the moderation dashboard page is rendered.\n\t * Return false to block rendering (e.g., redirect to login or show 403).\n\t * @param context - Route context\n\t */\n\tonBeforeModerationPageRendered?: (context: RouteContext) => boolean;\n\n\t/**\n\t * Called before the per-resource comments page is rendered.\n\t * Return false to block rendering (e.g., for authorization).\n\t * @param resourceType - The type of resource (e.g., \"blog-post\")\n\t * @param resourceId - The ID of the resource\n\t * @param context - Route context\n\t */\n\tonBeforeResourceCommentsRendered?: (\n\t\tresourceType: string,\n\t\tresourceId: string,\n\t\tcontext: RouteContext,\n\t) => boolean;\n\n\t/**\n\t * Called before the My Comments page is rendered.\n\t * Throw to block rendering (e.g., when the user is not authenticated).\n\t * @param context - Route context\n\t */\n\tonBeforeMyCommentsPageRendered?: (context: RouteContext) => boolean | void;\n\n\t// ============ Lifecycle Hooks ============\n\n\t/**\n\t * Called when a route is rendered.\n\t * @param routeName - Name of the route (e.g., 'moderation', 'resourceComments')\n\t * @param context - Route context\n\t */\n\tonRouteRender?: (\n\t\trouteName: string,\n\t\tcontext: RouteContext,\n\t) => void | Promise;\n\n\t/**\n\t * Called when a route encounters an error.\n\t * @param routeName - Name of the route\n\t * @param error - The error that occurred\n\t * @param context - Route context\n\t */\n\tonRouteError?: (\n\t\trouteName: string,\n\t\terror: Error,\n\t\tcontext: RouteContext,\n\t) => void | Promise;\n}\n", + "target": "src/components/btst/comments/client/overrides.ts" + }, + { + "path": "ui/components/when-visible.tsx", + "type": "registry:component", + "content": "\"use client\";\n\nimport { useEffect, useRef, useState, type ReactNode } from \"react\";\n\nexport interface WhenVisibleProps {\n\t/** Content to render once the element scrolls into view */\n\tchildren: ReactNode;\n\t/** Optional placeholder rendered before the element enters the viewport */\n\tfallback?: ReactNode;\n\t/** IntersectionObserver threshold (0–1). Defaults to 0 (any pixel visible). */\n\tthreshold?: number;\n\t/** Root margin passed to IntersectionObserver. Defaults to \"200px\" to preload slightly early. */\n\trootMargin?: string;\n\t/** Additional className applied to the sentinel wrapper div */\n\tclassName?: string;\n}\n\n/**\n * Lazy-mounts children only when the sentinel element scrolls into the viewport.\n * Once mounted, children remain mounted even if the element scrolls out of view.\n *\n * Use this to defer expensive renders (comment threads, carousels, etc.) until\n * the user actually scrolls to that section.\n */\nexport function WhenVisible({\n\tchildren,\n\tfallback = null,\n\tthreshold = 0,\n\trootMargin = \"200px\",\n\tclassName,\n}: WhenVisibleProps) {\n\tconst [isVisible, setIsVisible] = useState(false);\n\tconst sentinelRef = useRef(null);\n\n\tuseEffect(() => {\n\t\tconst el = sentinelRef.current;\n\t\tif (!el) return;\n\n\t\t// If IntersectionObserver is not available (SSR/old browsers), show immediately\n\t\tif (typeof IntersectionObserver === \"undefined\") {\n\t\t\tsetIsVisible(true);\n\t\t\treturn;\n\t\t}\n\n\t\tconst observer = new IntersectionObserver(\n\t\t\t(entries) => {\n\t\t\t\tconst entry = entries[0];\n\t\t\t\tif (entry?.isIntersecting) {\n\t\t\t\t\tsetIsVisible(true);\n\t\t\t\t\tobserver.disconnect();\n\t\t\t\t}\n\t\t\t},\n\t\t\t{ threshold, rootMargin },\n\t\t);\n\n\t\tobserver.observe(el);\n\t\treturn () => observer.disconnect();\n\t}, [threshold, rootMargin]);\n\n\treturn (\n\t\t\n\t\t\t{isVisible ? children : fallback}\n\t\t\n\t);\n}\n", + "target": "src/components/ui/when-visible.tsx" + }, + { + "path": "ui/components/pagination-controls.tsx", + "type": "registry:component", + "content": "\"use client\";\n\nimport { Button } from \"@/components/ui/button\";\nimport { ChevronLeft, ChevronRight } from \"lucide-react\";\n\nexport interface PaginationControlsProps {\n\t/** Current page, 1-based */\n\tcurrentPage: number;\n\ttotalPages: number;\n\ttotal: number;\n\tlimit: number;\n\toffset: number;\n\tonPageChange: (page: number) => void;\n\tlabels?: {\n\t\tprevious?: string;\n\t\tnext?: string;\n\t\t/** Template string; use {from}, {to}, {total} as placeholders */\n\t\tshowing?: string;\n\t};\n}\n\n/**\n * Generic Prev/Next pagination control with a \"Showing X–Y of Z\" label.\n * Plugin-agnostic — pass localized labels as props.\n * Returns null when totalPages ≤ 1.\n */\nexport function PaginationControls({\n\tcurrentPage,\n\ttotalPages,\n\ttotal,\n\tlimit,\n\toffset,\n\tonPageChange,\n\tlabels,\n}: PaginationControlsProps) {\n\tconst previous = labels?.previous ?? \"Previous\";\n\tconst next = labels?.next ?? \"Next\";\n\tconst showingTemplate = labels?.showing ?? \"Showing {from}–{to} of {total}\";\n\n\tconst from = offset + 1;\n\tconst to = Math.min(offset + limit, total);\n\n\tconst showingText = showingTemplate\n\t\t.replace(\"{from}\", String(from))\n\t\t.replace(\"{to}\", String(to))\n\t\t.replace(\"{total}\", String(total));\n\n\tif (totalPages <= 1) {\n\t\treturn null;\n\t}\n\n\treturn (\n\t\t\n\t\t\t{showingText}\n\t\t\t\n\t\t\t\t onPageChange(currentPage - 1)}\n\t\t\t\t\tdisabled={currentPage === 1}\n\t\t\t\t>\n\t\t\t\t\t\n\t\t\t\t\t{previous}\n\t\t\t\t\n\t\t\t\t\n\t\t\t\t\t{currentPage} / {totalPages}\n\t\t\t\t\n\t\t\t\t onPageChange(currentPage + 1)}\n\t\t\t\t\tdisabled={currentPage === totalPages}\n\t\t\t\t>\n\t\t\t\t\t{next}\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/ui/pagination-controls.tsx" + }, + { + "path": "ui/components/page-wrapper.tsx", + "type": "registry:component", + "content": "\"use client\";\n\nimport { PageLayout } from \"./page-layout\";\nimport { StackAttribution } from \"./stack-attribution\";\n\nexport interface PageWrapperProps {\n\tchildren: React.ReactNode;\n\tclassName?: string;\n\ttestId?: string;\n\t/**\n\t * Whether to show the \"Powered by BTST\" attribution.\n\t * Defaults to true.\n\t */\n\tshowAttribution?: boolean;\n}\n\n/**\n * Shared page wrapper component providing consistent layout and optional attribution\n * for plugin pages. Used by blog, CMS, and other plugins.\n *\n * @example\n * ```tsx\n * \n * \n * My Page\n * \n * \n * ```\n */\nexport function PageWrapper({\n\tchildren,\n\tclassName,\n\ttestId,\n\tshowAttribution = true,\n}: PageWrapperProps) {\n\treturn (\n\t\t<>\n\t\t\t\n\t\t\t\t{children}\n\t\t\t\n\n\t\t\t{showAttribution && }\n\t\t>\n\t);\n}\n", + "target": "src/components/ui/page-wrapper.tsx" + }, + { + "path": "ui/hooks/use-route-lifecycle.ts", + "type": "registry:hook", + "content": "\"use client\";\n\nimport { useEffect } from \"react\";\n\n/**\n * Base route context interface that plugins can extend\n */\nexport interface BaseRouteContext {\n\t/** Current route path */\n\tpath: string;\n\t/** Route parameters (e.g., { slug: \"my-post\" }) */\n\tparams?: Record;\n\t/** Whether rendering on server (true) or client (false) */\n\tisSSR: boolean;\n\t/** Additional context properties */\n\t[key: string]: unknown;\n}\n\n/**\n * Minimum interface required for route lifecycle hooks\n * Plugin overrides should implement these optional hooks\n */\nexport interface RouteLifecycleOverrides {\n\t/** Called when a route is rendered */\n\tonRouteRender?: (\n\t\trouteName: string,\n\t\tcontext: TContext,\n\t) => void | Promise;\n\t/** Called when a route encounters an error */\n\tonRouteError?: (\n\t\trouteName: string,\n\t\terror: Error,\n\t\tcontext: TContext,\n\t) => void | Promise;\n}\n\n/**\n * Hook to handle route lifecycle events\n * - Calls authorization check before render\n * - Calls onRouteRender on mount\n * - Handles errors with onRouteError\n *\n * @example\n * ```tsx\n * const overrides = usePluginOverrides(\"myPlugin\");\n *\n * useRouteLifecycle({\n * routeName: \"dashboard\",\n * context: { path: \"/dashboard\", isSSR: typeof window === \"undefined\" },\n * overrides,\n * beforeRenderHook: (overrides, context) => {\n * if (overrides.onBeforeDashboardRendered) {\n * return overrides.onBeforeDashboardRendered(context);\n * }\n * return true;\n * },\n * });\n * ```\n */\nexport function useRouteLifecycle<\n\tTContext extends BaseRouteContext,\n\tTOverrides extends RouteLifecycleOverrides,\n>({\n\trouteName,\n\tcontext,\n\toverrides,\n\tbeforeRenderHook,\n}: {\n\trouteName: string;\n\tcontext: TContext;\n\toverrides: TOverrides;\n\tbeforeRenderHook?: (overrides: TOverrides, context: TContext) => boolean;\n}) {\n\t// Authorization check - runs synchronously before render\n\tif (beforeRenderHook) {\n\t\tconst canRender = beforeRenderHook(overrides, context);\n\t\tif (!canRender) {\n\t\t\tconst error = new Error(`Unauthorized: Cannot render ${routeName}`);\n\t\t\t// Call error hook synchronously\n\t\t\tif (overrides.onRouteError) {\n\t\t\t\ttry {\n\t\t\t\t\tconst result = overrides.onRouteError(routeName, error, context);\n\t\t\t\t\tif (result instanceof Promise) {\n\t\t\t\t\t\tresult.catch(() => {}); // Ignore promise rejection\n\t\t\t\t\t}\n\t\t\t\t} catch {\n\t\t\t\t\t// Ignore errors in error hook\n\t\t\t\t}\n\t\t\t}\n\t\t\tthrow error;\n\t\t}\n\t}\n\n\t// Lifecycle hook - runs on mount\n\tuseEffect(() => {\n\t\tif (overrides.onRouteRender) {\n\t\t\ttry {\n\t\t\t\tconst result = overrides.onRouteRender(routeName, context);\n\t\t\t\tif (result instanceof Promise) {\n\t\t\t\t\tresult.catch((error) => {\n\t\t\t\t\t\t// If onRouteRender throws, call onRouteError\n\t\t\t\t\t\tif (overrides.onRouteError) {\n\t\t\t\t\t\t\toverrides.onRouteError(routeName, error, context);\n\t\t\t\t\t\t}\n\t\t\t\t\t});\n\t\t\t\t}\n\t\t\t} catch (error) {\n\t\t\t\t// If onRouteRender throws, call onRouteError\n\t\t\t\tif (overrides.onRouteError) {\n\t\t\t\t\toverrides.onRouteError(routeName, error as Error, context);\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}, [routeName, overrides, context]);\n}\n", + "target": "src/hooks/use-route-lifecycle.ts" + } + ], + "docs": "https://better-stack.ai/docs/plugins/comments" +} diff --git a/packages/stack/registry/btst-kanban.json b/packages/stack/registry/btst-kanban.json index 63a919ec..968bab17 100644 --- a/packages/stack/registry/btst-kanban.json +++ b/packages/stack/registry/btst-kanban.json @@ -91,7 +91,7 @@ { "path": "btst/kanban/client/components/pages/board-page.internal.tsx", "type": "registry:component", - "content": "\"use client\";\n\nimport { useState, useCallback, useMemo, useEffect } from \"react\";\nimport { ArrowLeft, Plus, Settings, Trash2, Pencil } from \"lucide-react\";\nimport { toast } from \"sonner\";\nimport { Button } from \"@/components/ui/button\";\nimport {\n\tDropdownMenu,\n\tDropdownMenuContent,\n\tDropdownMenuItem,\n\tDropdownMenuSeparator,\n\tDropdownMenuTrigger,\n} from \"@/components/ui/dropdown-menu\";\nimport {\n\tDialog,\n\tDialogContent,\n\tDialogDescription,\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 {\n\tuseSuspenseBoard,\n\tuseBoardMutations,\n\tuseColumnMutations,\n\tuseTaskMutations,\n} from \"@btst/stack/plugins/kanban/client/hooks\";\nimport { usePluginOverrides } from \"@btst/stack/context\";\nimport type { KanbanPluginOverrides } from \"../../overrides\";\nimport { KanbanBoard } from \"../shared/kanban-board\";\nimport { ColumnForm } from \"../forms/column-form\";\nimport { BoardForm } from \"../forms/board-form\";\nimport { TaskForm } from \"../forms/task-form\";\nimport { PageWrapper } from \"../shared/page-wrapper\";\nimport { EmptyState } from \"../shared/empty-state\";\nimport type { SerializedTask, SerializedColumn } from \"../../../types\";\n\ninterface BoardPageProps {\n\tboardId: string;\n}\n\ntype ModalState =\n\t| { type: \"none\" }\n\t| { type: \"addColumn\" }\n\t| { type: \"editColumn\"; columnId: string }\n\t| { type: \"deleteColumn\"; columnId: string }\n\t| { type: \"editBoard\" }\n\t| { type: \"deleteBoard\" }\n\t| { type: \"addTask\"; columnId: string }\n\t| { type: \"editTask\"; columnId: string; taskId: string };\n\nexport function BoardPage({ boardId }: BoardPageProps) {\n\tconst { data: board, error, refetch, isFetching } = useSuspenseBoard(boardId);\n\n\t// Suspense hooks only throw on initial fetch, not refetch failures\n\tif (error && !isFetching) {\n\t\tthrow error;\n\t}\n\n\tconst { Link: OverrideLink, navigate: overrideNavigate } =\n\t\tusePluginOverrides(\"kanban\");\n\tconst navigate =\n\t\toverrideNavigate ||\n\t\t((path: string) => {\n\t\t\twindow.location.href = path;\n\t\t});\n\tconst Link = OverrideLink || \"a\";\n\n\tconst { deleteBoard, isDeleting } = useBoardMutations();\n\tconst { deleteColumn, reorderColumns } = useColumnMutations();\n\tconst { deleteTask, moveTask, reorderTasks } = useTaskMutations();\n\n\tconst [modalState, setModalState] = useState({ type: \"none\" });\n\n\t// Helper function to convert board columns to kanban state format\n\tconst computeKanbanData = useCallback(\n\t\t(\n\t\t\tcolumns: SerializedColumn[] | undefined,\n\t\t): Record => {\n\t\t\tif (!columns) return {};\n\t\t\treturn columns.reduce(\n\t\t\t\t(acc, column) => {\n\t\t\t\t\tacc[column.id] = column.tasks || [];\n\t\t\t\t\treturn acc;\n\t\t\t\t},\n\t\t\t\t{} as Record,\n\t\t\t);\n\t\t},\n\t\t[],\n\t);\n\n\t// Initialize kanbanState with data from board to avoid flash of empty state\n\t// Using lazy initializer ensures we have the correct state on first render\n\tconst [kanbanState, setKanbanState] = useState<\n\t\tRecord\n\t>(() => computeKanbanData(board?.columns));\n\n\t// Keep kanbanState in sync when server data changes (e.g., after refetch)\n\tconst serverKanbanData = useMemo(\n\t\t() => computeKanbanData(board?.columns),\n\t\t[board?.columns, computeKanbanData],\n\t);\n\n\tuseEffect(() => {\n\t\tsetKanbanState(serverKanbanData);\n\t}, [serverKanbanData]);\n\n\tconst closeModal = useCallback(() => {\n\t\tsetModalState({ type: \"none\" });\n\t}, []);\n\n\tconst handleDeleteBoard = useCallback(async () => {\n\t\ttry {\n\t\t\tawait deleteBoard(boardId);\n\t\t\tcloseModal();\n\t\t\t// Use both navigate and a fallback to ensure navigation works\n\t\t\t// Some frameworks may have issues with router.push after mutations\n\t\t\tnavigate(\"/pages/kanban\");\n\t\t\t// Fallback: if navigate doesn't work, use window.location\n\t\t\tif (typeof window !== \"undefined\") {\n\t\t\t\tsetTimeout(() => {\n\t\t\t\t\t// Only redirect if we're still on the same page after 100ms\n\t\t\t\t\tif (window.location.pathname.includes(boardId)) {\n\t\t\t\t\t\twindow.location.href = \"/pages/kanban\";\n\t\t\t\t\t}\n\t\t\t\t}, 100);\n\t\t\t}\n\t\t} catch (error) {\n\t\t\tconst message =\n\t\t\t\terror instanceof Error ? error.message : \"Failed to delete board\";\n\t\t\ttoast.error(message);\n\t\t}\n\t}, [deleteBoard, boardId, navigate, closeModal]);\n\n\tconst handleKanbanChange = useCallback(\n\t\tasync (newData: Record) => {\n\t\t\tif (!board) return;\n\n\t\t\t// Capture current state for change detection\n\t\t\t// Note: We use a functional update to get the actual current state,\n\t\t\t// avoiding stale closure issues with rapid successive operations\n\t\t\tlet previousState: Record = {};\n\t\t\tsetKanbanState((current) => {\n\t\t\t\tpreviousState = current;\n\t\t\t\treturn newData;\n\t\t\t});\n\n\t\t\ttry {\n\t\t\t\t// Detect column reorder\n\t\t\t\tconst oldKeys = Object.keys(previousState);\n\t\t\t\tconst newKeys = Object.keys(newData);\n\t\t\t\tconst isColumnMove =\n\t\t\t\t\toldKeys.length === newKeys.length &&\n\t\t\t\t\toldKeys.join(\"\") !== newKeys.join(\"\");\n\n\t\t\t\tif (isColumnMove) {\n\t\t\t\t\t// Column reorder - use atomic batch endpoint with transaction support\n\t\t\t\t\tawait reorderColumns(board.id, newKeys);\n\t\t\t\t} else {\n\t\t\t\t\t// Task changes - detect cross-column moves and within-column reorders\n\t\t\t\t\tconst crossColumnMoves: Array<{\n\t\t\t\t\t\ttaskId: string;\n\t\t\t\t\t\ttargetColumnId: string;\n\t\t\t\t\t\ttargetOrder: number;\n\t\t\t\t\t}> = [];\n\t\t\t\t\tconst columnsToReorder: Map = new Map();\n\t\t\t\t\tconst targetColumnsOfCrossMove = new Set();\n\n\t\t\t\t\tfor (const [columnId, tasks] of Object.entries(newData)) {\n\t\t\t\t\t\tconst oldTasks = previousState[columnId] || [];\n\t\t\t\t\t\tlet hasOrderChanges = false;\n\n\t\t\t\t\t\tfor (let i = 0; i < tasks.length; i++) {\n\t\t\t\t\t\t\tconst task = tasks[i];\n\t\t\t\t\t\t\tif (!task) continue;\n\n\t\t\t\t\t\t\tif (task.columnId !== columnId) {\n\t\t\t\t\t\t\t\t// Task moved from another column - needs cross-column move\n\t\t\t\t\t\t\t\tcrossColumnMoves.push({\n\t\t\t\t\t\t\t\t\ttaskId: task.id,\n\t\t\t\t\t\t\t\t\ttargetColumnId: columnId,\n\t\t\t\t\t\t\t\t\ttargetOrder: i,\n\t\t\t\t\t\t\t\t});\n\t\t\t\t\t\t\t\ttargetColumnsOfCrossMove.add(columnId);\n\t\t\t\t\t\t\t} else if (task.order !== i) {\n\t\t\t\t\t\t\t\t// Task order changed within same column\n\t\t\t\t\t\t\t\thasOrderChanges = true;\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t}\n\n\t\t\t\t\t\t// Check if tasks were removed from this column (moved elsewhere)\n\t\t\t\t\t\tconst newTaskIds = new Set(tasks.map((t) => t.id));\n\t\t\t\t\t\tconst tasksRemoved = oldTasks.some((t) => !newTaskIds.has(t.id));\n\n\t\t\t\t\t\t// If order changes within column (not a target of cross-column move),\n\t\t\t\t\t\t// use atomic reorder\n\t\t\t\t\t\tif (\n\t\t\t\t\t\t\thasOrderChanges &&\n\t\t\t\t\t\t\t!targetColumnsOfCrossMove.has(columnId) &&\n\t\t\t\t\t\t\t!tasksRemoved\n\t\t\t\t\t\t) {\n\t\t\t\t\t\t\tcolumnsToReorder.set(\n\t\t\t\t\t\t\t\tcolumnId,\n\t\t\t\t\t\t\t\ttasks.map((t) => t.id),\n\t\t\t\t\t\t\t);\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\n\t\t\t\t\t// Handle cross-column moves first (these need individual moveTask calls)\n\t\t\t\t\tfor (const move of crossColumnMoves) {\n\t\t\t\t\t\tawait moveTask(move.taskId, move.targetColumnId, move.targetOrder);\n\t\t\t\t\t}\n\n\t\t\t\t\t// Then handle within-column reorders atomically\n\t\t\t\t\tfor (const [columnId, taskIds] of columnsToReorder) {\n\t\t\t\t\t\tawait reorderTasks(columnId, taskIds);\n\t\t\t\t\t}\n\n\t\t\t\t\t// Reorder target columns of cross-column moves to fix order collisions\n\t\t\t\t\t// The moveTask only sets the moved task's order, so other tasks need reordering\n\t\t\t\t\tfor (const targetColumnId of targetColumnsOfCrossMove) {\n\t\t\t\t\t\tconst tasks = newData[targetColumnId];\n\t\t\t\t\t\tif (tasks) {\n\t\t\t\t\t\t\tawait reorderTasks(\n\t\t\t\t\t\t\t\ttargetColumnId,\n\t\t\t\t\t\t\t\ttasks.map((t) => t.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// Sync with server after successful mutations\n\t\t\t\trefetch();\n\t\t\t} catch (error) {\n\t\t\t\t// On error, refetch from server to get the authoritative state.\n\t\t\t\t// We avoid manual rollback to previousState because with rapid successive\n\t\t\t\t// operations, the captured previousState may be stale - a later operation\n\t\t\t\t// may have already updated the state, and reverting would incorrectly\n\t\t\t\t// undo that operation too. The server is the source of truth.\n\t\t\t\trefetch();\n\t\t\t\t// Re-throw so error boundaries or toast handlers can catch it\n\t\t\t\tthrow error;\n\t\t\t}\n\t\t},\n\t\t[board, reorderColumns, moveTask, reorderTasks, refetch],\n\t);\n\n\tconst orderedColumns = useMemo(() => {\n\t\tif (!board?.columns) return [];\n\t\tconst columnMap = new Map(board.columns.map((c) => [c.id, c]));\n\t\treturn Object.keys(kanbanState)\n\t\t\t.map((columnId) => {\n\t\t\t\tconst column = columnMap.get(columnId);\n\t\t\t\tif (!column) return null;\n\t\t\t\treturn {\n\t\t\t\t\t...column,\n\t\t\t\t\ttasks: kanbanState[columnId] || [],\n\t\t\t\t};\n\t\t\t})\n\t\t\t.filter(\n\t\t\t\t(c): c is SerializedColumn & { tasks: SerializedTask[] } => c !== null,\n\t\t\t);\n\t}, [board?.columns, kanbanState]);\n\n\t// Board not found - only shown after data has loaded (not during loading)\n\tif (!board) {\n\t\treturn (\n\t\t\t navigate(\"/pages/kanban\")}>\n\t\t\t\t\t\t\n\t\t\t\t\t\tBack to Boards\n\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\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{board.name}\n\t\t\t\t\t\t\n\t\t\t\t\t\t{board.description && (\n\t\t\t\t\t\t\t{board.description}\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\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\tActions\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 setModalState({ type: \"addColumn\" })}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\tAdd Column\n\t\t\t\t\t\t\n\t\t\t\t\t\t setModalState({ type: \"editBoard\" })}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\tEdit Board\n\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t setModalState({ type: \"deleteBoard\" })}\n\t\t\t\t\t\t\tclassName=\"text-red-600 focus:text-red-600\"\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\tDelete Board\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{orderedColumns.length > 0 ? (\n\t\t\t\t setModalState({ type: \"addTask\", columnId })}\n\t\t\t\t\tonEditTask={(columnId, taskId) =>\n\t\t\t\t\t\tsetModalState({ type: \"editTask\", columnId, taskId })\n\t\t\t\t\t}\n\t\t\t\t\tonEditColumn={(columnId) =>\n\t\t\t\t\t\tsetModalState({ type: \"editColumn\", columnId })\n\t\t\t\t\t}\n\t\t\t\t\tonDeleteColumn={(columnId) =>\n\t\t\t\t\t\tsetModalState({ type: \"deleteColumn\", columnId })\n\t\t\t\t\t}\n\t\t\t\t/>\n\t\t\t) : (\n\t\t\t\t setModalState({ type: \"addColumn\" })}>\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\tAdd Column\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{/* Add Column Modal */}\n\t\t\t !open && closeModal()}\n\t\t\t>\n\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\tAdd Column\n\t\t\t\t\t\t\n\t\t\t\t\t\t\tAdd a new column to this board.\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\tcloseModal();\n\t\t\t\t\t\t\trefetch();\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{/* Edit Column Modal */}\n\t\t\t !open && closeModal()}\n\t\t\t>\n\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\tEdit Column\n\t\t\t\t\t\tUpdate the column details.\n\t\t\t\t\t\n\t\t\t\t\t{modalState.type === \"editColumn\" && (\n\t\t\t\t\t\t c.id === modalState.columnId)}\n\t\t\t\t\t\t\tonClose={closeModal}\n\t\t\t\t\t\t\tonSuccess={() => {\n\t\t\t\t\t\t\t\tcloseModal();\n\t\t\t\t\t\t\t\trefetch();\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 Column Modal */}\n\t\t\t !open && closeModal()}\n\t\t\t>\n\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\tDelete Column\n\t\t\t\t\t\t\n\t\t\t\t\t\t\tAre you sure you want to delete this column? All tasks in this\n\t\t\t\t\t\t\tcolumn will be permanently removed.\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\tCancel\n\t\t\t\t\t\t {\n\t\t\t\t\t\t\t\tif (modalState.type === \"deleteColumn\") {\n\t\t\t\t\t\t\t\t\ttry {\n\t\t\t\t\t\t\t\t\t\tawait deleteColumn(modalState.columnId);\n\t\t\t\t\t\t\t\t\t\tcloseModal();\n\t\t\t\t\t\t\t\t\t\trefetch();\n\t\t\t\t\t\t\t\t\t} catch (error) {\n\t\t\t\t\t\t\t\t\t\tconst message =\n\t\t\t\t\t\t\t\t\t\t\terror instanceof Error\n\t\t\t\t\t\t\t\t\t\t\t\t? error.message\n\t\t\t\t\t\t\t\t\t\t\t\t: \"Failed to delete column\";\n\t\t\t\t\t\t\t\t\t\ttoast.error(message);\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\tclassName=\"bg-red-600 hover:bg-red-700\"\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\tDelete\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{/* Edit Board Modal */}\n\t\t\t !open && closeModal()}\n\t\t\t>\n\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\tEdit Board\n\t\t\t\t\t\tUpdate board details.\n\t\t\t\t\t\n\t\t\t\t\t {\n\t\t\t\t\t\t\tcloseModal();\n\t\t\t\t\t\t\trefetch();\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 Board Modal */}\n\t\t\t !open && closeModal()}\n\t\t\t>\n\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\tDelete Board\n\t\t\t\t\t\t\n\t\t\t\t\t\t\tAre you sure you want to delete this board? This action cannot be\n\t\t\t\t\t\t\tundone. All columns and tasks will be permanently removed.\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\tCancel\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t{isDeleting ? \"Deleting...\" : \"Delete\"}\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{/* Add Task Modal */}\n\t\t\t !open && closeModal()}\n\t\t\t>\n\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\tAdd Task\n\t\t\t\t\t\tCreate a new task.\n\t\t\t\t\t\n\t\t\t\t\t{modalState.type === \"addTask\" && (\n\t\t\t\t\t\t {\n\t\t\t\t\t\t\t\tcloseModal();\n\t\t\t\t\t\t\t\trefetch();\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{/* Edit Task Modal */}\n\t\t\t !open && closeModal()}\n\t\t\t>\n\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\tEdit Task\n\t\t\t\t\t\tUpdate task details.\n\t\t\t\t\t\n\t\t\t\t\t{modalState.type === \"editTask\" && (\n\t\t\t\t\t\t c.id === modalState.columnId)\n\t\t\t\t\t\t\t\t?.tasks?.find((t) => t.id === modalState.taskId)}\n\t\t\t\t\t\t\tcolumns={board.columns || []}\n\t\t\t\t\t\t\tonClose={closeModal}\n\t\t\t\t\t\t\tonSuccess={() => {\n\t\t\t\t\t\t\t\tcloseModal();\n\t\t\t\t\t\t\t\trefetch();\n\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\tonDelete={async () => {\n\t\t\t\t\t\t\t\ttry {\n\t\t\t\t\t\t\t\t\tawait deleteTask(modalState.taskId);\n\t\t\t\t\t\t\t\t\tcloseModal();\n\t\t\t\t\t\t\t\t\trefetch();\n\t\t\t\t\t\t\t\t} catch (error) {\n\t\t\t\t\t\t\t\t\tconst message =\n\t\t\t\t\t\t\t\t\t\terror instanceof Error\n\t\t\t\t\t\t\t\t\t\t\t? error.message\n\t\t\t\t\t\t\t\t\t\t\t: \"Failed to delete task\";\n\t\t\t\t\t\t\t\t\ttoast.error(message);\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", + "content": "\"use client\";\n\nimport { useState, useCallback, useMemo, useEffect } from \"react\";\nimport { ArrowLeft, Plus, Settings, Trash2, Pencil } from \"lucide-react\";\nimport { toast } from \"sonner\";\nimport { Button } from \"@/components/ui/button\";\nimport {\n\tDropdownMenu,\n\tDropdownMenuContent,\n\tDropdownMenuItem,\n\tDropdownMenuSeparator,\n\tDropdownMenuTrigger,\n} from \"@/components/ui/dropdown-menu\";\nimport {\n\tDialog,\n\tDialogContent,\n\tDialogDescription,\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 {\n\tuseSuspenseBoard,\n\tuseBoardMutations,\n\tuseColumnMutations,\n\tuseTaskMutations,\n} from \"@btst/stack/plugins/kanban/client/hooks\";\nimport { usePluginOverrides } from \"@btst/stack/context\";\nimport type { KanbanPluginOverrides } from \"../../overrides\";\nimport { KanbanBoard } from \"../shared/kanban-board\";\nimport { ColumnForm } from \"../forms/column-form\";\nimport { BoardForm } from \"../forms/board-form\";\nimport { TaskForm } from \"../forms/task-form\";\nimport { PageWrapper } from \"../shared/page-wrapper\";\nimport { EmptyState } from \"../shared/empty-state\";\nimport type { SerializedTask, SerializedColumn } from \"../../../types\";\n\ninterface BoardPageProps {\n\tboardId: string;\n}\n\ntype ModalState =\n\t| { type: \"none\" }\n\t| { type: \"addColumn\" }\n\t| { type: \"editColumn\"; columnId: string }\n\t| { type: \"deleteColumn\"; columnId: string }\n\t| { type: \"editBoard\" }\n\t| { type: \"deleteBoard\" }\n\t| { type: \"addTask\"; columnId: string }\n\t| { type: \"editTask\"; columnId: string; taskId: string };\n\nexport function BoardPage({ boardId }: BoardPageProps) {\n\tconst { data: board, error, refetch, isFetching } = useSuspenseBoard(boardId);\n\n\t// Suspense hooks only throw on initial fetch, not refetch failures\n\tif (error && !isFetching) {\n\t\tthrow error;\n\t}\n\n\tconst {\n\t\tLink: OverrideLink,\n\t\tnavigate: overrideNavigate,\n\t\ttaskDetailBottomSlot,\n\t} = usePluginOverrides(\"kanban\");\n\tconst navigate =\n\t\toverrideNavigate ||\n\t\t((path: string) => {\n\t\t\twindow.location.href = path;\n\t\t});\n\tconst Link = OverrideLink || \"a\";\n\n\tconst { deleteBoard, isDeleting } = useBoardMutations();\n\tconst { deleteColumn, reorderColumns } = useColumnMutations();\n\tconst { deleteTask, moveTask, reorderTasks } = useTaskMutations();\n\n\tconst [modalState, setModalState] = useState({ type: \"none\" });\n\n\t// Helper function to convert board columns to kanban state format\n\tconst computeKanbanData = useCallback(\n\t\t(\n\t\t\tcolumns: SerializedColumn[] | undefined,\n\t\t): Record => {\n\t\t\tif (!columns) return {};\n\t\t\treturn columns.reduce(\n\t\t\t\t(acc, column) => {\n\t\t\t\t\tacc[column.id] = column.tasks || [];\n\t\t\t\t\treturn acc;\n\t\t\t\t},\n\t\t\t\t{} as Record,\n\t\t\t);\n\t\t},\n\t\t[],\n\t);\n\n\t// Initialize kanbanState with data from board to avoid flash of empty state\n\t// Using lazy initializer ensures we have the correct state on first render\n\tconst [kanbanState, setKanbanState] = useState<\n\t\tRecord\n\t>(() => computeKanbanData(board?.columns));\n\n\t// Keep kanbanState in sync when server data changes (e.g., after refetch)\n\tconst serverKanbanData = useMemo(\n\t\t() => computeKanbanData(board?.columns),\n\t\t[board?.columns, computeKanbanData],\n\t);\n\n\tuseEffect(() => {\n\t\tsetKanbanState(serverKanbanData);\n\t}, [serverKanbanData]);\n\n\tconst closeModal = useCallback(() => {\n\t\tsetModalState({ type: \"none\" });\n\t}, []);\n\n\tconst handleDeleteBoard = useCallback(async () => {\n\t\ttry {\n\t\t\tawait deleteBoard(boardId);\n\t\t\tcloseModal();\n\t\t\t// Use both navigate and a fallback to ensure navigation works\n\t\t\t// Some frameworks may have issues with router.push after mutations\n\t\t\tnavigate(\"/pages/kanban\");\n\t\t\t// Fallback: if navigate doesn't work, use window.location\n\t\t\tif (typeof window !== \"undefined\") {\n\t\t\t\tsetTimeout(() => {\n\t\t\t\t\t// Only redirect if we're still on the same page after 100ms\n\t\t\t\t\tif (window.location.pathname.includes(boardId)) {\n\t\t\t\t\t\twindow.location.href = \"/pages/kanban\";\n\t\t\t\t\t}\n\t\t\t\t}, 100);\n\t\t\t}\n\t\t} catch (error) {\n\t\t\tconst message =\n\t\t\t\terror instanceof Error ? error.message : \"Failed to delete board\";\n\t\t\ttoast.error(message);\n\t\t}\n\t}, [deleteBoard, boardId, navigate, closeModal]);\n\n\tconst handleKanbanChange = useCallback(\n\t\tasync (newData: Record) => {\n\t\t\tif (!board) return;\n\n\t\t\t// Capture current state for change detection\n\t\t\t// Note: We use a functional update to get the actual current state,\n\t\t\t// avoiding stale closure issues with rapid successive operations\n\t\t\tlet previousState: Record = {};\n\t\t\tsetKanbanState((current) => {\n\t\t\t\tpreviousState = current;\n\t\t\t\treturn newData;\n\t\t\t});\n\n\t\t\ttry {\n\t\t\t\t// Detect column reorder\n\t\t\t\tconst oldKeys = Object.keys(previousState);\n\t\t\t\tconst newKeys = Object.keys(newData);\n\t\t\t\tconst isColumnMove =\n\t\t\t\t\toldKeys.length === newKeys.length &&\n\t\t\t\t\toldKeys.join(\"\") !== newKeys.join(\"\");\n\n\t\t\t\tif (isColumnMove) {\n\t\t\t\t\t// Column reorder - use atomic batch endpoint with transaction support\n\t\t\t\t\tawait reorderColumns(board.id, newKeys);\n\t\t\t\t} else {\n\t\t\t\t\t// Task changes - detect cross-column moves and within-column reorders\n\t\t\t\t\tconst crossColumnMoves: Array<{\n\t\t\t\t\t\ttaskId: string;\n\t\t\t\t\t\ttargetColumnId: string;\n\t\t\t\t\t\ttargetOrder: number;\n\t\t\t\t\t}> = [];\n\t\t\t\t\tconst columnsToReorder: Map = new Map();\n\t\t\t\t\tconst targetColumnsOfCrossMove = new Set();\n\n\t\t\t\t\tfor (const [columnId, tasks] of Object.entries(newData)) {\n\t\t\t\t\t\tconst oldTasks = previousState[columnId] || [];\n\t\t\t\t\t\tlet hasOrderChanges = false;\n\n\t\t\t\t\t\tfor (let i = 0; i < tasks.length; i++) {\n\t\t\t\t\t\t\tconst task = tasks[i];\n\t\t\t\t\t\t\tif (!task) continue;\n\n\t\t\t\t\t\t\tif (task.columnId !== columnId) {\n\t\t\t\t\t\t\t\t// Task moved from another column - needs cross-column move\n\t\t\t\t\t\t\t\tcrossColumnMoves.push({\n\t\t\t\t\t\t\t\t\ttaskId: task.id,\n\t\t\t\t\t\t\t\t\ttargetColumnId: columnId,\n\t\t\t\t\t\t\t\t\ttargetOrder: i,\n\t\t\t\t\t\t\t\t});\n\t\t\t\t\t\t\t\ttargetColumnsOfCrossMove.add(columnId);\n\t\t\t\t\t\t\t} else if (task.order !== i) {\n\t\t\t\t\t\t\t\t// Task order changed within same column\n\t\t\t\t\t\t\t\thasOrderChanges = true;\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t}\n\n\t\t\t\t\t\t// Check if tasks were removed from this column (moved elsewhere)\n\t\t\t\t\t\tconst newTaskIds = new Set(tasks.map((t) => t.id));\n\t\t\t\t\t\tconst tasksRemoved = oldTasks.some((t) => !newTaskIds.has(t.id));\n\n\t\t\t\t\t\t// If order changes within column (not a target of cross-column move),\n\t\t\t\t\t\t// use atomic reorder\n\t\t\t\t\t\tif (\n\t\t\t\t\t\t\thasOrderChanges &&\n\t\t\t\t\t\t\t!targetColumnsOfCrossMove.has(columnId) &&\n\t\t\t\t\t\t\t!tasksRemoved\n\t\t\t\t\t\t) {\n\t\t\t\t\t\t\tcolumnsToReorder.set(\n\t\t\t\t\t\t\t\tcolumnId,\n\t\t\t\t\t\t\t\ttasks.map((t) => t.id),\n\t\t\t\t\t\t\t);\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\n\t\t\t\t\t// Handle cross-column moves first (these need individual moveTask calls)\n\t\t\t\t\tfor (const move of crossColumnMoves) {\n\t\t\t\t\t\tawait moveTask(move.taskId, move.targetColumnId, move.targetOrder);\n\t\t\t\t\t}\n\n\t\t\t\t\t// Then handle within-column reorders atomically\n\t\t\t\t\tfor (const [columnId, taskIds] of columnsToReorder) {\n\t\t\t\t\t\tawait reorderTasks(columnId, taskIds);\n\t\t\t\t\t}\n\n\t\t\t\t\t// Reorder target columns of cross-column moves to fix order collisions\n\t\t\t\t\t// The moveTask only sets the moved task's order, so other tasks need reordering\n\t\t\t\t\tfor (const targetColumnId of targetColumnsOfCrossMove) {\n\t\t\t\t\t\tconst tasks = newData[targetColumnId];\n\t\t\t\t\t\tif (tasks) {\n\t\t\t\t\t\t\tawait reorderTasks(\n\t\t\t\t\t\t\t\ttargetColumnId,\n\t\t\t\t\t\t\t\ttasks.map((t) => t.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// Sync with server after successful mutations\n\t\t\t\trefetch();\n\t\t\t} catch (error) {\n\t\t\t\t// On error, refetch from server to get the authoritative state.\n\t\t\t\t// We avoid manual rollback to previousState because with rapid successive\n\t\t\t\t// operations, the captured previousState may be stale - a later operation\n\t\t\t\t// may have already updated the state, and reverting would incorrectly\n\t\t\t\t// undo that operation too. The server is the source of truth.\n\t\t\t\trefetch();\n\t\t\t\t// Re-throw so error boundaries or toast handlers can catch it\n\t\t\t\tthrow error;\n\t\t\t}\n\t\t},\n\t\t[board, reorderColumns, moveTask, reorderTasks, refetch],\n\t);\n\n\tconst orderedColumns = useMemo(() => {\n\t\tif (!board?.columns) return [];\n\t\tconst columnMap = new Map(board.columns.map((c) => [c.id, c]));\n\t\treturn Object.keys(kanbanState)\n\t\t\t.map((columnId) => {\n\t\t\t\tconst column = columnMap.get(columnId);\n\t\t\t\tif (!column) return null;\n\t\t\t\treturn {\n\t\t\t\t\t...column,\n\t\t\t\t\ttasks: kanbanState[columnId] || [],\n\t\t\t\t};\n\t\t\t})\n\t\t\t.filter(\n\t\t\t\t(c): c is SerializedColumn & { tasks: SerializedTask[] } => c !== null,\n\t\t\t);\n\t}, [board?.columns, kanbanState]);\n\n\t// Board not found - only shown after data has loaded (not during loading)\n\tif (!board) {\n\t\treturn (\n\t\t\t navigate(\"/pages/kanban\")}>\n\t\t\t\t\t\t\n\t\t\t\t\t\tBack to Boards\n\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\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{board.name}\n\t\t\t\t\t\t\n\t\t\t\t\t\t{board.description && (\n\t\t\t\t\t\t\t{board.description}\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\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\tActions\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 setModalState({ type: \"addColumn\" })}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\tAdd Column\n\t\t\t\t\t\t\n\t\t\t\t\t\t setModalState({ type: \"editBoard\" })}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\tEdit Board\n\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t setModalState({ type: \"deleteBoard\" })}\n\t\t\t\t\t\t\tclassName=\"text-red-600 focus:text-red-600\"\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\tDelete Board\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{orderedColumns.length > 0 ? (\n\t\t\t\t setModalState({ type: \"addTask\", columnId })}\n\t\t\t\t\tonEditTask={(columnId, taskId) =>\n\t\t\t\t\t\tsetModalState({ type: \"editTask\", columnId, taskId })\n\t\t\t\t\t}\n\t\t\t\t\tonEditColumn={(columnId) =>\n\t\t\t\t\t\tsetModalState({ type: \"editColumn\", columnId })\n\t\t\t\t\t}\n\t\t\t\t\tonDeleteColumn={(columnId) =>\n\t\t\t\t\t\tsetModalState({ type: \"deleteColumn\", columnId })\n\t\t\t\t\t}\n\t\t\t\t/>\n\t\t\t) : (\n\t\t\t\t setModalState({ type: \"addColumn\" })}>\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\tAdd Column\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{/* Add Column Modal */}\n\t\t\t !open && closeModal()}\n\t\t\t>\n\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\tAdd Column\n\t\t\t\t\t\t\n\t\t\t\t\t\t\tAdd a new column to this board.\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\tcloseModal();\n\t\t\t\t\t\t\trefetch();\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{/* Edit Column Modal */}\n\t\t\t !open && closeModal()}\n\t\t\t>\n\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\tEdit Column\n\t\t\t\t\t\tUpdate the column details.\n\t\t\t\t\t\n\t\t\t\t\t{modalState.type === \"editColumn\" && (\n\t\t\t\t\t\t c.id === modalState.columnId)}\n\t\t\t\t\t\t\tonClose={closeModal}\n\t\t\t\t\t\t\tonSuccess={() => {\n\t\t\t\t\t\t\t\tcloseModal();\n\t\t\t\t\t\t\t\trefetch();\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 Column Modal */}\n\t\t\t !open && closeModal()}\n\t\t\t>\n\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\tDelete Column\n\t\t\t\t\t\t\n\t\t\t\t\t\t\tAre you sure you want to delete this column? All tasks in this\n\t\t\t\t\t\t\tcolumn will be permanently removed.\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\tCancel\n\t\t\t\t\t\t {\n\t\t\t\t\t\t\t\tif (modalState.type === \"deleteColumn\") {\n\t\t\t\t\t\t\t\t\ttry {\n\t\t\t\t\t\t\t\t\t\tawait deleteColumn(modalState.columnId);\n\t\t\t\t\t\t\t\t\t\tcloseModal();\n\t\t\t\t\t\t\t\t\t\trefetch();\n\t\t\t\t\t\t\t\t\t} catch (error) {\n\t\t\t\t\t\t\t\t\t\tconst message =\n\t\t\t\t\t\t\t\t\t\t\terror instanceof Error\n\t\t\t\t\t\t\t\t\t\t\t\t? error.message\n\t\t\t\t\t\t\t\t\t\t\t\t: \"Failed to delete column\";\n\t\t\t\t\t\t\t\t\t\ttoast.error(message);\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\tclassName=\"bg-red-600 hover:bg-red-700\"\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\tDelete\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{/* Edit Board Modal */}\n\t\t\t !open && closeModal()}\n\t\t\t>\n\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\tEdit Board\n\t\t\t\t\t\tUpdate board details.\n\t\t\t\t\t\n\t\t\t\t\t {\n\t\t\t\t\t\t\tcloseModal();\n\t\t\t\t\t\t\trefetch();\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 Board Modal */}\n\t\t\t !open && closeModal()}\n\t\t\t>\n\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\tDelete Board\n\t\t\t\t\t\t\n\t\t\t\t\t\t\tAre you sure you want to delete this board? This action cannot be\n\t\t\t\t\t\t\tundone. All columns and tasks will be permanently removed.\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\tCancel\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t{isDeleting ? \"Deleting...\" : \"Delete\"}\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{/* Add Task Modal */}\n\t\t\t !open && closeModal()}\n\t\t\t>\n\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\tAdd Task\n\t\t\t\t\t\tCreate a new task.\n\t\t\t\t\t\n\t\t\t\t\t{modalState.type === \"addTask\" && (\n\t\t\t\t\t\t {\n\t\t\t\t\t\t\t\tcloseModal();\n\t\t\t\t\t\t\t\trefetch();\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{/* Edit Task Modal */}\n\t\t\t !open && closeModal()}\n\t\t\t>\n\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\tEdit Task\n\t\t\t\t\t\tUpdate task details.\n\t\t\t\t\t\n\t\t\t\t\t{modalState.type === \"editTask\" && (\n\t\t\t\t\t\t<>\n\t\t\t\t\t\t\t c.id === modalState.columnId)\n\t\t\t\t\t\t\t\t\t?.tasks?.find((t) => t.id === modalState.taskId)}\n\t\t\t\t\t\t\t\tcolumns={board.columns || []}\n\t\t\t\t\t\t\t\tonClose={closeModal}\n\t\t\t\t\t\t\t\tonSuccess={() => {\n\t\t\t\t\t\t\t\t\tcloseModal();\n\t\t\t\t\t\t\t\t\trefetch();\n\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\tonDelete={async () => {\n\t\t\t\t\t\t\t\t\ttry {\n\t\t\t\t\t\t\t\t\t\tawait deleteTask(modalState.taskId);\n\t\t\t\t\t\t\t\t\t\tcloseModal();\n\t\t\t\t\t\t\t\t\t\trefetch();\n\t\t\t\t\t\t\t\t\t} catch (error) {\n\t\t\t\t\t\t\t\t\t\tconst message =\n\t\t\t\t\t\t\t\t\t\t\terror instanceof Error\n\t\t\t\t\t\t\t\t\t\t\t\t? error.message\n\t\t\t\t\t\t\t\t\t\t\t\t: \"Failed to delete task\";\n\t\t\t\t\t\t\t\t\t\ttoast.error(message);\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{taskDetailBottomSlot &&\n\t\t\t\t\t\t\t\t(() => {\n\t\t\t\t\t\t\t\t\tconst task = board.columns\n\t\t\t\t\t\t\t\t\t\t?.find((c) => c.id === modalState.columnId)\n\t\t\t\t\t\t\t\t\t\t?.tasks?.find((t) => t.id === modalState.taskId);\n\t\t\t\t\t\t\t\t\treturn task ? (\n\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t{taskDetailBottomSlot(task)}\n\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t) : null;\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\n\t\t\t\n\t\t\n\t);\n}\n", "target": "src/components/btst/kanban/client/components/pages/board-page.internal.tsx" }, { @@ -193,7 +193,7 @@ { "path": "btst/kanban/client/overrides.ts", "type": "registry:lib", - "content": "import type { ComponentType } from \"react\";\nimport type { KanbanLocalization } from \"./localization\";\n\n/**\n * User information for assignee display/selection\n * Framework-agnostic - consumers map their auth system to this shape\n */\nexport interface KanbanUser {\n\tid: string;\n\tname: string;\n\tavatarUrl?: string;\n\temail?: string;\n}\n\n/**\n * Context passed to lifecycle hooks\n */\nexport interface RouteContext {\n\t/** Current route path */\n\tpath: string;\n\t/** Route parameters (e.g., { boardId: \"abc123\" }) */\n\tparams?: Record;\n\t/** Whether rendering on server (true) or client (false) */\n\tisSSR: boolean;\n\t/** Additional context properties */\n\t[key: string]: unknown;\n}\n\n/**\n * Overridable components and functions for the Kanban plugin\n *\n * External consumers can provide their own implementations of these\n * to customize the behavior for their framework (Next.js, React Router, etc.)\n */\nexport interface KanbanPluginOverrides {\n\t/**\n\t * Link component for navigation\n\t */\n\tLink?: ComponentType & Record>;\n\t/**\n\t * Navigation function for programmatic navigation\n\t */\n\tnavigate: (path: string) => void | Promise;\n\t/**\n\t * Refresh function to invalidate server-side cache (e.g., Next.js router.refresh())\n\t */\n\trefresh?: () => void | Promise;\n\t/**\n\t * Image component for displaying images\n\t */\n\tImage?: ComponentType<\n\t\tReact.ImgHTMLAttributes & Record\n\t>;\n\t/**\n\t * Localization object for the kanban plugin\n\t */\n\tlocalization?: KanbanLocalization;\n\t/**\n\t * API base URL\n\t */\n\tapiBaseURL: string;\n\t/**\n\t * API base path\n\t */\n\tapiBasePath: string;\n\t/**\n\t * Whether to show the attribution\n\t */\n\tshowAttribution?: boolean;\n\t/**\n\t * Optional headers to pass with API requests (e.g., for SSR auth)\n\t */\n\theaders?: HeadersInit;\n\n\t// ============ User Resolution (required for assignee features) ============\n\n\t/**\n\t * Resolve user info from an assigneeId\n\t * Called when rendering task cards/forms that have an assignee\n\t * Return null for unknown users (will show fallback UI)\n\t */\n\tresolveUser: (\n\t\tuserId: string,\n\t) => Promise | KanbanUser | null;\n\n\t/**\n\t * Search/list users available for assignment\n\t * Called when user opens the assignee picker\n\t * @param query - Search query (empty string for initial load)\n\t * @param boardId - Optional board context for scoped user lists\n\t */\n\tsearchUsers: (\n\t\tquery: string,\n\t\tboardId?: string,\n\t) => Promise | KanbanUser[];\n\n\t// ============ Lifecycle Hooks (optional) ============\n\n\t/**\n\t * Called when a route is rendered\n\t * @param routeName - Name of the route (e.g., 'boards', 'board', 'newBoard')\n\t * @param context - Route context with path, params, etc.\n\t */\n\tonRouteRender?: (\n\t\trouteName: string,\n\t\tcontext: RouteContext,\n\t) => void | Promise;\n\n\t/**\n\t * Called when a route encounters an error\n\t * @param routeName - Name of the route\n\t * @param error - The error that occurred\n\t * @param context - Route context\n\t */\n\tonRouteError?: (\n\t\trouteName: string,\n\t\terror: Error,\n\t\tcontext: RouteContext,\n\t) => void | Promise;\n\n\t/**\n\t * Called before the boards list page is rendered\n\t * Return false to prevent rendering (e.g., for authorization)\n\t * @param context - Route context\n\t */\n\tonBeforeBoardsPageRendered?: (context: RouteContext) => boolean;\n\n\t/**\n\t * Called before a single board page is rendered\n\t * Return false to prevent rendering (e.g., for authorization)\n\t * @param boardId - The board ID\n\t * @param context - Route context\n\t */\n\tonBeforeBoardPageRendered?: (\n\t\tboardId: string,\n\t\tcontext: RouteContext,\n\t) => boolean;\n\n\t/**\n\t * Called before the new board page is rendered\n\t * Return false to prevent rendering (e.g., for authorization)\n\t * @param context - Route context\n\t */\n\tonBeforeNewBoardPageRendered?: (context: RouteContext) => boolean;\n}\n", + "content": "import type { ComponentType, ReactNode } from \"react\";\nimport type { KanbanLocalization } from \"./localization\";\nimport type { SerializedTask } from \"../types\";\n\n/**\n * User information for assignee display/selection\n * Framework-agnostic - consumers map their auth system to this shape\n */\nexport interface KanbanUser {\n\tid: string;\n\tname: string;\n\tavatarUrl?: string;\n\temail?: string;\n}\n\n/**\n * Context passed to lifecycle hooks\n */\nexport interface RouteContext {\n\t/** Current route path */\n\tpath: string;\n\t/** Route parameters (e.g., { boardId: \"abc123\" }) */\n\tparams?: Record;\n\t/** Whether rendering on server (true) or client (false) */\n\tisSSR: boolean;\n\t/** Additional context properties */\n\t[key: string]: unknown;\n}\n\n/**\n * Overridable components and functions for the Kanban plugin\n *\n * External consumers can provide their own implementations of these\n * to customize the behavior for their framework (Next.js, React Router, etc.)\n */\nexport interface KanbanPluginOverrides {\n\t/**\n\t * Link component for navigation\n\t */\n\tLink?: ComponentType & Record>;\n\t/**\n\t * Navigation function for programmatic navigation\n\t */\n\tnavigate: (path: string) => void | Promise;\n\t/**\n\t * Refresh function to invalidate server-side cache (e.g., Next.js router.refresh())\n\t */\n\trefresh?: () => void | Promise;\n\t/**\n\t * Image component for displaying images\n\t */\n\tImage?: ComponentType<\n\t\tReact.ImgHTMLAttributes & Record\n\t>;\n\t/**\n\t * Localization object for the kanban plugin\n\t */\n\tlocalization?: KanbanLocalization;\n\t/**\n\t * API base URL\n\t */\n\tapiBaseURL: string;\n\t/**\n\t * API base path\n\t */\n\tapiBasePath: string;\n\t/**\n\t * Whether to show the attribution\n\t */\n\tshowAttribution?: boolean;\n\t/**\n\t * Optional headers to pass with API requests (e.g., for SSR auth)\n\t */\n\theaders?: HeadersInit;\n\n\t// ============ User Resolution (required for assignee features) ============\n\n\t/**\n\t * Resolve user info from an assigneeId\n\t * Called when rendering task cards/forms that have an assignee\n\t * Return null for unknown users (will show fallback UI)\n\t */\n\tresolveUser: (\n\t\tuserId: string,\n\t) => Promise | KanbanUser | null;\n\n\t/**\n\t * Search/list users available for assignment\n\t * Called when user opens the assignee picker\n\t * @param query - Search query (empty string for initial load)\n\t * @param boardId - Optional board context for scoped user lists\n\t */\n\tsearchUsers: (\n\t\tquery: string,\n\t\tboardId?: string,\n\t) => Promise | KanbanUser[];\n\n\t// ============ Lifecycle Hooks (optional) ============\n\n\t/**\n\t * Called when a route is rendered\n\t * @param routeName - Name of the route (e.g., 'boards', 'board', 'newBoard')\n\t * @param context - Route context with path, params, etc.\n\t */\n\tonRouteRender?: (\n\t\trouteName: string,\n\t\tcontext: RouteContext,\n\t) => void | Promise;\n\n\t/**\n\t * Called when a route encounters an error\n\t * @param routeName - Name of the route\n\t * @param error - The error that occurred\n\t * @param context - Route context\n\t */\n\tonRouteError?: (\n\t\trouteName: string,\n\t\terror: Error,\n\t\tcontext: RouteContext,\n\t) => void | Promise;\n\n\t/**\n\t * Called before the boards list page is rendered\n\t * Return false to prevent rendering (e.g., for authorization)\n\t * @param context - Route context\n\t */\n\tonBeforeBoardsPageRendered?: (context: RouteContext) => boolean;\n\n\t/**\n\t * Called before a single board page is rendered\n\t * Return false to prevent rendering (e.g., for authorization)\n\t * @param boardId - The board ID\n\t * @param context - Route context\n\t */\n\tonBeforeBoardPageRendered?: (\n\t\tboardId: string,\n\t\tcontext: RouteContext,\n\t) => boolean;\n\n\t/**\n\t * Called before the new board page is rendered\n\t * Return false to prevent rendering (e.g., for authorization)\n\t * @param context - Route context\n\t */\n\tonBeforeNewBoardPageRendered?: (context: RouteContext) => boolean;\n\n\t// ============ Slot Overrides ============\n\n\t/**\n\t * Optional slot rendered at the bottom of the task detail dialog.\n\t * Use this to inject a comment thread or any custom content without\n\t * coupling the kanban plugin to the comments plugin.\n\t *\n\t * @example\n\t * ```tsx\n\t * kanban: {\n\t * taskDetailBottomSlot: (task) => (\n\t * \n\t * ),\n\t * }\n\t * ```\n\t */\n\ttaskDetailBottomSlot?: (task: SerializedTask) => ReactNode;\n}\n", "target": "src/components/btst/kanban/client/overrides.ts" }, { diff --git a/packages/stack/registry/registry.json b/packages/stack/registry/registry.json index 7355e1eb..fcc8f8d1 100644 --- a/packages/stack/registry/registry.json +++ b/packages/stack/registry/registry.json @@ -15,7 +15,6 @@ "@milkdown/kit", "date-fns", "highlight.js", - "react-intersection-observer", "react-markdown", "rehype-highlight", "rehype-katex", @@ -169,6 +168,30 @@ ], "docs": "https://better-stack.ai/docs/plugins/kanban" }, + { + "name": "btst-comments", + "type": "registry:block", + "title": "Comments Plugin Pages", + "description": "Ejectable page components for the @btst/stack comments plugin. Customize the UI layer while keeping data-fetching in @btst/stack.", + "author": "BTST ", + "dependencies": [ + "@btst/stack", + "date-fns" + ], + "registryDependencies": [ + "alert-dialog", + "avatar", + "badge", + "button", + "checkbox", + "dialog", + "separator", + "table", + "tabs", + "textarea" + ], + "docs": "https://better-stack.ai/docs/plugins/comments" + }, { "name": "btst-ui-builder", "type": "registry:block", diff --git a/packages/stack/scripts/build-registry.ts b/packages/stack/scripts/build-registry.ts index 7e5b79e1..b34f3a5b 100644 --- a/packages/stack/scripts/build-registry.ts +++ b/packages/stack/scripts/build-registry.ts @@ -173,7 +173,6 @@ const PLUGINS: PluginConfig[] = [ "@milkdown/kit", "date-fns", "highlight.js", - "react-intersection-observer", "react-markdown", "rehype-highlight", "rehype-katex", @@ -273,6 +272,16 @@ const PLUGINS: PluginConfig[] = [ // kanban/utils.ts has no external npm imports (pure utility functions) pluginRootFiles: ["types.ts", "schemas.ts", "utils.ts"], }, + { + name: "comments", + title: "Comments Plugin Pages", + description: + "Ejectable page components for the @btst/stack comments plugin. " + + "Customize the UI layer while keeping data-fetching in @btst/stack.", + extraNpmDeps: ["date-fns"], + extraRegistryDeps: [], + pluginRootFiles: ["types.ts", "schemas.ts"], + }, { name: "ui-builder", title: "UI Builder Plugin Pages", diff --git a/packages/stack/scripts/test-registry.sh b/packages/stack/scripts/test-registry.sh index 10ba8a95..ddbcd135 100755 --- a/packages/stack/scripts/test-registry.sh +++ b/packages/stack/scripts/test-registry.sh @@ -219,7 +219,7 @@ console.log('tsconfig.json patched'); # embedded from packages/ui (see build-registry.ts — "form" excluded from # STANDARD_SHADCN_COMPONENTS). All other standard components (select, accordion, # dialog, dropdown-menu, …) are correctly Radix-based with this flag. - npx --yes shadcn@latest init --defaults --force --base radix + npx --yes shadcn@4.0.5 init --defaults --force --base radix success "shadcn init completed (radix-nova)" INSTALL_FAILURES=() @@ -230,7 +230,7 @@ console.log('tsconfig.json patched'); # We treat those as warnings so the rest of the test can proceed. for PLUGIN in "${PLUGIN_NAMES[@]}"; do echo "Installing btst-${PLUGIN}…" - if npx --yes shadcn@latest add \ + if npx --yes shadcn@4.0.5 add \ "http://localhost:$SERVER_PORT/btst-${PLUGIN}.json" \ --yes --overwrite 2>&1; then success "btst-${PLUGIN} installed" diff --git a/packages/stack/src/plugins/blog/client/components/loading/post-navigation-skeleton.tsx b/packages/stack/src/plugins/blog/client/components/loading/post-navigation-skeleton.tsx new file mode 100644 index 00000000..e63763df --- /dev/null +++ b/packages/stack/src/plugins/blog/client/components/loading/post-navigation-skeleton.tsx @@ -0,0 +1,10 @@ +import { Skeleton } from "@workspace/ui/components/skeleton"; + +export function PostNavigationSkeleton() { + return ( + + + + + ); +} diff --git a/packages/stack/src/plugins/blog/client/components/loading/recent-posts-carousel-skeleton.tsx b/packages/stack/src/plugins/blog/client/components/loading/recent-posts-carousel-skeleton.tsx new file mode 100644 index 00000000..e8354568 --- /dev/null +++ b/packages/stack/src/plugins/blog/client/components/loading/recent-posts-carousel-skeleton.tsx @@ -0,0 +1,18 @@ +import { Skeleton } from "@workspace/ui/components/skeleton"; +import { PostCardSkeleton } from "./post-card-skeleton"; + +export function RecentPostsCarouselSkeleton() { + return ( + + + + + + + {[1, 2, 3].map((i) => ( + + ))} + + + ); +} diff --git a/packages/stack/src/plugins/blog/client/components/pages/post-page.internal.tsx b/packages/stack/src/plugins/blog/client/components/pages/post-page.internal.tsx index 387c1772..4cfdd9a6 100644 --- a/packages/stack/src/plugins/blog/client/components/pages/post-page.internal.tsx +++ b/packages/stack/src/plugins/blog/client/components/pages/post-page.internal.tsx @@ -21,6 +21,9 @@ import { useRouteLifecycle } from "@workspace/ui/hooks/use-route-lifecycle"; import { OnThisPage, OnThisPageSelect } from "../shared/on-this-page"; import type { SerializedPost } from "../../../types"; import { useRegisterPageAIContext } from "@btst/stack/plugins/ai-chat/client/context"; +import { WhenVisible } from "@workspace/ui/components/when-visible"; +import { PostNavigationSkeleton } from "../loading/post-navigation-skeleton"; +import { RecentPostsCarouselSkeleton } from "../loading/recent-posts-carousel-skeleton"; // Internal component with actual page content export function PostPage({ slug }: { slug: string }) { @@ -52,14 +55,14 @@ export function PostPage({ slug }: { slug: string }) { const { post } = useSuspensePost(slug ?? ""); - const { previousPost, nextPost, ref } = useNextPreviousPosts( + const { previousPost, nextPost } = useNextPreviousPosts( post?.createdAt ?? new Date(), { enabled: !!post, }, ); - const { recentPosts, ref: recentPostsRef } = useRecentPosts({ + const { recentPosts } = useRecentPosts({ limit: 5, excludeSlug: slug, enabled: !!post, @@ -120,13 +123,25 @@ export function PostPage({ slug }: { slug: string }) { - + } + > + + - + } + > + + + + {overrides.postBottomSlot && ( + + {overrides.postBottomSlot(post)} + + )} diff --git a/packages/stack/src/plugins/blog/client/components/shared/post-navigation.tsx b/packages/stack/src/plugins/blog/client/components/shared/post-navigation.tsx index 62cec3af..302e6512 100644 --- a/packages/stack/src/plugins/blog/client/components/shared/post-navigation.tsx +++ b/packages/stack/src/plugins/blog/client/components/shared/post-navigation.tsx @@ -10,13 +10,11 @@ import type { SerializedPost } from "../../../types"; interface PostNavigationProps { previousPost: SerializedPost | null; nextPost: SerializedPost | null; - ref?: (node: Element | null) => void; } export function PostNavigation({ previousPost, nextPost, - ref, }: PostNavigationProps) { const { Link } = usePluginOverrides< BlogPluginOverrides, @@ -29,9 +27,6 @@ export function PostNavigation({ return ( <> - {/* Ref div to trigger intersection observer when scrolled into view */} - {ref && } - {/* Only show navigation buttons if posts are available */} {(previousPost || nextPost) && ( <> diff --git a/packages/stack/src/plugins/blog/client/components/shared/recent-posts-carousel.tsx b/packages/stack/src/plugins/blog/client/components/shared/recent-posts-carousel.tsx index 401819a9..c403bcb5 100644 --- a/packages/stack/src/plugins/blog/client/components/shared/recent-posts-carousel.tsx +++ b/packages/stack/src/plugins/blog/client/components/shared/recent-posts-carousel.tsx @@ -16,10 +16,9 @@ import { BLOG_LOCALIZATION } from "../../localization"; interface RecentPostsCarouselProps { posts: SerializedPost[]; - ref?: (node: Element | null) => void; } -export function RecentPostsCarousel({ posts, ref }: RecentPostsCarouselProps) { +export function RecentPostsCarousel({ posts }: RecentPostsCarouselProps) { const { PostCard, Link, localization } = usePluginOverrides< BlogPluginOverrides, Partial @@ -32,9 +31,6 @@ export function RecentPostsCarousel({ posts, ref }: RecentPostsCarouselProps) { const basePath = useBasePath(); return ( - {/* Ref div to trigger intersection observer when scrolled into view */} - {ref && } - {posts && posts.length > 0 && ( <> diff --git a/packages/stack/src/plugins/blog/client/hooks/blog-hooks.tsx b/packages/stack/src/plugins/blog/client/hooks/blog-hooks.tsx index 622f33df..eb8881a7 100644 --- a/packages/stack/src/plugins/blog/client/hooks/blog-hooks.tsx +++ b/packages/stack/src/plugins/blog/client/hooks/blog-hooks.tsx @@ -15,7 +15,6 @@ import type { BlogApiRouter } from "../../api/plugin"; import { useDebounce } from "./use-debounce"; import { useEffect, useRef } from "react"; import { z } from "zod"; -import { useInView } from "react-intersection-observer"; import { createPostSchema, updatePostSchema } from "../../schemas"; import { createBlogQueryKeys } from "../../query-keys"; import { usePluginOverrides } from "@btst/stack/context"; @@ -604,16 +603,13 @@ export interface UseNextPreviousPostsResult { } /** - * Hook for fetching previous and next posts relative to a given date - * Uses useInView to only fetch when the component is in view + * Hook for fetching previous and next posts relative to a given date. + * Pair with `` in the render tree for lazy loading. */ export function useNextPreviousPosts( createdAt: string | Date, options: UseNextPreviousPostsOptions = {}, -): UseNextPreviousPostsResult & { - ref: (node: Element | null) => void; - inView: boolean; -} { +): UseNextPreviousPostsResult { const { apiBaseURL, apiBasePath, headers } = usePluginOverrides("blog"); const client = createApiClient({ @@ -622,13 +618,6 @@ export function useNextPreviousPosts( }); const queries = createBlogQueryKeys(client, headers); - const { ref, inView } = useInView({ - // start a little early so the data is ready as it scrolls in - rootMargin: "200px 0px", - // run once; keep data cached after - triggerOnce: true, - }); - const dateValue = typeof createdAt === "string" ? new Date(createdAt) : createdAt; const baseQuery = queries.posts.nextPrevious(dateValue); @@ -641,7 +630,7 @@ export function useNextPreviousPosts( >({ ...baseQuery, ...SHARED_QUERY_CONFIG, - enabled: (options.enabled ?? true) && inView && !!client, + enabled: (options.enabled ?? true) && !!client, }); return { @@ -650,8 +639,6 @@ export function useNextPreviousPosts( isLoading, error, refetch, - ref, - inView, }; } @@ -682,15 +669,12 @@ export interface UseRecentPostsResult { } /** - * Hook for fetching recent posts - * Uses useInView to only fetch when the component is in view + * Hook for fetching recent posts. + * Pair with `` in the render tree for lazy loading. */ export function useRecentPosts( options: UseRecentPostsOptions = {}, -): UseRecentPostsResult & { - ref: (node: Element | null) => void; - inView: boolean; -} { +): UseRecentPostsResult { const { apiBaseURL, apiBasePath, headers } = usePluginOverrides("blog"); const client = createApiClient({ @@ -699,13 +683,6 @@ export function useRecentPosts( }); const queries = createBlogQueryKeys(client, headers); - const { ref, inView } = useInView({ - // start a little early so the data is ready as it scrolls in - rootMargin: "200px 0px", - // run once; keep data cached after - triggerOnce: true, - }); - const baseQuery = queries.posts.recent({ limit: options.limit ?? 5, excludeSlug: options.excludeSlug, @@ -719,7 +696,7 @@ export function useRecentPosts( >({ ...baseQuery, ...SHARED_QUERY_CONFIG, - enabled: (options.enabled ?? true) && inView && !!client, + enabled: (options.enabled ?? true) && !!client, }); return { @@ -727,7 +704,5 @@ export function useRecentPosts( isLoading, error, refetch, - ref, - inView, }; } diff --git a/packages/stack/src/plugins/blog/client/overrides.ts b/packages/stack/src/plugins/blog/client/overrides.ts index 921f2651..c1d543ed 100644 --- a/packages/stack/src/plugins/blog/client/overrides.ts +++ b/packages/stack/src/plugins/blog/client/overrides.ts @@ -1,5 +1,5 @@ import type { SerializedPost } from "../types"; -import type { ComponentType } from "react"; +import type { ComponentType, ReactNode } from "react"; import type { BlogLocalization } from "./localization"; /** @@ -134,4 +134,29 @@ export interface BlogPluginOverrides { * @param context - Route context */ onBeforeDraftsPageRendered?: (context: RouteContext) => boolean; + + // ============ Slot Overrides ============ + + /** + * Optional slot rendered below the blog post body. + * Use this to inject a comment thread or any custom content without + * coupling the blog plugin to the comments plugin. + * + * @example + * ```tsx + * blog: { + * postBottomSlot: (post) => ( + * + * ), + * } + * ``` + */ + postBottomSlot?: (post: SerializedPost) => ReactNode; } diff --git a/packages/stack/src/plugins/cms/client/components/shared/pagination.tsx b/packages/stack/src/plugins/cms/client/components/shared/pagination.tsx index 624730d3..ff40b77d 100644 --- a/packages/stack/src/plugins/cms/client/components/shared/pagination.tsx +++ b/packages/stack/src/plugins/cms/client/components/shared/pagination.tsx @@ -1,10 +1,9 @@ "use client"; -import { Button } from "@workspace/ui/components/button"; -import { ChevronLeft, ChevronRight } from "lucide-react"; import { usePluginOverrides } from "@btst/stack/context"; import type { CMSPluginOverrides } from "../../overrides"; import { CMS_LOCALIZATION } from "../../localization"; +import { PaginationControls } from "@workspace/ui/components/pagination-controls"; interface PaginationProps { currentPage: number; @@ -27,46 +26,19 @@ export function Pagination({ usePluginOverrides("cms"); const localization = { ...CMS_LOCALIZATION, ...customLocalization }; - const from = offset + 1; - const to = Math.min(offset + limit, total); - - if (totalPages <= 1) { - return null; - } - return ( - - - {localization.CMS_LIST_PAGINATION_SHOWING.replace( - "{from}", - String(from), - ) - .replace("{to}", String(to)) - .replace("{total}", String(total))} - - - onPageChange(currentPage - 1)} - disabled={currentPage === 1} - > - - {localization.CMS_LIST_PAGINATION_PREVIOUS} - - - {currentPage} / {totalPages} - - onPageChange(currentPage + 1)} - disabled={currentPage === totalPages} - > - {localization.CMS_LIST_PAGINATION_NEXT} - - - - + ); } diff --git a/packages/stack/src/plugins/comments/api/getters.ts b/packages/stack/src/plugins/comments/api/getters.ts new file mode 100644 index 00000000..80a2dad0 --- /dev/null +++ b/packages/stack/src/plugins/comments/api/getters.ts @@ -0,0 +1,376 @@ +import type { DBAdapter as Adapter } from "@btst/db"; +import type { + Comment, + CommentLike, + CommentListResult, + SerializedComment, +} from "../types"; +import type { z } from "zod"; +import type { + CommentListQuerySchema, + CommentCountQuerySchema, +} from "../schemas"; + +/** + * Resolve display info for a batch of authorIds using the consumer-supplied resolveUser hook. + * Deduplicates lookups — each unique authorId is resolved only once per call. + * + * @remarks **Security:** No authorization hooks are called. The caller is responsible for + * any access-control checks before invoking this function. + */ +async function resolveAuthors( + authorIds: string[], + resolveUser?: ( + authorId: string, + ) => Promise<{ name: string; avatarUrl?: string } | null>, +): Promise> { + const unique = [...new Set(authorIds)]; + const map = new Map(); + + if (!resolveUser || unique.length === 0) { + for (const id of unique) { + map.set(id, { name: "[deleted]", avatarUrl: null }); + } + return map; + } + + await Promise.all( + unique.map(async (id) => { + try { + const result = await resolveUser(id); + map.set(id, { + name: result?.name ?? "[deleted]", + avatarUrl: result?.avatarUrl ?? null, + }); + } catch { + map.set(id, { name: "[deleted]", avatarUrl: null }); + } + }), + ); + + return map; +} + +/** + * Serialize a raw Comment from the DB into a SerializedComment for the API response. + * Enriches with resolved author info and like status. + */ +function enrichComment( + comment: Comment, + authorMap: Map, + likedCommentIds: Set, + replyCount = 0, +): SerializedComment { + const author = authorMap.get(comment.authorId) ?? { + name: "[deleted]", + avatarUrl: null, + }; + return { + id: comment.id, + resourceId: comment.resourceId, + resourceType: comment.resourceType, + parentId: comment.parentId ?? null, + authorId: comment.authorId, + resolvedAuthorName: author.name, + resolvedAvatarUrl: author.avatarUrl, + body: comment.body, + status: comment.status, + likes: comment.likes, + isLikedByCurrentUser: likedCommentIds.has(comment.id), + editedAt: comment.editedAt?.toISOString() ?? null, + createdAt: comment.createdAt.toISOString(), + updatedAt: comment.updatedAt.toISOString(), + replyCount, + }; +} + +type WhereCondition = { + field: string; + value: string | number | boolean | Date | string[] | number[] | null; + operator: "eq"; +}; + +/** + * Build the base WHERE conditions from common list params (excluding status). + */ +function buildBaseConditions( + params: z.infer, +): WhereCondition[] { + const conditions: WhereCondition[] = []; + + if (params.resourceId) { + conditions.push({ + field: "resourceId", + value: params.resourceId, + operator: "eq", + }); + } + if (params.resourceType) { + conditions.push({ + field: "resourceType", + value: params.resourceType, + operator: "eq", + }); + } + if (params.parentId !== undefined) { + const parentValue = + params.parentId === null || params.parentId === "null" + ? null + : params.parentId; + conditions.push({ field: "parentId", value: parentValue, operator: "eq" }); + } + if (params.authorId) { + conditions.push({ + field: "authorId", + value: params.authorId, + operator: "eq", + }); + } + + return conditions; +} + +/** + * List comments for a resource, optionally filtered by status and parentId. + * Server-side resolves author display info and like status. + * + * When `status` is "approved" (default) and `currentUserId` is provided, the + * result also includes the current user's own pending comments so they remain + * visible after a page refresh without requiring admin access. + * + * Pure DB function — no hooks, no HTTP context. Safe for server-side use. + * + * @param adapter - The database adapter + * @param params - Filter/pagination parameters + * @param resolveUser - Optional consumer hook to resolve author display info + */ +export async function listComments( + adapter: Adapter, + params: z.infer, + resolveUser?: ( + authorId: string, + ) => Promise<{ name: string; avatarUrl?: string } | null>, +): Promise { + const limit = params.limit ?? 20; + const offset = params.offset ?? 0; + const sortDirection = params.sort ?? "asc"; + + // When authorId is provided and no explicit status filter is requested, + // return all statuses (the "my comments" mode — the caller owns the data). + // Otherwise default to "approved" to prevent leaking pending/spam to + // unauthenticated callers. + const omitStatusFilter = !!params.authorId && !params.status; + const statusFilter = omitStatusFilter ? null : (params.status ?? "approved"); + const baseConditions = buildBaseConditions(params); + + let comments: Comment[]; + let total: number; + + if ( + !omitStatusFilter && + statusFilter === "approved" && + params.currentUserId + ) { + // Fetch approved comments AND the current user's own pending comments so + // they remain visible after a page refresh (React Query cache is lost). + // Two separate queries are needed because the DB adapter only supports + // AND-chained equality conditions (no OR operator). + const [approvedRaw, ownPendingRaw] = await Promise.all([ + adapter.findMany({ + model: "comment", + where: [ + ...baseConditions, + { field: "status", value: "approved", operator: "eq" }, + ], + sortBy: { field: "createdAt", direction: sortDirection }, + }), + adapter.findMany({ + model: "comment", + where: [ + ...baseConditions, + { field: "status", value: "pending", operator: "eq" }, + { field: "authorId", value: params.currentUserId, operator: "eq" }, + ], + sortBy: { field: "createdAt", direction: sortDirection }, + }), + ]); + + // Merge — approved takes precedence if an ID somehow appears in both. + const approvedIds = new Set(approvedRaw.map((c) => c.id)); + const merged = [ + ...approvedRaw, + ...ownPendingRaw.filter((c) => !approvedIds.has(c.id)), + ]; + merged.sort((a, b) => { + const diff = a.createdAt.getTime() - b.createdAt.getTime(); + return sortDirection === "desc" ? -diff : diff; + }); + + total = merged.length; + comments = merged.slice(offset, offset + limit); + } else { + const where: WhereCondition[] = [...baseConditions]; + if (statusFilter !== null) { + where.push({ + field: "status", + value: statusFilter, + operator: "eq", + }); + } + + const [found, count] = await Promise.all([ + adapter.findMany({ + model: "comment", + limit, + offset, + where, + sortBy: { field: "createdAt", direction: sortDirection }, + }), + adapter.count({ model: "comment", where }), + ]); + comments = found; + total = count; + } + + // Resolve author display info server-side + const authorIds = comments.map((c) => c.authorId); + const authorMap = await resolveAuthors(authorIds, resolveUser); + + // Resolve like status for currentUserId (if provided) + const likedCommentIds = new Set(); + if (params.currentUserId && comments.length > 0) { + const commentIds = comments.map((c) => c.id); + // Fetch all likes by the currentUser for these comments + const likes = await Promise.all( + commentIds.map((commentId) => + adapter.findOne({ + model: "commentLike", + where: [ + { field: "commentId", value: commentId, operator: "eq" }, + { + field: "authorId", + value: params.currentUserId!, + operator: "eq", + }, + ], + }), + ), + ); + likes.forEach((like, i) => { + if (like) likedCommentIds.add(commentIds[i]!); + }); + } + + // Batch-count replies for top-level comments so the client can show the + // expand button without firing a separate request per comment. + // When currentUserId is provided, also count the user's own pending replies + // so the button appears immediately after a page refresh. + const replyCounts = new Map(); + const isTopLevelQuery = + params.parentId === null || params.parentId === "null"; + if (isTopLevelQuery && comments.length > 0) { + await Promise.all( + comments.map(async (c) => { + const approvedCount = await adapter.count({ + model: "comment", + where: [ + { field: "parentId", value: c.id, operator: "eq" }, + { field: "status", value: "approved", operator: "eq" }, + ], + }); + + let ownPendingCount = 0; + if (params.currentUserId) { + ownPendingCount = await adapter.count({ + model: "comment", + where: [ + { field: "parentId", value: c.id, operator: "eq" }, + { field: "status", value: "pending", operator: "eq" }, + { + field: "authorId", + value: params.currentUserId, + operator: "eq", + }, + ], + }); + } + + replyCounts.set(c.id, approvedCount + ownPendingCount); + }), + ); + } + + const items = comments.map((c) => + enrichComment(c, authorMap, likedCommentIds, replyCounts.get(c.id) ?? 0), + ); + + return { items, total, limit, offset }; +} + +/** + * Get a single comment by ID, enriched with author info. + * Returns null if not found. + * + * Pure DB function — no hooks, no HTTP context. + */ +export async function getCommentById( + adapter: Adapter, + id: string, + resolveUser?: ( + authorId: string, + ) => Promise<{ name: string; avatarUrl?: string } | null>, + currentUserId?: string, +): Promise { + const comment = await adapter.findOne({ + model: "comment", + where: [{ field: "id", value: id, operator: "eq" }], + }); + + if (!comment) return null; + + const authorMap = await resolveAuthors([comment.authorId], resolveUser); + + const likedCommentIds = new Set(); + if (currentUserId) { + const like = await adapter.findOne({ + model: "commentLike", + where: [ + { field: "commentId", value: id, operator: "eq" }, + { field: "authorId", value: currentUserId, operator: "eq" }, + ], + }); + if (like) likedCommentIds.add(id); + } + + return enrichComment(comment, authorMap, likedCommentIds); +} + +/** + * Count comments for a resource, optionally filtered by status. + * + * Pure DB function — no hooks, no HTTP context. + */ +export async function getCommentCount( + adapter: Adapter, + params: z.infer, +): Promise { + const whereConditions: Array<{ + field: string; + value: string | number | boolean | Date | string[] | number[] | null; + operator: "eq"; + }> = [ + { field: "resourceId", value: params.resourceId, operator: "eq" }, + { field: "resourceType", value: params.resourceType, operator: "eq" }, + ]; + + // Default to "approved" when no status is provided so that omitting the + // parameter never leaks pending/spam counts to unauthenticated callers. + const statusFilter = params.status ?? "approved"; + whereConditions.push({ + field: "status", + value: statusFilter, + operator: "eq", + }); + + return adapter.count({ model: "comment", where: whereConditions }); +} diff --git a/packages/stack/src/plugins/comments/api/index.ts b/packages/stack/src/plugins/comments/api/index.ts new file mode 100644 index 00000000..fcc9db64 --- /dev/null +++ b/packages/stack/src/plugins/comments/api/index.ts @@ -0,0 +1,21 @@ +export { + commentsBackendPlugin, + type CommentsApiRouter, + type CommentsApiContext, + type CommentsBackendOptions, +} from "./plugin"; +export { + listComments, + getCommentById, + getCommentCount, +} from "./getters"; +export { + createComment, + updateComment, + updateCommentStatus, + deleteComment, + toggleCommentLike, + type CreateCommentInput, +} from "./mutations"; +export { serializeComment } from "./serializers"; +export { COMMENTS_QUERY_KEYS } from "./query-key-defs"; diff --git a/packages/stack/src/plugins/comments/api/mutations.ts b/packages/stack/src/plugins/comments/api/mutations.ts new file mode 100644 index 00000000..a99935e6 --- /dev/null +++ b/packages/stack/src/plugins/comments/api/mutations.ts @@ -0,0 +1,188 @@ +import type { DBAdapter as Adapter } from "@btst/db"; +import type { Comment, CommentLike } from "../types"; + +/** + * Input for creating a new comment. + */ +export interface CreateCommentInput { + resourceId: string; + resourceType: string; + parentId?: string | null; + authorId: string; + body: string; + status?: "pending" | "approved" | "spam"; +} + +/** + * Create a new comment. + * + * @remarks **Security:** No authorization hooks are called. The caller is + * responsible for any access-control checks (e.g., onBeforePost) before + * invoking this function. + */ +export async function createComment( + adapter: Adapter, + input: CreateCommentInput, +): Promise { + return adapter.create({ + model: "comment", + data: { + resourceId: input.resourceId, + resourceType: input.resourceType, + parentId: input.parentId ?? null, + authorId: input.authorId, + body: input.body, + status: input.status ?? "pending", + likes: 0, + createdAt: new Date(), + updatedAt: new Date(), + }, + }); +} + +/** + * Update the body of an existing comment and set editedAt. + * + * @remarks **Security:** No authorization hooks are called. The caller is + * responsible for ensuring the requesting user owns the comment (onBeforeEdit). + */ +export async function updateComment( + adapter: Adapter, + id: string, + body: string, +): Promise { + const existing = await adapter.findOne({ + model: "comment", + where: [{ field: "id", value: id, operator: "eq" }], + }); + if (!existing) return null; + + return adapter.update({ + model: "comment", + where: [{ field: "id", value: id, operator: "eq" }], + update: { + body, + editedAt: new Date(), + updatedAt: new Date(), + }, + }); +} + +/** + * Update the status of a comment (approve, reject, spam). + * + * @remarks **Security:** No authorization hooks are called. Callers should + * ensure the requesting user has moderation privileges. + */ +export async function updateCommentStatus( + adapter: Adapter, + id: string, + status: "pending" | "approved" | "spam", +): Promise { + const existing = await adapter.findOne({ + model: "comment", + where: [{ field: "id", value: id, operator: "eq" }], + }); + if (!existing) return null; + + return adapter.update({ + model: "comment", + where: [{ field: "id", value: id, operator: "eq" }], + update: { status, updatedAt: new Date() }, + }); +} + +/** + * Delete a comment by ID. + * + * @remarks **Security:** No authorization hooks are called. Callers should + * ensure the requesting user has permission to delete this comment. + */ +export async function deleteComment( + adapter: Adapter, + id: string, +): Promise { + const existing = await adapter.findOne({ + model: "comment", + where: [{ field: "id", value: id, operator: "eq" }], + }); + if (!existing) return false; + + await adapter.delete({ + model: "comment", + where: [{ field: "id", value: id, operator: "eq" }], + }); + return true; +} + +/** + * Toggle a like on a comment for a given authorId. + * - If the user has not liked the comment: creates a commentLike row and increments the likes counter. + * - If the user has already liked the comment: deletes the commentLike row and decrements the likes counter. + * Returns the updated likes count. + * + * All reads and writes are performed inside a single transaction to prevent + * concurrent requests from causing counter drift or duplicate like rows. + * + * @remarks **Security:** No authorization hooks are called. The caller is + * responsible for ensuring the requesting user is authenticated (authorId is valid). + */ +export async function toggleCommentLike( + adapter: Adapter, + commentId: string, + authorId: string, +): Promise<{ likes: number; isLiked: boolean }> { + return adapter.transaction(async (tx) => { + const comment = await tx.findOne({ + model: "comment", + where: [{ field: "id", value: commentId, operator: "eq" }], + }); + if (!comment) { + throw new Error("Comment not found"); + } + + const existingLike = await tx.findOne({ + model: "commentLike", + where: [ + { field: "commentId", value: commentId, operator: "eq" }, + { field: "authorId", value: authorId, operator: "eq" }, + ], + }); + + let newLikes: number; + let isLiked: boolean; + + if (existingLike) { + // Unlike + await tx.delete({ + model: "commentLike", + where: [ + { field: "commentId", value: commentId, operator: "eq" }, + { field: "authorId", value: authorId, operator: "eq" }, + ], + }); + newLikes = Math.max(0, comment.likes - 1); + isLiked = false; + } else { + // Like + await tx.create({ + model: "commentLike", + data: { + commentId, + authorId, + createdAt: new Date(), + }, + }); + newLikes = comment.likes + 1; + isLiked = true; + } + + await tx.update({ + model: "comment", + where: [{ field: "id", value: commentId, operator: "eq" }], + update: { likes: newLikes, updatedAt: new Date() }, + }); + + return { likes: newLikes, isLiked }; + }); +} diff --git a/packages/stack/src/plugins/comments/api/plugin.ts b/packages/stack/src/plugins/comments/api/plugin.ts new file mode 100644 index 00000000..a8f2872f --- /dev/null +++ b/packages/stack/src/plugins/comments/api/plugin.ts @@ -0,0 +1,625 @@ +import type { DBAdapter as Adapter } from "@btst/db"; +import { defineBackendPlugin, createEndpoint } from "@btst/stack/plugins/api"; +import { z } from "zod"; +import { commentsSchema as dbSchema } from "../db"; +import type { Comment } from "../types"; +import { + CommentListQuerySchema, + CommentCountQuerySchema, + createCommentSchema, + updateCommentSchema, + updateCommentStatusSchema, +} from "../schemas"; +import { listComments, getCommentById, getCommentCount } from "./getters"; +import { + createComment, + updateComment, + updateCommentStatus, + deleteComment, + toggleCommentLike, +} from "./mutations"; +import { runHookWithShim } from "../../utils"; + +/** + * Context passed to comments API hooks + */ +export interface CommentsApiContext { + body?: unknown; + params?: unknown; + query?: unknown; + request?: Request; + headers?: Headers; + [key: string]: unknown; +} + +/** + * Configuration options for the comments backend plugin + */ +export interface CommentsBackendOptions { + /** + * When true, new comments are automatically approved (status: "approved"). + * Default: false — all comments start as "pending" until a moderator approves. + */ + autoApprove?: boolean; + + /** + * Server-side user resolution hook. Called once per unique authorId when + * serving GET /comments. Return null for deleted/unknown users (shown as "[deleted]"). + * Deduplicates lookups — each unique authorId is resolved only once per request. + */ + resolveUser?: ( + authorId: string, + ) => Promise<{ name: string; avatarUrl?: string } | null>; + + /** + * Called before the comment list or count is returned. Throw to reject. + * When this hook is absent, any request with `status` other than "approved" + * is automatically rejected with 403 on both `GET /comments` and + * `GET /comments/count` — preventing anonymous callers from reading or + * probing the pending/spam moderation queues. Configure this hook to + * authorize admin callers (e.g. check session role). + */ + onBeforeList?: ( + query: z.infer, + context: CommentsApiContext, + ) => Promise | void; + + /** + * Called before a comment is created. Must return `{ authorId: string }` — + * the server-resolved identity of the commenter. + * + * ⚠️ SECURITY REQUIRED: Derive `authorId` from the authenticated session + * (e.g. JWT / session cookie). Never trust any ID supplied by the client. + * Throw to reject the request (e.g. when the user is not authenticated). + * + * `authorId` is intentionally absent from the POST body schema. This hook + * is the only place it can be set. `commentsBackendPlugin` throws at startup + * if this hook is not provided. + */ + onBeforePost: ( + input: z.infer, + context: CommentsApiContext, + ) => Promise<{ authorId: string }> | { authorId: string }; + + /** + * Called after a comment is successfully created. + */ + onAfterPost?: ( + comment: Comment, + context: CommentsApiContext, + ) => Promise | void; + + /** + * Called before a comment body is edited. Throw an error to reject the edit. + * Use this to enforce that only the comment owner can edit (compare authorId to session). + */ + onBeforeEdit?: ( + commentId: string, + update: { body: string }, + context: CommentsApiContext, + ) => Promise | void; + + /** + * Called after a comment is successfully edited. + */ + onAfterEdit?: ( + comment: Comment, + context: CommentsApiContext, + ) => Promise | void; + + /** + * Called before a like is toggled. Throw to reject. + * + * When this hook is **absent**, any like/unlike request is automatically + * rejected with 403 — preventing unauthenticated callers from toggling likes + * on behalf of arbitrary user IDs. Configure this hook to verify `authorId` + * matches the authenticated session. + */ + onBeforeLike?: ( + commentId: string, + authorId: string, + context: CommentsApiContext, + ) => Promise | void; + + /** + * Called before a comment's status is changed. Throw to reject. + * + * When this hook is **absent**, any status-change request is automatically + * rejected with 403 — preventing unauthenticated callers from moderating + * comments. Configure this hook to verify the caller has admin/moderator + * privileges. + */ + onBeforeStatusChange?: ( + commentId: string, + status: "pending" | "approved" | "spam", + context: CommentsApiContext, + ) => Promise | void; + + /** + * Called after a comment status is changed to "approved". + */ + onAfterApprove?: ( + comment: Comment, + context: CommentsApiContext, + ) => Promise | void; + + /** + * Called before a comment is deleted. Throw to reject. + * + * When this hook is **absent**, any delete request is automatically rejected + * with 403 — preventing unauthenticated callers from deleting comments. The + * CommentCard UI hides the Delete button client-side, but that is not a + * security boundary. Configure this hook to enforce admin-only access. + */ + onBeforeDelete?: ( + commentId: string, + context: CommentsApiContext, + ) => Promise | void; + + /** + * Called after a comment is deleted. + */ + onAfterDelete?: ( + commentId: string, + context: CommentsApiContext, + ) => Promise | void; + + /** + * Called before the comment list is returned for an author-scoped query + * (i.e. when `authorId` is present in `GET /comments`). Throw to reject. + * + * When this hook is **absent**, any request that includes `authorId` is + * automatically rejected with 403 — preventing anonymous callers from + * reading or probing any user's comment history. + * + * Use this hook to verify the `authorId` matches the authenticated session: + * ```ts + * onBeforeListByAuthor: async (authorId, _query, ctx) => { + * const session = await getSession(ctx.headers) + * if (!session?.user) throw new Error("Authentication required") + * if (authorId !== session.user.id && !session.user.isAdmin) + * throw new Error("Forbidden") + * } + * ``` + */ + onBeforeListByAuthor?: ( + authorId: string, + query: z.infer, + context: CommentsApiContext, + ) => Promise | void; + + /** + * Resolve the current authenticated user's ID from the request context + * (e.g. session cookie or JWT). The resolved ID is used to include the + * user's own pending comments alongside approved ones in `GET /comments` + * responses so they remain visible after posting. + * + * Return `null` or `undefined` to indicate the request is unauthenticated. + * + * `commentsBackendPlugin` throws at startup if this hook is not provided. + * + * ```ts + * resolveCurrentUserId: async (ctx) => { + * const session = await getSession(ctx.headers) + * return session?.user?.id ?? null + * } + * ``` + */ + resolveCurrentUserId: ( + context: CommentsApiContext, + ) => Promise | string | null | undefined; +} + +export const commentsBackendPlugin = (options: CommentsBackendOptions) => { + if (!options?.onBeforePost) { + throw new Error( + "[btst/comments] onBeforePost is required. " + + "It must return { authorId: string } derived from the authenticated session. " + + "authorId is no longer accepted in the POST body — the server resolves identity exclusively via this hook.", + ); + } + if (!options?.resolveCurrentUserId) { + throw new Error( + "[btst/comments] resolveCurrentUserId is required. " + + "It must return the current user's ID derived from the authenticated session, " + + "or null/undefined when unauthenticated. " + + "The client-supplied currentUserId query parameter is never trusted — " + + "the server resolves identity exclusively via this hook.", + ); + } + + return defineBackendPlugin({ + name: "comments", + dbPlugin: dbSchema, + + api: (adapter: Adapter) => ({ + listComments: (params: z.infer) => + listComments(adapter, params, options?.resolveUser), + getCommentById: (id: string, currentUserId?: string) => + getCommentById(adapter, id, options?.resolveUser, currentUserId), + getCommentCount: (params: z.infer) => + getCommentCount(adapter, params), + }), + + routes: (adapter: Adapter) => { + // GET /comments + const listCommentsEndpoint = createEndpoint( + "/comments", + { + method: "GET", + query: CommentListQuerySchema, + }, + async (ctx) => { + const context: CommentsApiContext = { + query: ctx.query, + headers: ctx.headers, + }; + try { + // Author-scoped queries: require onBeforeListByAuthor (403 when absent). + // This is the single security gate for per-user comment history queries + // and runs before any status-filter check. + if (ctx.query.authorId) { + if (!options?.onBeforeListByAuthor) { + throw ctx.error(403, { + message: + "Forbidden: authorId filter requires onBeforeListByAuthor hook", + }); + } + await runHookWithShim( + () => + options.onBeforeListByAuthor!( + ctx.query.authorId!, + ctx.query, + context, + ), + ctx.error, + "Forbidden: Cannot list comments for this author", + ); + } + + // Restrict non-approved status filters to authorized callers only. + // Without onBeforeList, anonymous callers cannot read pending/spam queues. + if (ctx.query.status && ctx.query.status !== "approved") { + if (!options?.onBeforeList) { + throw ctx.error(403, { + message: "Forbidden: status filter requires authorization", + }); + } + await runHookWithShim( + () => options.onBeforeList!(ctx.query, context), + ctx.error, + "Forbidden: Cannot list comments with this status filter", + ); + } else if (options?.onBeforeList && !ctx.query.authorId) { + // Only call onBeforeList for non-author-scoped queries to avoid + // double-hooking when both authorId and onBeforeList are present. + await runHookWithShim( + () => options.onBeforeList!(ctx.query, context), + ctx.error, + "Forbidden: Cannot list comments", + ); + } + + // Resolve currentUserId server-side — the client-supplied query + // parameter is intentionally discarded and replaced with the + // session-verified identity from resolveCurrentUserId. + let resolvedCurrentUserId: string | undefined; + try { + const result = await options.resolveCurrentUserId(context); + resolvedCurrentUserId = result ?? undefined; + } catch { + resolvedCurrentUserId = undefined; + } + + return await listComments( + adapter, + { ...ctx.query, currentUserId: resolvedCurrentUserId }, + options?.resolveUser, + ); + } catch (error) { + throw error; + } + }, + ); + + // POST /comments + const createCommentEndpoint = createEndpoint( + "/comments", + { + method: "POST", + body: createCommentSchema, + }, + async (ctx) => { + const context: CommentsApiContext = { + body: ctx.body, + headers: ctx.headers, + }; + try { + const { authorId } = await runHookWithShim( + () => options.onBeforePost(ctx.body, context), + ctx.error, + "Unauthorized: Cannot post comment", + ); + + const status = options?.autoApprove ? "approved" : "pending"; + const comment = await createComment(adapter, { + ...ctx.body, + authorId, + status, + }); + + if (options?.onAfterPost) { + await options.onAfterPost(comment, context); + } + + // Return a fully serialized comment so the client receives + // resolvedAuthorName / resolvedAvatarUrl / isLikedByCurrentUser — + // without this the optimistic-update replacement crashes because + // those fields are undefined on the raw DB record. + const serialized = await getCommentById( + adapter, + comment.id, + options?.resolveUser, + ); + return serialized ?? comment; + } catch (error) { + throw error; + } + }, + ); + + // PATCH /comments/:id (edit body) + const updateCommentEndpoint = createEndpoint( + "/comments/:id", + { + method: "PATCH", + body: updateCommentSchema, + }, + async (ctx) => { + const { id } = ctx.params; + const context: CommentsApiContext = { + params: ctx.params, + body: ctx.body, + headers: ctx.headers, + }; + try { + // Require onBeforeEdit (403 when absent). + // Without an explicit hook the caller cannot be authenticated, so + // editing any comment body is rejected by default — matching the + // same secure-by-default pattern used for onBeforeListByAuthor. + if (!options?.onBeforeEdit) { + throw ctx.error(403, { + message: + "Forbidden: editing comments requires the onBeforeEdit hook", + }); + } + await runHookWithShim( + () => options.onBeforeEdit!(id, { body: ctx.body.body }, context), + ctx.error, + "Unauthorized: Cannot edit comment", + ); + + const updated = await updateComment(adapter, id, ctx.body.body); + if (!updated) { + throw ctx.error(404, { message: "Comment not found" }); + } + + if (options?.onAfterEdit) { + await options.onAfterEdit(updated, context); + } + + return updated; + } catch (error) { + throw error; + } + }, + ); + + // GET /comments/count + const getCommentCountEndpoint = createEndpoint( + "/comments/count", + { + method: "GET", + query: CommentCountQuerySchema, + }, + async (ctx) => { + const context: CommentsApiContext = { + query: ctx.query, + headers: ctx.headers, + }; + try { + // Mirror the same authorization guard used by GET /comments. + // Without onBeforeList, non-approved status counts are blocked so + // unauthenticated callers cannot probe the moderation queue sizes. + if (ctx.query.status && ctx.query.status !== "approved") { + if (!options?.onBeforeList) { + throw ctx.error(403, { + message: "Forbidden: status filter requires authorization", + }); + } + await runHookWithShim( + () => + options.onBeforeList!( + { ...ctx.query, status: ctx.query.status }, + context, + ), + ctx.error, + "Forbidden: Cannot count comments with this status filter", + ); + } else if (options?.onBeforeList) { + await runHookWithShim( + () => + options.onBeforeList!( + { ...ctx.query, status: ctx.query.status }, + context, + ), + ctx.error, + "Forbidden: Cannot count comments", + ); + } + + const count = await getCommentCount(adapter, ctx.query); + return { count }; + } catch (error) { + throw error; + } + }, + ); + + // POST /comments/:id/like (toggle) + const toggleLikeEndpoint = createEndpoint( + "/comments/:id/like", + { + method: "POST", + body: z.object({ authorId: z.string().min(1) }), + }, + async (ctx) => { + const { id } = ctx.params; + const context: CommentsApiContext = { + params: ctx.params, + body: ctx.body, + headers: ctx.headers, + }; + try { + // Require onBeforeLike (403 when absent) — same secure-by-default + // pattern used for onBeforeEdit, onBeforeStatusChange, and + // onBeforeDelete. The authorId in the request body is client-supplied + // and must be verified against the authenticated session; without + // this hook any caller can toggle likes on behalf of any user ID. + if (!options?.onBeforeLike) { + throw ctx.error(403, { + message: + "Forbidden: toggling likes requires the onBeforeLike hook", + }); + } + await runHookWithShim( + () => options.onBeforeLike!(id, ctx.body.authorId, context), + ctx.error, + "Unauthorized: Cannot like comment", + ); + + const result = await toggleCommentLike( + adapter, + id, + ctx.body.authorId, + ); + return result; + } catch (error) { + throw error; + } + }, + ); + + // PATCH /comments/:id/status (admin) + const updateStatusEndpoint = createEndpoint( + "/comments/:id/status", + { + method: "PATCH", + body: updateCommentStatusSchema, + }, + async (ctx) => { + const { id } = ctx.params; + const context: CommentsApiContext = { + params: ctx.params, + body: ctx.body, + headers: ctx.headers, + }; + try { + // Require onBeforeStatusChange (403 when absent) — same + // secure-by-default pattern used for onBeforeEdit and + // onBeforeListByAuthor. Moderation is an admin operation; without + // this hook any unauthenticated caller could change any comment's + // status. + if (!options?.onBeforeStatusChange) { + throw ctx.error(403, { + message: + "Forbidden: changing comment status requires the onBeforeStatusChange hook", + }); + } + await runHookWithShim( + () => options.onBeforeStatusChange!(id, ctx.body.status, context), + ctx.error, + "Unauthorized: Cannot change comment status", + ); + + const updated = await updateCommentStatus( + adapter, + id, + ctx.body.status, + ); + if (!updated) { + throw ctx.error(404, { message: "Comment not found" }); + } + + if (ctx.body.status === "approved" && options?.onAfterApprove) { + await options.onAfterApprove(updated, context); + } + + return updated; + } catch (error) { + throw error; + } + }, + ); + + // DELETE /comments/:id (admin) + const deleteCommentEndpoint = createEndpoint( + "/comments/:id", + { + method: "DELETE", + }, + async (ctx) => { + const { id } = ctx.params; + const context: CommentsApiContext = { + params: ctx.params, + headers: ctx.headers, + }; + try { + // Require onBeforeDelete (403 when absent) — same + // secure-by-default pattern used for onBeforeEdit and + // onBeforeListByAuthor. Deletion is an admin operation; without + // this hook any unauthenticated caller could delete any comment. + if (!options?.onBeforeDelete) { + throw ctx.error(403, { + message: + "Forbidden: deleting comments requires the onBeforeDelete hook", + }); + } + await runHookWithShim( + () => options.onBeforeDelete!(id, context), + ctx.error, + "Unauthorized: Cannot delete comment", + ); + + const deleted = await deleteComment(adapter, id); + if (!deleted) { + throw ctx.error(404, { message: "Comment not found" }); + } + + if (options?.onAfterDelete) { + await options.onAfterDelete(id, context); + } + + return { success: true }; + } catch (error) { + throw error; + } + }, + ); + + return { + listComments: listCommentsEndpoint, + createComment: createCommentEndpoint, + updateComment: updateCommentEndpoint, + getCommentCount: getCommentCountEndpoint, + toggleLike: toggleLikeEndpoint, + updateCommentStatus: updateStatusEndpoint, + deleteComment: deleteCommentEndpoint, + } as const; + }, + }); +}; + +export type CommentsApiRouter = ReturnType< + ReturnType["routes"] +>; diff --git a/packages/stack/src/plugins/comments/api/query-key-defs.ts b/packages/stack/src/plugins/comments/api/query-key-defs.ts new file mode 100644 index 00000000..f1c4378e --- /dev/null +++ b/packages/stack/src/plugins/comments/api/query-key-defs.ts @@ -0,0 +1,143 @@ +/** + * Internal query key constants for the Comments plugin. + * Shared between query-keys.ts (HTTP path) and any SSG/direct DB path + * to prevent key drift between loaders and prefetch calls. + */ + +export interface CommentsListDiscriminator { + resourceId: string | undefined; + resourceType: string | undefined; + parentId: string | null | undefined; + status: string | undefined; + currentUserId: string | undefined; + authorId: string | undefined; + sort: string | undefined; + limit: number; + offset: number; +} + +/** + * Builds the discriminator object for the comments list query key. + */ +export function commentsListDiscriminator(params?: { + resourceId?: string; + resourceType?: string; + parentId?: string | null; + status?: string; + currentUserId?: string; + authorId?: string; + sort?: string; + limit?: number; + offset?: number; +}): CommentsListDiscriminator { + return { + resourceId: params?.resourceId, + resourceType: params?.resourceType, + parentId: params?.parentId, + status: params?.status, + currentUserId: params?.currentUserId, + authorId: params?.authorId, + sort: params?.sort, + limit: params?.limit ?? 20, + offset: params?.offset ?? 0, + }; +} + +export interface CommentCountDiscriminator { + resourceId: string; + resourceType: string; + status: string | undefined; +} + +export function commentCountDiscriminator(params: { + resourceId: string; + resourceType: string; + status?: string; +}): CommentCountDiscriminator { + return { + resourceId: params.resourceId, + resourceType: params.resourceType, + status: params.status, + }; +} + +/** + * Discriminator for the infinite thread query (top-level comments only). + * Intentionally excludes `offset` — pages are driven by `pageParam`, not the key. + */ +export interface CommentsThreadDiscriminator { + resourceId: string | undefined; + resourceType: string | undefined; + parentId: string | null | undefined; + status: string | undefined; + currentUserId: string | undefined; + limit: number; +} + +export function commentsThreadDiscriminator(params?: { + resourceId?: string; + resourceType?: string; + parentId?: string | null; + status?: string; + currentUserId?: string; + limit?: number; +}): CommentsThreadDiscriminator { + return { + resourceId: params?.resourceId, + resourceType: params?.resourceType, + parentId: params?.parentId, + status: params?.status, + currentUserId: params?.currentUserId, + limit: params?.limit ?? 20, + }; +} + +/** Full query key builders — use with queryClient.setQueryData() */ +export const COMMENTS_QUERY_KEYS = { + /** + * Key for comments list query. + * Full key: ["comments", "list", { resourceId, resourceType, parentId, status, currentUserId, limit, offset }] + */ + commentsList: (params?: { + resourceId?: string; + resourceType?: string; + parentId?: string | null; + status?: string; + currentUserId?: string; + authorId?: string; + sort?: string; + limit?: number; + offset?: number; + }) => ["comments", "list", commentsListDiscriminator(params)] as const, + + /** + * Key for a single comment detail query. + * Full key: ["comments", "detail", id] + */ + commentDetail: (id: string) => ["comments", "detail", id] as const, + + /** + * Key for comment count query. + * Full key: ["comments", "count", { resourceId, resourceType, status }] + */ + commentCount: (params: { + resourceId: string; + resourceType: string; + status?: string; + }) => ["comments", "count", commentCountDiscriminator(params)] as const, + + /** + * Key for the infinite thread query (top-level comments, load-more). + * Full key: ["commentsThread", "list", { resourceId, resourceType, parentId, status, currentUserId, limit }] + * Offset is excluded — it is driven by `pageParam`, not baked into the key. + */ + commentsThread: (params?: { + resourceId?: string; + resourceType?: string; + parentId?: string | null; + status?: string; + currentUserId?: string; + limit?: number; + }) => + ["commentsThread", "list", commentsThreadDiscriminator(params)] as const, +}; diff --git a/packages/stack/src/plugins/comments/api/serializers.ts b/packages/stack/src/plugins/comments/api/serializers.ts new file mode 100644 index 00000000..2e153793 --- /dev/null +++ b/packages/stack/src/plugins/comments/api/serializers.ts @@ -0,0 +1,37 @@ +import type { Comment, SerializedComment } from "../types"; + +/** + * Serialize a raw Comment DB record into a SerializedComment for SSG/setQueryData. + * Note: resolvedAuthorName, resolvedAvatarUrl, and isLikedByCurrentUser are not + * available from the DB record alone — use getters.ts enrichment for those. + * This serializer is for cases where you already have a SerializedComment from + * the HTTP layer and just need a type-safe round-trip. + * + * Pure function — no DB access, no hooks. + */ +export function serializeComment(comment: Comment): Omit< + SerializedComment, + "resolvedAuthorName" | "resolvedAvatarUrl" | "isLikedByCurrentUser" +> & { + resolvedAuthorName: string; + resolvedAvatarUrl: null; + isLikedByCurrentUser: false; +} { + return { + id: comment.id, + resourceId: comment.resourceId, + resourceType: comment.resourceType, + parentId: comment.parentId ?? null, + authorId: comment.authorId, + resolvedAuthorName: "[deleted]", + resolvedAvatarUrl: null, + isLikedByCurrentUser: false, + body: comment.body, + status: comment.status, + likes: comment.likes, + editedAt: comment.editedAt?.toISOString() ?? null, + createdAt: comment.createdAt.toISOString(), + updatedAt: comment.updatedAt.toISOString(), + replyCount: 0, + }; +} diff --git a/packages/stack/src/plugins/comments/client.css b/packages/stack/src/plugins/comments/client.css new file mode 100644 index 00000000..84e5c901 --- /dev/null +++ b/packages/stack/src/plugins/comments/client.css @@ -0,0 +1,2 @@ +/* Comments Plugin Client CSS */ +/* No custom styles needed - uses shadcn/ui components */ diff --git a/packages/stack/src/plugins/comments/client/components/comment-count.tsx b/packages/stack/src/plugins/comments/client/components/comment-count.tsx new file mode 100644 index 00000000..0af4f05a --- /dev/null +++ b/packages/stack/src/plugins/comments/client/components/comment-count.tsx @@ -0,0 +1,66 @@ +"use client"; + +import { MessageSquare } from "lucide-react"; +import { useCommentCount } from "../hooks/use-comments"; + +export interface CommentCountProps { + resourceId: string; + resourceType: string; + /** Only count approved comments (default) */ + status?: "pending" | "approved" | "spam"; + apiBaseURL: string; + apiBasePath: string; + headers?: HeadersInit; + /** Optional className for the wrapper span */ + className?: string; +} + +/** + * Lightweight badge showing the comment count for a resource. + * Does not mount a full comment thread — suitable for post list cards. + * + * @example + * ```tsx + * + * ``` + */ +export function CommentCount({ + resourceId, + resourceType, + status = "approved", + apiBaseURL, + apiBasePath, + headers, + className, +}: CommentCountProps) { + const { count, isLoading } = useCommentCount( + { apiBaseURL, apiBasePath, headers }, + { resourceId, resourceType, status }, + ); + + if (isLoading) { + return ( + + + … + + ); + } + + return ( + + + {count} + + ); +} diff --git a/packages/stack/src/plugins/comments/client/components/comment-form.tsx b/packages/stack/src/plugins/comments/client/components/comment-form.tsx new file mode 100644 index 00000000..f967e7f1 --- /dev/null +++ b/packages/stack/src/plugins/comments/client/components/comment-form.tsx @@ -0,0 +1,108 @@ +"use client"; + +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"; + +export interface CommentFormProps { + /** Current user's ID — required to post */ + authorId: string; + /** Optional parent comment ID for replies */ + parentId?: string | null; + /** Initial body value (for editing) */ + initialBody?: string; + /** Label for the submit button */ + submitLabel?: string; + /** Called when form is submitted */ + onSubmit: (body: string) => Promise; + /** Called when cancel is clicked (shows Cancel button when provided) */ + onCancel?: () => void; + /** Custom input component — defaults to a plain Textarea */ + InputComponent?: ComponentType<{ + value: string; + onChange: (value: string) => void; + disabled?: boolean; + placeholder?: string; + }>; + /** Localization strings */ + localization?: Partial; +} + +export function CommentForm({ + authorId: _authorId, + initialBody = "", + submitLabel, + onSubmit, + onCancel, + InputComponent, + localization: localizationProp, +}: CommentFormProps) { + const loc = { ...COMMENTS_LOCALIZATION, ...localizationProp }; + const [body, setBody] = useState(initialBody); + const [isPending, setIsPending] = useState(false); + const [error, setError] = useState(null); + + const resolvedSubmitLabel = submitLabel ?? loc.COMMENTS_FORM_POST_COMMENT; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + if (!body.trim()) return; + setError(null); + setIsPending(true); + try { + await onSubmit(body.trim()); + setBody(""); + } catch (err) { + setError( + err instanceof Error ? err.message : loc.COMMENTS_FORM_SUBMIT_ERROR, + ); + } finally { + setIsPending(false); + } + }; + + return ( + + {InputComponent ? ( + + ) : ( + setBody(e.target.value)} + placeholder={loc.COMMENTS_FORM_PLACEHOLDER} + disabled={isPending} + rows={3} + className="resize-none" + /> + )} + + {error && {error}} + + + {onCancel && ( + + {loc.COMMENTS_FORM_CANCEL} + + )} + + {isPending ? loc.COMMENTS_FORM_POSTING : resolvedSubmitLabel} + + + + ); +} diff --git a/packages/stack/src/plugins/comments/client/components/comment-thread.tsx b/packages/stack/src/plugins/comments/client/components/comment-thread.tsx new file mode 100644 index 00000000..4deded43 --- /dev/null +++ b/packages/stack/src/plugins/comments/client/components/comment-thread.tsx @@ -0,0 +1,681 @@ +"use client"; + +import { useState, type ComponentType } from "react"; +import { WhenVisible } from "@workspace/ui/components/when-visible"; +import { + Avatar, + AvatarFallback, + AvatarImage, +} from "@workspace/ui/components/avatar"; +import { Badge } from "@workspace/ui/components/badge"; +import { Button } from "@workspace/ui/components/button"; +import { Separator } from "@workspace/ui/components/separator"; +import { Heart, MessageSquare, Pencil, Check, X, LogIn } from "lucide-react"; +import { formatDistanceToNow } from "date-fns"; +import type { SerializedComment } from "../../types"; +import { CommentForm } from "./comment-form"; +import { + useComments, + useInfiniteComments, + usePostComment, + useUpdateComment, + useDeleteComment, + useToggleLike, +} from "../hooks/use-comments"; +import { + COMMENTS_LOCALIZATION, + type CommentsLocalization, +} from "../localization"; +import { usePluginOverrides } from "@btst/stack/context"; +import type { CommentsPluginOverrides } from "../overrides"; + +/** Custom input component props */ +export interface CommentInputProps { + value: string; + onChange: (value: string) => void; + disabled?: boolean; + placeholder?: string; +} + +/** Custom renderer component props */ +export interface CommentRendererProps { + body: string; +} + +/** Override slot for custom input + renderer */ +export interface CommentComponents { + Input?: ComponentType; + Renderer?: ComponentType; +} + +export interface CommentThreadProps { + /** The resource this thread is attached to (e.g. post slug, task ID) */ + resourceId: string; + /** Discriminates resources across plugins (e.g. "blog-post", "kanban-task") */ + resourceType: string; + /** Base URL for API calls */ + apiBaseURL: string; + /** Path where the API is mounted */ + apiBasePath: string; + /** Currently authenticated user ID. Omit for read-only / unauthenticated. */ + currentUserId?: string; + /** + * URL to redirect unauthenticated users to. + * When provided and currentUserId is absent, shows a "Please login to comment" prompt. + */ + loginHref?: string; + /** Optional HTTP headers for API calls (e.g. forwarding cookies) */ + headers?: HeadersInit; + /** Swap in custom Input / Renderer components */ + components?: CommentComponents; + /** Optional className applied to the root wrapper */ + className?: string; + /** Localization strings — defaults to English */ + localization?: Partial; + /** + * Number of top-level comments to load per page. + * Clicking "Load more" fetches the next page. Default: 10. + */ + pageSize?: number; +} + +const DEFAULT_RENDERER: ComponentType = ({ body }) => ( + {body} +); + +function getInitials(name: string | null | undefined) { + if (!name) return "?"; + return name + .split(" ") + .slice(0, 2) + .map((n) => n[0]) + .join("") + .toUpperCase(); +} + +// ─── Comment Card ───────────────────────────────────────────────────────────── + +function CommentCard({ + comment, + currentUserId, + apiBaseURL, + apiBasePath, + resourceId, + resourceType, + headers, + components, + loc, + infiniteKey, + onReplyClick, +}: { + comment: SerializedComment; + currentUserId?: string; + apiBaseURL: string; + apiBasePath: string; + resourceId: string; + resourceType: string; + headers?: HeadersInit; + components?: CommentComponents; + loc: CommentsLocalization; + /** Infinite thread query key — pass for top-level comments so like optimistic + * updates target the correct InfiniteData cache entry. */ + infiniteKey?: readonly unknown[]; + onReplyClick: (parentId: string) => void; +}) { + const [isEditing, setIsEditing] = useState(false); + const Renderer = components?.Renderer ?? DEFAULT_RENDERER; + + const config = { apiBaseURL, apiBasePath, headers }; + + const updateMutation = useUpdateComment(config); + const deleteMutation = useDeleteComment(config); + const toggleLikeMutation = useToggleLike(config, { + resourceId, + resourceType, + parentId: comment.parentId, + currentUserId, + infiniteKey, + }); + + const isOwn = currentUserId && comment.authorId === currentUserId; + const isPending = comment.status === "pending"; + const isApproved = comment.status === "approved"; + + const handleEdit = async (body: string) => { + await updateMutation.mutateAsync({ id: comment.id, body }); + setIsEditing(false); + }; + + const handleDelete = async () => { + if (!window.confirm(loc.COMMENTS_DELETE_CONFIRM)) return; + await deleteMutation.mutateAsync(comment.id); + }; + + const handleLike = () => { + if (!currentUserId) return; + toggleLikeMutation.mutate({ + commentId: comment.id, + authorId: currentUserId, + }); + }; + + return ( + + + {comment.resolvedAvatarUrl && ( + + )} + + {getInitials(comment.resolvedAuthorName)} + + + + + + + {comment.resolvedAuthorName} + + + {formatDistanceToNow(new Date(comment.createdAt), { + addSuffix: true, + })} + + {comment.editedAt && ( + + {loc.COMMENTS_EDITED_BADGE} + + )} + {isPending && isOwn && ( + + {loc.COMMENTS_PENDING_BADGE} + + )} + + + {isEditing ? ( + setIsEditing(false)} + /> + ) : ( + + )} + + {!isEditing && ( + + {currentUserId && isApproved && ( + + + {comment.likes > 0 && ( + {comment.likes} + )} + + )} + + {currentUserId && !comment.parentId && isApproved && ( + onReplyClick(comment.id)} + data-testid="reply-button" + > + + {loc.COMMENTS_REPLY_BUTTON} + + )} + + {isOwn && ( + <> + {isApproved && ( + setIsEditing(true)} + data-testid="edit-button" + > + + {loc.COMMENTS_EDIT_BUTTON} + + )} + + + {loc.COMMENTS_DELETE_BUTTON} + + > + )} + + )} + + + ); +} + +// ─── Thread Inner (handles data) ────────────────────────────────────────────── + +const DEFAULT_PAGE_SIZE = 100; + +function CommentThreadInner({ + resourceId, + resourceType, + apiBaseURL, + apiBasePath, + currentUserId, + loginHref, + headers, + components, + localization: localizationProp, + pageSize: pageSizeProp, +}: CommentThreadProps) { + const overrides = usePluginOverrides< + CommentsPluginOverrides, + Partial + >("comments", {}); + const pageSize = + pageSizeProp ?? overrides.defaultCommentPageSize ?? DEFAULT_PAGE_SIZE; + const loc = { ...COMMENTS_LOCALIZATION, ...localizationProp }; + const [replyingTo, setReplyingTo] = useState(null); + const [expandedReplies, setExpandedReplies] = useState>( + new Set(), + ); + + const config = { apiBaseURL, apiBasePath, headers }; + + const { + comments, + total, + isLoading, + loadMore, + hasMore, + isLoadingMore, + queryKey: threadQueryKey, + } = useInfiniteComments(config, { + resourceId, + resourceType, + status: "approved", + parentId: null, + currentUserId, + pageSize, + }); + + const postMutation = usePostComment(config, { + resourceId, + resourceType, + currentUserId, + infiniteKey: threadQueryKey, + }); + + const handlePost = async (body: string) => { + if (!currentUserId) return; + await postMutation.mutateAsync({ + body, + parentId: null, + }); + }; + + const handleReply = async (body: string, parentId: string) => { + if (!currentUserId) return; + await postMutation.mutateAsync({ + body, + parentId, + }); + setReplyingTo(null); + setExpandedReplies((prev) => new Set(prev).add(parentId)); + }; + + return ( + + + + + {total === 0 ? loc.COMMENTS_TITLE : `${total} ${loc.COMMENTS_TITLE}`} + + + + {isLoading && ( + + {[1, 2].map((i) => ( + + + + + + + + + ))} + + )} + + {!isLoading && comments.length > 0 && ( + + {comments.map((comment) => ( + + { + setReplyingTo(replyingTo === parentId ? null : parentId); + }} + /> + + {/* Replies */} + { + setExpandedReplies((prev) => { + const next = new Set(prev); + next.has(comment.id) + ? next.delete(comment.id) + : next.add(comment.id); + return next; + }); + }} + /> + + {replyingTo === comment.id && currentUserId && ( + + handleReply(body, comment.id)} + onCancel={() => setReplyingTo(null)} + /> + + )} + + ))} + + )} + + {!isLoading && comments.length === 0 && ( + + {loc.COMMENTS_EMPTY} + + )} + + {hasMore && ( + + loadMore()} + disabled={isLoadingMore} + data-testid="load-more-comments" + > + {isLoadingMore ? loc.COMMENTS_LOADING_MORE : loc.COMMENTS_LOAD_MORE} + + + )} + + + + {currentUserId ? ( + + + + ) : ( + + + + {loc.COMMENTS_LOGIN_PROMPT} + + {loginHref && ( + + {loc.COMMENTS_LOGIN_LINK} + + )} + + )} + + ); +} + +// ─── Replies Section ─────────────────────────────────────────────────────────── + +function RepliesSection({ + parentId, + resourceId, + resourceType, + apiBaseURL, + apiBasePath, + currentUserId, + headers, + components, + loc, + expanded, + replyCount, + onToggle, +}: { + parentId: string; + resourceId: string; + resourceType: string; + apiBaseURL: string; + apiBasePath: string; + currentUserId?: string; + headers?: HeadersInit; + components?: CommentComponents; + loc: CommentsLocalization; + expanded: boolean; + /** Pre-computed from the parent comment — avoids an extra fetch on mount. */ + replyCount: number; + onToggle: () => void; +}) { + const config = { apiBaseURL, apiBasePath, headers }; + // Only fetch reply bodies once the section is expanded. + const { comments: replies } = useComments( + config, + { + resourceId, + resourceType, + parentId, + status: "approved", + currentUserId, + }, + { enabled: expanded }, + ); + + // Hide when there are no known replies — but keep rendered when already + // expanded so a freshly-posted first reply (which increments replyCount + // only after the server responds) stays visible in the same session. + if (replyCount === 0 && !expanded) return null; + + // Prefer the fetched count (accurate after optimistic inserts); fall back to + // the server-provided replyCount before the fetch completes. + const displayCount = expanded ? replies.length || replyCount : replyCount; + + return ( + + {!expanded && ( + + + {displayCount}{" "} + {displayCount === 1 + ? loc.COMMENTS_REPLIES_SINGULAR + : loc.COMMENTS_REPLIES_PLURAL} + + )} + {expanded && ( + + {replies.map((reply) => ( + {}} // No nested replies in v1 + /> + ))} + + {loc.COMMENTS_HIDE_REPLIES} + + + )} + + ); +} + +// ─── Public export: lazy-mounts on scroll into view ─────────────────────────── + +/** + * Embeddable threaded comment section. + * + * Lazy-mounts when the component scrolls into the viewport (via WhenVisible). + * Requires `currentUserId` to allow posting; shows a "Please login" prompt otherwise. + * + * @example + * ```tsx + * + * ``` + */ +function CommentThreadSkeleton() { + return ( + + {/* Header */} + + + + + + {/* Comment rows */} + {[1, 2, 3].map((i) => ( + + + + + + + + + + + + + + + + ))} + + {/* Separator */} + + + {/* Textarea placeholder */} + + + + + + + + ); +} + +export function CommentThread(props: CommentThreadProps) { + return ( + } + rootMargin="300px" + className={props.className} + > + + + ); +} diff --git a/packages/stack/src/plugins/comments/client/components/index.tsx b/packages/stack/src/plugins/comments/client/components/index.tsx new file mode 100644 index 00000000..f6b7f645 --- /dev/null +++ b/packages/stack/src/plugins/comments/client/components/index.tsx @@ -0,0 +1,11 @@ +export { + CommentThread, + type CommentThreadProps, + type CommentComponents, + type CommentInputProps, + type CommentRendererProps, +} from "./comment-thread"; +export { CommentCount, type CommentCountProps } from "./comment-count"; +export { CommentForm, type CommentFormProps } from "./comment-form"; +export { ModerationPageComponent } from "./pages/moderation-page"; +export { ResourceCommentsPageComponent } from "./pages/resource-comments-page"; diff --git a/packages/stack/src/plugins/comments/client/components/pages/moderation-page.internal.tsx b/packages/stack/src/plugins/comments/client/components/pages/moderation-page.internal.tsx new file mode 100644 index 00000000..c1167c76 --- /dev/null +++ b/packages/stack/src/plugins/comments/client/components/pages/moderation-page.internal.tsx @@ -0,0 +1,544 @@ +"use client"; + +import { useState } from "react"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@workspace/ui/components/table"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, +} from "@workspace/ui/components/dialog"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@workspace/ui/components/alert-dialog"; +import { Button } from "@workspace/ui/components/button"; +import { Badge } from "@workspace/ui/components/badge"; +import { Tabs, TabsList, TabsTrigger } from "@workspace/ui/components/tabs"; +import { Checkbox } from "@workspace/ui/components/checkbox"; +import { + Avatar, + AvatarFallback, + AvatarImage, +} from "@workspace/ui/components/avatar"; +import { CheckCircle, ShieldOff, Trash2, Eye } from "lucide-react"; +import { toast } from "sonner"; +import { formatDistanceToNow } from "date-fns"; +import { useRegisterPageAIContext } from "@btst/stack/plugins/ai-chat/client/context"; +import type { SerializedComment, CommentStatus } from "../../../types"; +import { + useSuspenseComments, + useUpdateCommentStatus, + useDeleteComment, +} from "../../hooks/use-comments"; +import { + COMMENTS_LOCALIZATION, + type CommentsLocalization, +} from "../../localization"; + +interface ModerationPageProps { + apiBaseURL: string; + apiBasePath: string; + headers?: HeadersInit; + localization?: CommentsLocalization; +} + +function getInitials(name: string | null | undefined) { + if (!name) return "?"; + return name + .split(" ") + .slice(0, 2) + .map((n) => n[0]) + .join("") + .toUpperCase(); +} + +function StatusBadge({ status }: { status: CommentStatus }) { + const variants: Record< + CommentStatus, + "secondary" | "default" | "destructive" + > = { + pending: "secondary", + approved: "default", + spam: "destructive", + }; + return {status}; +} + +export function ModerationPage({ + apiBaseURL, + apiBasePath, + headers, + localization: localizationProp, +}: ModerationPageProps) { + const loc = { ...COMMENTS_LOCALIZATION, ...localizationProp }; + const [activeTab, setActiveTab] = useState("pending"); + const [selected, setSelected] = useState>(new Set()); + const [viewComment, setViewComment] = useState( + null, + ); + const [deleteIds, setDeleteIds] = useState([]); + + const config = { apiBaseURL, apiBasePath, headers }; + + const { comments, total, refetch } = useSuspenseComments(config, { + status: activeTab, + }); + + const updateStatus = useUpdateCommentStatus(config); + const deleteMutation = useDeleteComment(config); + + // Register AI context with pending comment previews + useRegisterPageAIContext({ + routeName: "comments-moderation", + pageDescription: `${total} ${activeTab} comments in the moderation queue.\n\nTop ${activeTab} comments:\n${comments + .slice(0, 5) + .map( + (c) => + `- "${c.body.slice(0, 80)}${c.body.length > 80 ? "…" : ""}" by ${c.resolvedAuthorName} on ${c.resourceType}/${c.resourceId}`, + ) + .join("\n")}`, + suggestions: [ + "Approve all safe-looking comments", + "Flag spam comments", + "Summarize today's discussion", + ], + }); + + const toggleSelect = (id: string) => { + setSelected((prev) => { + const next = new Set(prev); + next.has(id) ? next.delete(id) : next.add(id); + return next; + }); + }; + + const toggleSelectAll = () => { + if (selected.size === comments.length) { + setSelected(new Set()); + } else { + setSelected(new Set(comments.map((c) => c.id))); + } + }; + + const handleApprove = async (id: string) => { + try { + await updateStatus.mutateAsync({ id, status: "approved" }); + toast.success(loc.COMMENTS_MODERATION_TOAST_APPROVED); + await refetch(); + } catch { + toast.error(loc.COMMENTS_MODERATION_TOAST_APPROVE_ERROR); + } + }; + + const handleSpam = async (id: string) => { + try { + await updateStatus.mutateAsync({ id, status: "spam" }); + toast.success(loc.COMMENTS_MODERATION_TOAST_SPAM); + await refetch(); + } catch { + toast.error(loc.COMMENTS_MODERATION_TOAST_SPAM_ERROR); + } + }; + + const handleDelete = async (ids: string[]) => { + try { + await Promise.all(ids.map((id) => deleteMutation.mutateAsync(id))); + toast.success( + ids.length === 1 + ? loc.COMMENTS_MODERATION_TOAST_DELETED + : loc.COMMENTS_MODERATION_TOAST_DELETED_PLURAL.replace( + "{n}", + String(ids.length), + ), + ); + setSelected(new Set()); + setDeleteIds([]); + await refetch(); + } catch { + toast.error(loc.COMMENTS_MODERATION_TOAST_DELETE_ERROR); + } + }; + + const handleBulkApprove = async () => { + const ids = [...selected]; + try { + await Promise.all( + ids.map((id) => updateStatus.mutateAsync({ id, status: "approved" })), + ); + toast.success( + loc.COMMENTS_MODERATION_TOAST_BULK_APPROVED.replace( + "{n}", + String(ids.length), + ), + ); + setSelected(new Set()); + await refetch(); + } catch { + toast.error(loc.COMMENTS_MODERATION_TOAST_BULK_APPROVE_ERROR); + } + }; + + return ( + + + {loc.COMMENTS_MODERATION_TITLE} + + {loc.COMMENTS_MODERATION_DESCRIPTION} + + + + { + setActiveTab(v as CommentStatus); + setSelected(new Set()); + }} + > + + + {loc.COMMENTS_MODERATION_TAB_PENDING} + + + {loc.COMMENTS_MODERATION_TAB_APPROVED} + + + {loc.COMMENTS_MODERATION_TAB_SPAM} + + + + + {/* Bulk actions toolbar */} + {selected.size > 0 && ( + + + {loc.COMMENTS_MODERATION_SELECTED.replace( + "{n}", + String(selected.size), + )} + + {activeTab !== "approved" && ( + + + {loc.COMMENTS_MODERATION_APPROVE_SELECTED} + + )} + setDeleteIds([...selected])} + > + + {loc.COMMENTS_MODERATION_DELETE_SELECTED} + + + )} + + {comments.length === 0 ? ( + + + + {loc.COMMENTS_MODERATION_EMPTY.replace("{status}", activeTab)} + + + ) : ( + + + + + + 0 + } + onCheckedChange={toggleSelectAll} + aria-label={loc.COMMENTS_MODERATION_SELECT_ALL} + /> + + {loc.COMMENTS_MODERATION_COL_AUTHOR} + {loc.COMMENTS_MODERATION_COL_COMMENT} + {loc.COMMENTS_MODERATION_COL_RESOURCE} + {loc.COMMENTS_MODERATION_COL_DATE} + + {loc.COMMENTS_MODERATION_COL_ACTIONS} + + + + + {comments.map((comment) => ( + + + toggleSelect(comment.id)} + aria-label={loc.COMMENTS_MODERATION_SELECT_ONE} + /> + + + + + {comment.resolvedAvatarUrl && ( + + )} + + {getInitials(comment.resolvedAuthorName)} + + + + {comment.resolvedAuthorName} + + + + + + {comment.body} + + + + + {comment.resourceType}/{comment.resourceId} + + + + {formatDistanceToNow(new Date(comment.createdAt), { + addSuffix: true, + })} + + + + setViewComment(comment)} + data-testid="view-button" + > + + + {activeTab !== "approved" && ( + handleApprove(comment.id)} + disabled={updateStatus.isPending} + data-testid="approve-button" + > + + + )} + {activeTab !== "spam" && ( + handleSpam(comment.id)} + disabled={updateStatus.isPending} + data-testid="spam-button" + > + + + )} + setDeleteIds([comment.id])} + data-testid="delete-button" + > + + + + + + ))} + + + + )} + + {/* View comment dialog */} + setViewComment(null)}> + + + {loc.COMMENTS_MODERATION_DIALOG_TITLE} + + {viewComment && ( + + + + {viewComment.resolvedAvatarUrl && ( + + )} + + {getInitials(viewComment.resolvedAuthorName)} + + + + + {viewComment.resolvedAuthorName} + + + {new Date(viewComment.createdAt).toLocaleString()} + + + + + + + + + {loc.COMMENTS_MODERATION_DIALOG_RESOURCE} + + + {viewComment.resourceType}/{viewComment.resourceId} + + + + + {loc.COMMENTS_MODERATION_DIALOG_LIKES} + + {viewComment.likes} + + {viewComment.parentId && ( + + + {loc.COMMENTS_MODERATION_DIALOG_REPLY_TO} + + {viewComment.parentId} + + )} + {viewComment.editedAt && ( + + + {loc.COMMENTS_MODERATION_DIALOG_EDITED} + + + {new Date(viewComment.editedAt).toLocaleString()} + + + )} + + + + + {loc.COMMENTS_MODERATION_DIALOG_BODY} + + + {viewComment.body} + + + + + {viewComment.status !== "approved" && ( + { + await handleApprove(viewComment.id); + setViewComment(null); + }} + disabled={updateStatus.isPending} + data-testid="dialog-approve-button" + > + + {loc.COMMENTS_MODERATION_DIALOG_APPROVE} + + )} + {viewComment.status !== "spam" && ( + { + await handleSpam(viewComment.id); + setViewComment(null); + }} + disabled={updateStatus.isPending} + > + + {loc.COMMENTS_MODERATION_DIALOG_MARK_SPAM} + + )} + { + setDeleteIds([viewComment.id]); + setViewComment(null); + }} + > + + {loc.COMMENTS_MODERATION_DIALOG_DELETE} + + + + )} + + + + {/* Delete confirmation dialog */} + 0} + onOpenChange={(open) => !open && setDeleteIds([])} + > + + + + {deleteIds.length === 1 + ? loc.COMMENTS_MODERATION_DELETE_TITLE_SINGULAR + : loc.COMMENTS_MODERATION_DELETE_TITLE_PLURAL.replace( + "{n}", + String(deleteIds.length), + )} + + + {deleteIds.length === 1 + ? loc.COMMENTS_MODERATION_DELETE_DESCRIPTION_SINGULAR + : loc.COMMENTS_MODERATION_DELETE_DESCRIPTION_PLURAL} + + + + + {loc.COMMENTS_MODERATION_DELETE_CANCEL} + + handleDelete(deleteIds)} + data-testid="confirm-delete-button" + > + {deleteMutation.isPending + ? loc.COMMENTS_MODERATION_DELETE_DELETING + : loc.COMMENTS_MODERATION_DELETE_CONFIRM} + + + + + + ); +} diff --git a/packages/stack/src/plugins/comments/client/components/pages/moderation-page.tsx b/packages/stack/src/plugins/comments/client/components/pages/moderation-page.tsx new file mode 100644 index 00000000..2b20eacf --- /dev/null +++ b/packages/stack/src/plugins/comments/client/components/pages/moderation-page.tsx @@ -0,0 +1,70 @@ +"use client"; + +import { lazy } from "react"; +import { ComposedRoute } from "@btst/stack/client/components"; +import { usePluginOverrides } from "@btst/stack/context"; +import type { CommentsPluginOverrides } from "../../overrides"; +import { COMMENTS_LOCALIZATION } from "../../localization"; +import { useRouteLifecycle } from "@workspace/ui/hooks/use-route-lifecycle"; +import { PageWrapper } from "../shared/page-wrapper"; + +const ModerationPageInternal = lazy(() => + import("./moderation-page.internal").then((m) => ({ + default: m.ModerationPage, + })), +); + +function ModerationPageSkeleton() { + return ( + + + + + + + ); +} + +export function ModerationPageComponent() { + return ( + + console.error("[btst/comments] Moderation error:", error) + } + /> + ); +} + +function ModerationPageWrapper() { + const overrides = usePluginOverrides("comments"); + const loc = { ...COMMENTS_LOCALIZATION, ...overrides.localization }; + + useRouteLifecycle({ + routeName: "moderation", + context: { + path: "/comments/moderation", + isSSR: typeof window === "undefined", + }, + overrides, + beforeRenderHook: (o, context) => { + if (o.onBeforeModerationPageRendered) { + return o.onBeforeModerationPageRendered(context); + } + return true; + }, + }); + + return ( + + + + ); +} diff --git a/packages/stack/src/plugins/comments/client/components/pages/my-comments-page.internal.tsx b/packages/stack/src/plugins/comments/client/components/pages/my-comments-page.internal.tsx new file mode 100644 index 00000000..607d2d6e --- /dev/null +++ b/packages/stack/src/plugins/comments/client/components/pages/my-comments-page.internal.tsx @@ -0,0 +1,395 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@workspace/ui/components/table"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@workspace/ui/components/alert-dialog"; +import { Button } from "@workspace/ui/components/button"; +import { Badge } from "@workspace/ui/components/badge"; +import { + Avatar, + AvatarFallback, + AvatarImage, +} from "@workspace/ui/components/avatar"; +import { Trash2, ExternalLink, LogIn, MessageSquareOff } from "lucide-react"; +import { toast } from "sonner"; +import { formatDistanceToNow } from "date-fns"; +import type { CommentsPluginOverrides } from "../../overrides"; +import { PaginationControls } from "@workspace/ui/components/pagination-controls"; +import type { SerializedComment, CommentStatus } from "../../../types"; +import { + useSuspenseComments, + useDeleteComment, +} from "../../hooks/use-comments"; +import { + COMMENTS_LOCALIZATION, + type CommentsLocalization, +} from "../../localization"; + +const PAGE_LIMIT = 20; + +interface MyCommentsPageProps { + apiBaseURL: string; + apiBasePath: string; + headers?: HeadersInit; + currentUserId?: CommentsPluginOverrides["currentUserId"]; + resourceLinks?: CommentsPluginOverrides["resourceLinks"]; + localization?: CommentsLocalization; +} + +function getInitials(name: string | null | undefined) { + if (!name) return "?"; + return name + .split(" ") + .slice(0, 2) + .map((n) => n[0]) + .join("") + .toUpperCase(); +} + +function StatusBadge({ + status, + loc, +}: { + status: CommentStatus; + loc: CommentsLocalization; +}) { + if (status === "approved") { + return ( + + {loc.COMMENTS_MY_STATUS_APPROVED} + + ); + } + if (status === "pending") { + return ( + + {loc.COMMENTS_MY_STATUS_PENDING} + + ); + } + return ( + + {loc.COMMENTS_MY_STATUS_SPAM} + + ); +} + +// ─── Resolved currentUserId hook ───────────────────────────────────────────── + +function useResolvedCurrentUserId( + raw: CommentsPluginOverrides["currentUserId"], +): string | undefined { + const [resolved, setResolved] = useState( + typeof raw === "string" ? raw : undefined, + ); + + useEffect(() => { + if (typeof raw === "function") { + void Promise.resolve(raw()).then((id) => { + setResolved(id ?? undefined); + }); + } else { + setResolved(raw ?? undefined); + } + }, [raw]); + + return resolved; +} + +// ─── Main export ────────────────────────────────────────────────────────────── + +export function MyCommentsPage({ + apiBaseURL, + apiBasePath, + headers, + currentUserId: currentUserIdProp, + resourceLinks, + localization: localizationProp, +}: MyCommentsPageProps) { + const loc = { ...COMMENTS_LOCALIZATION, ...localizationProp }; + const resolvedUserId = useResolvedCurrentUserId(currentUserIdProp); + + if (!resolvedUserId) { + return ( + + + {loc.COMMENTS_MY_LOGIN_TITLE} + + {loc.COMMENTS_MY_LOGIN_DESCRIPTION} + + + ); + } + + return ( + + ); +} + +// ─── List (suspense boundary is in ComposedRoute) ───────────────────────────── + +function MyCommentsList({ + apiBaseURL, + apiBasePath, + headers, + currentUserId, + resourceLinks, + loc, +}: { + apiBaseURL: string; + apiBasePath: string; + headers?: HeadersInit; + currentUserId: string; + resourceLinks?: CommentsPluginOverrides["resourceLinks"]; + loc: CommentsLocalization; +}) { + const [page, setPage] = useState(1); + const [deleteId, setDeleteId] = useState(null); + + const config = { apiBaseURL, apiBasePath, headers }; + const offset = (page - 1) * PAGE_LIMIT; + + const { comments, total, refetch } = useSuspenseComments(config, { + authorId: currentUserId, + sort: "desc", + limit: PAGE_LIMIT, + offset, + }); + + const deleteMutation = useDeleteComment(config); + + const totalPages = Math.max(1, Math.ceil(total / PAGE_LIMIT)); + + const handleDelete = async () => { + if (!deleteId) return; + try { + await deleteMutation.mutateAsync(deleteId); + toast.success(loc.COMMENTS_MY_TOAST_DELETED); + refetch(); + } catch { + toast.error(loc.COMMENTS_MY_TOAST_DELETE_ERROR); + } finally { + setDeleteId(null); + } + }; + + if (comments.length === 0 && page === 1) { + return ( + + + {loc.COMMENTS_MY_EMPTY_TITLE} + + {loc.COMMENTS_MY_EMPTY_DESCRIPTION} + + + ); + } + + return ( + + + + {loc.COMMENTS_MY_PAGE_TITLE} + + + {total} {loc.COMMENTS_MY_COL_COMMENT.toLowerCase()} + {total !== 1 ? "s" : ""} + + + + + + + + + {loc.COMMENTS_MY_COL_COMMENT} + + {loc.COMMENTS_MY_COL_RESOURCE} + + + {loc.COMMENTS_MY_COL_STATUS} + + + {loc.COMMENTS_MY_COL_DATE} + + + + + + {comments.map((comment) => ( + setDeleteId(comment.id)} + isDeleting={deleteMutation.isPending && deleteId === comment.id} + /> + ))} + + + + { + setPage(p); + window.scrollTo({ top: 0, behavior: "smooth" }); + }} + /> + + + !open && setDeleteId(null)} + > + + + {loc.COMMENTS_MY_DELETE_TITLE} + + {loc.COMMENTS_MY_DELETE_DESCRIPTION} + + + + + {loc.COMMENTS_MY_DELETE_CANCEL} + + + {loc.COMMENTS_MY_DELETE_CONFIRM} + + + + + + ); +} + +// ─── Row ────────────────────────────────────────────────────────────────────── + +function CommentRow({ + comment, + resourceLinks, + loc, + onDelete, + isDeleting, +}: { + comment: SerializedComment; + resourceLinks?: CommentsPluginOverrides["resourceLinks"]; + loc: CommentsLocalization; + onDelete: () => void; + isDeleting: boolean; +}) { + const resourceUrl = resourceLinks?.[comment.resourceType]?.( + comment.resourceId, + ); + + return ( + + + + {comment.resolvedAvatarUrl && ( + + )} + + {getInitials(comment.resolvedAuthorName)} + + + + + + {comment.body} + {comment.parentId && ( + + {loc.COMMENTS_MY_REPLY_INDICATOR} + + )} + + + + + + {comment.resourceType.replace(/-/g, " ")} + + {resourceUrl ? ( + + {loc.COMMENTS_MY_VIEW_LINK} + + + ) : ( + + {comment.resourceId} + + )} + + + + + + + + + {formatDistanceToNow(new Date(comment.createdAt), { addSuffix: true })} + + + + + + {loc.COMMENTS_MY_DELETE_BUTTON_SR} + + + + ); +} diff --git a/packages/stack/src/plugins/comments/client/components/pages/my-comments-page.tsx b/packages/stack/src/plugins/comments/client/components/pages/my-comments-page.tsx new file mode 100644 index 00000000..0e73baee --- /dev/null +++ b/packages/stack/src/plugins/comments/client/components/pages/my-comments-page.tsx @@ -0,0 +1,72 @@ +"use client"; + +import { lazy } from "react"; +import { ComposedRoute } from "@btst/stack/client/components"; +import { usePluginOverrides } from "@btst/stack/context"; +import type { CommentsPluginOverrides } from "../../overrides"; +import { COMMENTS_LOCALIZATION } from "../../localization"; +import { useRouteLifecycle } from "@workspace/ui/hooks/use-route-lifecycle"; +import { PageWrapper } from "../shared/page-wrapper"; + +const MyCommentsPageInternal = lazy(() => + import("./my-comments-page.internal").then((m) => ({ + default: m.MyCommentsPage, + })), +); + +function MyCommentsPageSkeleton() { + return ( + + + + + + ); +} + +export function MyCommentsPageComponent() { + return ( + + console.error("[btst/comments] My Comments error:", error) + } + /> + ); +} + +function MyCommentsPageWrapper() { + const overrides = usePluginOverrides("comments"); + const loc = { ...COMMENTS_LOCALIZATION, ...overrides.localization }; + + useRouteLifecycle({ + routeName: "myComments", + context: { + path: "/comments/my-comments", + isSSR: typeof window === "undefined", + }, + overrides, + beforeRenderHook: (o, context) => { + if (o.onBeforeMyCommentsPageRendered) { + const result = o.onBeforeMyCommentsPageRendered(context); + return result === false ? false : true; + } + return true; + }, + }); + + return ( + + + + ); +} diff --git a/packages/stack/src/plugins/comments/client/components/pages/resource-comments-page.internal.tsx b/packages/stack/src/plugins/comments/client/components/pages/resource-comments-page.internal.tsx new file mode 100644 index 00000000..36a6d000 --- /dev/null +++ b/packages/stack/src/plugins/comments/client/components/pages/resource-comments-page.internal.tsx @@ -0,0 +1,228 @@ +"use client"; + +import type { SerializedComment } from "../../../types"; +import { + useSuspenseComments, + useUpdateCommentStatus, + useDeleteComment, +} from "../../hooks/use-comments"; +import { CommentThread } from "../comment-thread"; +import { Button } from "@workspace/ui/components/button"; +import { Badge } from "@workspace/ui/components/badge"; +import { + Avatar, + AvatarFallback, + AvatarImage, +} from "@workspace/ui/components/avatar"; +import { CheckCircle, ShieldOff, Trash2 } from "lucide-react"; +import { formatDistanceToNow } from "date-fns"; +import { toast } from "sonner"; +import { + COMMENTS_LOCALIZATION, + type CommentsLocalization, +} from "../../localization"; + +interface ResourceCommentsPageProps { + resourceId: string; + resourceType: string; + apiBaseURL: string; + apiBasePath: string; + headers?: HeadersInit; + localization?: CommentsLocalization; +} + +function getInitials(name: string | null | undefined) { + if (!name) return "?"; + return name + .split(" ") + .slice(0, 2) + .map((n) => n[0]) + .join("") + .toUpperCase(); +} + +export function ResourceCommentsPage({ + resourceId, + resourceType, + apiBaseURL, + apiBasePath, + headers, + localization: localizationProp, +}: ResourceCommentsPageProps) { + const loc = { ...COMMENTS_LOCALIZATION, ...localizationProp }; + const config = { apiBaseURL, apiBasePath, headers }; + + const { + comments: pendingComments, + total: pendingTotal, + refetch, + } = useSuspenseComments(config, { + resourceId, + resourceType, + status: "pending", + }); + + const updateStatus = useUpdateCommentStatus(config); + const deleteMutation = useDeleteComment(config); + + const handleApprove = async (id: string) => { + try { + await updateStatus.mutateAsync({ id, status: "approved" }); + toast.success(loc.COMMENTS_RESOURCE_TOAST_APPROVED); + refetch(); + } catch { + toast.error(loc.COMMENTS_RESOURCE_TOAST_APPROVE_ERROR); + } + }; + + const handleSpam = async (id: string) => { + try { + await updateStatus.mutateAsync({ id, status: "spam" }); + toast.success(loc.COMMENTS_RESOURCE_TOAST_SPAM); + refetch(); + } catch { + toast.error(loc.COMMENTS_RESOURCE_TOAST_SPAM_ERROR); + } + }; + + const handleDelete = async (id: string) => { + if (!window.confirm(loc.COMMENTS_RESOURCE_DELETE_CONFIRM)) return; + try { + await deleteMutation.mutateAsync(id); + toast.success(loc.COMMENTS_RESOURCE_TOAST_DELETED); + refetch(); + } catch { + toast.error(loc.COMMENTS_RESOURCE_TOAST_DELETE_ERROR); + } + }; + + return ( + + + {loc.COMMENTS_RESOURCE_TITLE} + + {resourceType}/{resourceId} + + + + {pendingTotal > 0 && ( + + + {loc.COMMENTS_RESOURCE_PENDING_SECTION} + {pendingTotal} + + + {pendingComments.map((comment) => ( + handleApprove(comment.id)} + onSpam={() => handleSpam(comment.id)} + onDelete={() => handleDelete(comment.id)} + isUpdating={updateStatus.isPending} + isDeleting={deleteMutation.isPending} + /> + ))} + + + )} + + + + {loc.COMMENTS_RESOURCE_THREAD_SECTION} + + + + + ); +} + +function PendingCommentRow({ + comment, + loc, + onApprove, + onSpam, + onDelete, + isUpdating, + isDeleting, +}: { + comment: SerializedComment; + loc: CommentsLocalization; + onApprove: () => void; + onSpam: () => void; + onDelete: () => void; + isUpdating: boolean; + isDeleting: boolean; +}) { + return ( + + + {comment.resolvedAvatarUrl && ( + + )} + + {getInitials(comment.resolvedAuthorName)} + + + + + + {comment.resolvedAuthorName} + + + {formatDistanceToNow(new Date(comment.createdAt), { + addSuffix: true, + })} + + + + {comment.body} + + + + + {loc.COMMENTS_RESOURCE_ACTION_APPROVE} + + + + {loc.COMMENTS_RESOURCE_ACTION_SPAM} + + + + {loc.COMMENTS_RESOURCE_ACTION_DELETE} + + + + + ); +} diff --git a/packages/stack/src/plugins/comments/client/components/pages/resource-comments-page.tsx b/packages/stack/src/plugins/comments/client/components/pages/resource-comments-page.tsx new file mode 100644 index 00000000..aa1a7929 --- /dev/null +++ b/packages/stack/src/plugins/comments/client/components/pages/resource-comments-page.tsx @@ -0,0 +1,93 @@ +"use client"; + +import { lazy } from "react"; +import { ComposedRoute } from "@btst/stack/client/components"; +import { usePluginOverrides } from "@btst/stack/context"; +import type { CommentsPluginOverrides } from "../../overrides"; +import { COMMENTS_LOCALIZATION } from "../../localization"; +import { useRouteLifecycle } from "@workspace/ui/hooks/use-route-lifecycle"; +import { PageWrapper } from "../shared/page-wrapper"; + +const ResourceCommentsPageInternal = lazy(() => + import("./resource-comments-page.internal").then((m) => ({ + default: m.ResourceCommentsPage, + })), +); + +function ResourceCommentsSkeleton() { + return ( + + + + + + ); +} + +export function ResourceCommentsPageComponent({ + resourceId, + resourceType, +}: { + resourceId: string; + resourceType: string; +}) { + return ( + ( + + )} + LoadingComponent={ResourceCommentsSkeleton} + onError={(error) => + console.error("[btst/comments] Resource comments error:", error) + } + /> + ); +} + +function ResourceCommentsPageWrapper({ + resourceId, + resourceType, +}: { + resourceId: string; + resourceType: string; +}) { + const overrides = usePluginOverrides("comments"); + const loc = { ...COMMENTS_LOCALIZATION, ...overrides.localization }; + + useRouteLifecycle({ + routeName: "resourceComments", + context: { + path: `/comments/${resourceType}/${resourceId}`, + params: { resourceId, resourceType }, + isSSR: typeof window === "undefined", + }, + overrides, + beforeRenderHook: (o, context) => { + if (o.onBeforeResourceCommentsRendered) { + return o.onBeforeResourceCommentsRendered( + resourceType, + resourceId, + context, + ); + } + return true; + }, + }); + + return ( + + + + ); +} diff --git a/packages/stack/src/plugins/comments/client/components/shared/page-wrapper.tsx b/packages/stack/src/plugins/comments/client/components/shared/page-wrapper.tsx new file mode 100644 index 00000000..d734cd43 --- /dev/null +++ b/packages/stack/src/plugins/comments/client/components/shared/page-wrapper.tsx @@ -0,0 +1,32 @@ +"use client"; + +import { usePluginOverrides } from "@btst/stack/context"; +import { PageWrapper as SharedPageWrapper } from "@workspace/ui/components/page-wrapper"; +import type { CommentsPluginOverrides } from "../../overrides"; + +export function PageWrapper({ + children, + className, + testId, +}: { + children: React.ReactNode; + className?: string; + testId?: string; +}) { + const { showAttribution } = usePluginOverrides< + CommentsPluginOverrides, + Partial + >("comments", { + showAttribution: true, + }); + + return ( + + {children} + + ); +} diff --git a/packages/stack/src/plugins/comments/client/hooks/index.tsx b/packages/stack/src/plugins/comments/client/hooks/index.tsx new file mode 100644 index 00000000..8fa37820 --- /dev/null +++ b/packages/stack/src/plugins/comments/client/hooks/index.tsx @@ -0,0 +1,12 @@ +export { + useComments, + useSuspenseComments, + useInfiniteComments, + useCommentCount, + usePostComment, + useUpdateComment, + useApproveComment, + useUpdateCommentStatus, + useDeleteComment, + useToggleLike, +} from "./use-comments"; diff --git a/packages/stack/src/plugins/comments/client/hooks/use-comments.tsx b/packages/stack/src/plugins/comments/client/hooks/use-comments.tsx new file mode 100644 index 00000000..73c8b1a3 --- /dev/null +++ b/packages/stack/src/plugins/comments/client/hooks/use-comments.tsx @@ -0,0 +1,630 @@ +"use client"; + +import { + useQuery, + useInfiniteQuery, + useMutation, + useQueryClient, + useSuspenseQuery, + type InfiniteData, +} from "@tanstack/react-query"; +import { createApiClient } from "@btst/stack/plugins/client"; +import { createCommentsQueryKeys } from "../../query-keys"; +import type { CommentsApiRouter } from "../../api"; +import type { SerializedComment, CommentListResult } from "../../types"; + +interface CommentsClientConfig { + apiBaseURL: string; + apiBasePath: string; + headers?: HeadersInit; +} + +function getClient(config: CommentsClientConfig) { + return createApiClient({ + baseURL: config.apiBaseURL, + basePath: config.apiBasePath, + }); +} + +function toError(error: unknown): Error { + if (error instanceof Error) return error; + if (typeof error === "object" && error !== null) { + const obj = error as Record; + const message = + (typeof obj.message === "string" ? obj.message : null) || + JSON.stringify(error); + return new Error(message); + } + return new Error(String(error)); +} + +/** + * Fetch a paginated list of comments for a resource. + * Returns approved comments by default. + */ +export function useComments( + config: CommentsClientConfig, + params: { + resourceId?: string; + resourceType?: string; + parentId?: string | null; + status?: "pending" | "approved" | "spam"; + currentUserId?: string; + authorId?: string; + sort?: "asc" | "desc"; + limit?: number; + offset?: number; + }, + options?: { enabled?: boolean }, +) { + const client = getClient(config); + const queries = createCommentsQueryKeys(client, config.headers); + + const query = useQuery({ + ...queries.comments.list(params), + staleTime: 30_000, + retry: false, + enabled: options?.enabled ?? true, + }); + + return { + data: query.data, + comments: query.data?.items ?? [], + total: query.data?.total ?? 0, + isLoading: query.isLoading, + isFetching: query.isFetching, + error: query.error, + refetch: query.refetch, + }; +} + +/** + * useSuspenseQuery version — for use in .internal.tsx files. + */ +export function useSuspenseComments( + config: CommentsClientConfig, + params: { + resourceId?: string; + resourceType?: string; + parentId?: string | null; + status?: "pending" | "approved" | "spam"; + currentUserId?: string; + authorId?: string; + sort?: "asc" | "desc"; + limit?: number; + offset?: number; + }, +) { + const client = getClient(config); + const queries = createCommentsQueryKeys(client, config.headers); + + const { data, refetch, error, isFetching } = useSuspenseQuery({ + ...queries.comments.list(params), + staleTime: 30_000, + retry: false, + }); + + if (error && !isFetching) { + throw error; + } + + return { + comments: data?.items ?? [], + total: data?.total ?? 0, + refetch, + }; +} + +/** + * Infinite-scroll variant for the CommentThread component. + * Uses the "commentsThread" factory namespace (separate from the plain + * useComments / useSuspenseComments queries) to avoid InfiniteData shape conflicts. + * + * Mirrors the blog's usePosts pattern: spread the factory base query into + * useInfiniteQuery, drive pages via pageParam, and derive hasMore from server total. + */ +export function useInfiniteComments( + config: CommentsClientConfig, + params: { + resourceId: string; + resourceType: string; + parentId?: string | null; + status?: "pending" | "approved" | "spam"; + currentUserId?: string; + pageSize?: number; + }, + options?: { enabled?: boolean }, +) { + const pageSize = params.pageSize ?? 10; + const client = getClient(config); + const queries = createCommentsQueryKeys(client, config.headers); + + const baseQuery = queries.commentsThread.list({ + resourceId: params.resourceId, + resourceType: params.resourceType, + parentId: params.parentId ?? null, + status: params.status, + currentUserId: params.currentUserId, + limit: pageSize, + }); + + const query = useInfiniteQuery< + CommentListResult, + Error, + InfiniteData, + typeof baseQuery.queryKey, + number + >({ + ...baseQuery, + initialPageParam: 0, + getNextPageParam: (lastPage) => { + const nextOffset = lastPage.offset + lastPage.limit; + return nextOffset < lastPage.total ? nextOffset : undefined; + }, + staleTime: 30_000, + retry: false, + enabled: options?.enabled ?? true, + }); + + const comments = query.data?.pages.flatMap((p) => p.items) ?? []; + const total = query.data?.pages[0]?.total ?? 0; + + return { + comments, + total, + queryKey: baseQuery.queryKey, + isLoading: query.isLoading, + isFetching: query.isFetching, + loadMore: query.fetchNextPage, + hasMore: !!query.hasNextPage, + isLoadingMore: query.isFetchingNextPage, + error: query.error, + }; +} + +/** + * Fetch the approved comment count for a resource. + */ +export function useCommentCount( + config: CommentsClientConfig, + params: { + resourceId: string; + resourceType: string; + status?: "pending" | "approved" | "spam"; + }, +) { + const client = getClient(config); + const queries = createCommentsQueryKeys(client, config.headers); + + const query = useQuery({ + ...queries.commentCount.byResource(params), + staleTime: 60_000, + retry: false, + }); + + return { + count: query.data ?? 0, + isLoading: query.isLoading, + error: query.error, + }; +} + +/** + * Post a new comment with optimistic update. + * When autoApprove is false the optimistic entry shows as "pending" — visible + * only to the comment's own author via the `currentUserId` match in the UI. + * + * Pass `infiniteKey` (from `useInfiniteComments`) when the thread uses an + * infinite query so the optimistic update targets InfiniteData + * instead of a plain CommentListResult cache entry. + */ +export function usePostComment( + config: CommentsClientConfig, + params: { + resourceId: string; + resourceType: string; + currentUserId?: string; + /** When provided, optimistic updates target this infinite-query cache key. */ + infiniteKey?: readonly unknown[]; + }, +) { + const queryClient = useQueryClient(); + const client = getClient(config); + const queries = createCommentsQueryKeys(client, config.headers); + + // Compute the list key for a given parentId so optimistic updates always + // target the exact cache entry the component is subscribed to. + // parentId must be normalised to null (not undefined) because useComments + // passes `parentId: null` explicitly — null and undefined produce different + // discriminator objects and therefore different React Query cache keys. + const getListKey = (parentId: string | null | undefined) => { + // Top-level posts for a thread using useInfiniteComments get the infinite key. + if (params.infiniteKey && (parentId ?? null) === null) { + return params.infiniteKey; + } + return queries.comments.list({ + resourceId: params.resourceId, + resourceType: params.resourceType, + parentId: parentId ?? null, + status: "approved", + currentUserId: params.currentUserId, + }).queryKey; + }; + + const isInfinitePost = (parentId: string | null | undefined) => + !!params.infiniteKey && (parentId ?? null) === null; + + return useMutation({ + mutationFn: async (input: { body: string; parentId?: string | null }) => { + const response = await client("@post/comments", { + method: "POST", + body: { + resourceId: params.resourceId, + resourceType: params.resourceType, + parentId: input.parentId ?? null, + body: input.body, + }, + headers: config.headers, + }); + + const data = (response as { data?: unknown }).data; + if (!data) throw toError((response as { error?: unknown }).error); + return data as SerializedComment; + }, + onMutate: async (input) => { + const listKey = getListKey(input.parentId); + await queryClient.cancelQueries({ queryKey: listKey }); + + // Optimistic comment — shows to own author with "pending" badge + const optimisticId = `optimistic-${Date.now()}`; + const optimistic: SerializedComment = { + id: optimisticId, + resourceId: params.resourceId, + resourceType: params.resourceType, + parentId: input.parentId ?? null, + authorId: params.currentUserId ?? "", + resolvedAuthorName: "You", + resolvedAvatarUrl: null, + body: input.body, + status: "pending", + likes: 0, + isLikedByCurrentUser: false, + editedAt: null, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + replyCount: 0, + }; + + if (isInfinitePost(input.parentId)) { + const previous = + queryClient.getQueryData>(listKey); + + queryClient.setQueryData>( + listKey, + (old) => { + if (!old) { + return { + pages: [ + { items: [optimistic], total: 1, limit: 10, offset: 0 }, + ], + pageParams: [0], + }; + } + const lastIdx = old.pages.length - 1; + return { + ...old, + pages: old.pages.map((page, idx) => + idx === lastIdx + ? { + ...page, + items: [...page.items, optimistic], + total: page.total + 1, + } + : page, + ), + }; + }, + ); + + return { previous, isInfinite: true as const, listKey, optimisticId }; + } + + const previous = queryClient.getQueryData(listKey); + queryClient.setQueryData(listKey, (old) => { + if (!old) { + return { items: [optimistic], total: 1, limit: 20, offset: 0 }; + } + return { + ...old, + items: [...old.items, optimistic], + total: old.total + 1, + }; + }); + + return { previous, isInfinite: false as const, listKey, optimisticId }; + }, + onSuccess: (data, _input, context) => { + if (!context) return; + // Replace the optimistic item with the real server response. + // The server may return status "pending" (autoApprove: false) or "approved" + // (autoApprove: true). Either way we keep the item in the cache so the + // author continues to see their comment — with a "Pending approval" badge + // when pending. Without this, the onSettled invalidation would refetch + // only approved comments and make the pending entry disappear. + if (context.isInfinite) { + queryClient.setQueryData>( + context.listKey, + (old) => { + if (!old) return old; + return { + ...old, + pages: old.pages.map((page) => ({ + ...page, + items: page.items.map((item) => + item.id === context.optimisticId ? data : item, + ), + })), + }; + }, + ); + } else { + queryClient.setQueryData(context.listKey, (old) => { + if (!old) return old; + return { + ...old, + items: old.items.map((item) => + item.id === context.optimisticId ? data : item, + ), + }; + }); + } + }, + onError: (_err, _input, context) => { + if (context?.previous !== undefined) { + queryClient.setQueryData(context.listKey, context.previous); + } + }, + // No onSettled list invalidation — the mutation response is the ground + // truth. Invalidating would trigger a server refetch that returns only + // approved comments, erasing a pending optimistic entry from the cache. + }); +} + +/** + * Edit the body of an existing comment. + */ +export function useUpdateComment(config: CommentsClientConfig) { + const queryClient = useQueryClient(); + const client = getClient(config); + const queries = createCommentsQueryKeys(client, config.headers); + + return useMutation({ + mutationFn: async (input: { id: string; body: string }) => { + const response = await client("@patch/comments/:id", { + method: "PATCH", + params: { id: input.id }, + body: { body: input.body }, + headers: config.headers, + }); + const data = (response as { data?: unknown }).data; + if (!data) throw toError((response as { error?: unknown }).error); + return data as SerializedComment; + }, + onSettled: () => { + queryClient.invalidateQueries({ + queryKey: queries.comments.list._def, + }); + // Also invalidate the infinite thread cache so edits are reflected there. + queryClient.invalidateQueries({ queryKey: ["commentsThread"] }); + }, + }); +} + +/** + * Approve a comment (set status to "approved"). Admin use. + */ +export function useApproveComment(config: CommentsClientConfig) { + const queryClient = useQueryClient(); + const client = getClient(config); + const queries = createCommentsQueryKeys(client, config.headers); + + return useMutation({ + mutationFn: async (id: string) => { + const response = await client("@patch/comments/:id/status", { + method: "PATCH", + params: { id }, + body: { status: "approved" }, + headers: config.headers, + }); + const data = (response as { data?: unknown }).data; + if (!data) throw toError((response as { error?: unknown }).error); + return data as SerializedComment; + }, + onSettled: () => { + queryClient.invalidateQueries({ + queryKey: queries.comments.list._def, + }); + queryClient.invalidateQueries({ + queryKey: queries.commentCount.byResource._def, + }); + }, + }); +} + +/** + * Update comment status (pending / approved / spam). Admin use. + */ +export function useUpdateCommentStatus(config: CommentsClientConfig) { + const queryClient = useQueryClient(); + const client = getClient(config); + const queries = createCommentsQueryKeys(client, config.headers); + + return useMutation({ + mutationFn: async (input: { + id: string; + status: "pending" | "approved" | "spam"; + }) => { + const response = await client("@patch/comments/:id/status", { + method: "PATCH", + params: { id: input.id }, + body: { status: input.status }, + headers: config.headers, + }); + const data = (response as { data?: unknown }).data; + if (!data) throw toError((response as { error?: unknown }).error); + return data as SerializedComment; + }, + onSettled: () => { + queryClient.invalidateQueries({ + queryKey: queries.comments.list._def, + }); + queryClient.invalidateQueries({ + queryKey: queries.commentCount.byResource._def, + }); + }, + }); +} + +/** + * Delete a comment. Admin use. + */ +export function useDeleteComment(config: CommentsClientConfig) { + const queryClient = useQueryClient(); + const client = getClient(config); + const queries = createCommentsQueryKeys(client, config.headers); + + return useMutation({ + mutationFn: async (id: string) => { + const response = await client("@delete/comments/:id", { + method: "DELETE", + params: { id }, + headers: config.headers, + }); + const data = (response as { data?: unknown }).data; + if (!data) throw toError((response as { error?: unknown }).error); + return data as { success: boolean }; + }, + onSettled: () => { + queryClient.invalidateQueries({ + queryKey: queries.comments.list._def, + }); + queryClient.invalidateQueries({ + queryKey: queries.commentCount.byResource._def, + }); + // Also invalidate the infinite thread cache so deletions are reflected there. + queryClient.invalidateQueries({ queryKey: ["commentsThread"] }); + }, + }); +} + +/** + * Toggle a like on a comment with optimistic update. + * + * Pass `infiniteKey` (from `useInfiniteComments`) for top-level thread comments + * so the optimistic update targets InfiniteData instead of + * a plain CommentListResult cache entry. + */ +export function useToggleLike( + config: CommentsClientConfig, + params: { + resourceId: string; + resourceType: string; + /** parentId of the comment being liked — must match the parentId used by + * useComments so the optimistic setQueryData hits the correct cache entry. + * Pass `null` for top-level comments, or the parent comment ID for replies. */ + parentId?: string | null; + currentUserId?: string; + /** When the comment lives in an infinite thread, pass the thread's query key + * so the optimistic update targets the correct InfiniteData cache entry. */ + infiniteKey?: readonly unknown[]; + }, +) { + const queryClient = useQueryClient(); + const client = getClient(config); + const queries = createCommentsQueryKeys(client, config.headers); + + // For top-level thread comments use the infinite key; for replies (or when no + // infinite key is supplied) fall back to the regular list cache entry. + const isInfinite = !!params.infiniteKey && (params.parentId ?? null) === null; + const listKey = isInfinite + ? params.infiniteKey! + : queries.comments.list({ + resourceId: params.resourceId, + resourceType: params.resourceType, + parentId: params.parentId ?? null, + status: "approved", + currentUserId: params.currentUserId, + }).queryKey; + + function applyLikeUpdate( + commentId: string, + updater: (c: SerializedComment) => SerializedComment, + ) { + if (isInfinite) { + queryClient.setQueryData>( + listKey, + (old) => { + if (!old) return old; + return { + ...old, + pages: old.pages.map((page) => ({ + ...page, + items: page.items.map((c) => + c.id === commentId ? updater(c) : c, + ), + })), + }; + }, + ); + } else { + queryClient.setQueryData(listKey, (old) => { + if (!old) return old; + return { + ...old, + items: old.items.map((c) => (c.id === commentId ? updater(c) : c)), + }; + }); + } + } + + return useMutation({ + mutationFn: async (input: { commentId: string; authorId: string }) => { + const response = await client("@post/comments/:id/like", { + method: "POST", + params: { id: input.commentId }, + body: { authorId: input.authorId }, + headers: config.headers, + }); + const data = (response as { data?: unknown }).data; + if (!data) throw toError((response as { error?: unknown }).error); + return data as { likes: number; isLiked: boolean }; + }, + onMutate: async (input) => { + await queryClient.cancelQueries({ queryKey: listKey }); + + // Snapshot previous state for rollback. + const previous = isInfinite + ? queryClient.getQueryData>(listKey) + : queryClient.getQueryData(listKey); + + applyLikeUpdate(input.commentId, (c) => { + const wasLiked = c.isLikedByCurrentUser; + return { + ...c, + isLikedByCurrentUser: !wasLiked, + likes: wasLiked ? Math.max(0, c.likes - 1) : c.likes + 1, + }; + }); + + return { previous }; + }, + onError: (_err, _input, context) => { + if (context?.previous !== undefined) { + queryClient.setQueryData(listKey, context.previous); + } + }, + onSettled: () => { + queryClient.invalidateQueries({ queryKey: listKey }); + }, + }); +} diff --git a/packages/stack/src/plugins/comments/client/index.ts b/packages/stack/src/plugins/comments/client/index.ts new file mode 100644 index 00000000..d769af40 --- /dev/null +++ b/packages/stack/src/plugins/comments/client/index.ts @@ -0,0 +1,14 @@ +export { + commentsClientPlugin, + type CommentsClientConfig, + type CommentsClientHooks, + type LoaderContext, +} from "./plugin"; +export { + type CommentsPluginOverrides, + type RouteContext, +} from "./overrides"; +export { + COMMENTS_LOCALIZATION, + type CommentsLocalization, +} from "./localization"; diff --git a/packages/stack/src/plugins/comments/client/localization/comments-moderation.ts b/packages/stack/src/plugins/comments/client/localization/comments-moderation.ts new file mode 100644 index 00000000..1c6a7745 --- /dev/null +++ b/packages/stack/src/plugins/comments/client/localization/comments-moderation.ts @@ -0,0 +1,71 @@ +export const COMMENTS_MODERATION = { + COMMENTS_MODERATION_TITLE: "Comment Moderation", + COMMENTS_MODERATION_DESCRIPTION: + "Review and manage comments across all resources.", + + COMMENTS_MODERATION_TAB_PENDING: "Pending", + COMMENTS_MODERATION_TAB_APPROVED: "Approved", + COMMENTS_MODERATION_TAB_SPAM: "Spam", + + COMMENTS_MODERATION_SELECTED: "{n} selected", + COMMENTS_MODERATION_APPROVE_SELECTED: "Approve selected", + COMMENTS_MODERATION_DELETE_SELECTED: "Delete selected", + COMMENTS_MODERATION_EMPTY: "No {status} comments.", + + COMMENTS_MODERATION_COL_AUTHOR: "Author", + COMMENTS_MODERATION_COL_COMMENT: "Comment", + COMMENTS_MODERATION_COL_RESOURCE: "Resource", + COMMENTS_MODERATION_COL_DATE: "Date", + COMMENTS_MODERATION_COL_ACTIONS: "Actions", + COMMENTS_MODERATION_SELECT_ALL: "Select all", + COMMENTS_MODERATION_SELECT_ONE: "Select comment", + + COMMENTS_MODERATION_ACTION_VIEW: "View", + COMMENTS_MODERATION_ACTION_APPROVE: "Approve", + COMMENTS_MODERATION_ACTION_SPAM: "Mark as spam", + COMMENTS_MODERATION_ACTION_DELETE: "Delete", + + COMMENTS_MODERATION_TOAST_APPROVED: "Comment approved", + COMMENTS_MODERATION_TOAST_APPROVE_ERROR: "Failed to approve comment", + COMMENTS_MODERATION_TOAST_SPAM: "Marked as spam", + COMMENTS_MODERATION_TOAST_SPAM_ERROR: "Failed to update status", + COMMENTS_MODERATION_TOAST_DELETED: "Comment deleted", + COMMENTS_MODERATION_TOAST_DELETED_PLURAL: "{n} comments deleted", + COMMENTS_MODERATION_TOAST_DELETE_ERROR: "Failed to delete comment(s)", + COMMENTS_MODERATION_TOAST_BULK_APPROVED: "{n} comment(s) approved", + COMMENTS_MODERATION_TOAST_BULK_APPROVE_ERROR: "Failed to approve comments", + + COMMENTS_MODERATION_DIALOG_TITLE: "Comment Details", + COMMENTS_MODERATION_DIALOG_RESOURCE: "Resource", + COMMENTS_MODERATION_DIALOG_LIKES: "Likes", + COMMENTS_MODERATION_DIALOG_REPLY_TO: "Reply to", + COMMENTS_MODERATION_DIALOG_EDITED: "Edited", + COMMENTS_MODERATION_DIALOG_BODY: "Body", + COMMENTS_MODERATION_DIALOG_APPROVE: "Approve", + COMMENTS_MODERATION_DIALOG_MARK_SPAM: "Mark spam", + COMMENTS_MODERATION_DIALOG_DELETE: "Delete", + + COMMENTS_MODERATION_DELETE_TITLE_SINGULAR: "Delete comment?", + COMMENTS_MODERATION_DELETE_TITLE_PLURAL: "Delete {n} comments?", + COMMENTS_MODERATION_DELETE_DESCRIPTION_SINGULAR: + "This action cannot be undone. The comment will be permanently deleted.", + COMMENTS_MODERATION_DELETE_DESCRIPTION_PLURAL: + "This action cannot be undone. The comments will be permanently deleted.", + COMMENTS_MODERATION_DELETE_CANCEL: "Cancel", + COMMENTS_MODERATION_DELETE_CONFIRM: "Delete", + COMMENTS_MODERATION_DELETE_DELETING: "Deleting…", + + COMMENTS_RESOURCE_TITLE: "Comments", + COMMENTS_RESOURCE_PENDING_SECTION: "Pending Review", + COMMENTS_RESOURCE_THREAD_SECTION: "Thread", + COMMENTS_RESOURCE_ACTION_APPROVE: "Approve", + COMMENTS_RESOURCE_ACTION_SPAM: "Spam", + COMMENTS_RESOURCE_ACTION_DELETE: "Delete", + COMMENTS_RESOURCE_DELETE_CONFIRM: "Delete this comment?", + COMMENTS_RESOURCE_TOAST_APPROVED: "Comment approved", + COMMENTS_RESOURCE_TOAST_APPROVE_ERROR: "Failed to approve", + COMMENTS_RESOURCE_TOAST_SPAM: "Marked as spam", + COMMENTS_RESOURCE_TOAST_SPAM_ERROR: "Failed to update", + COMMENTS_RESOURCE_TOAST_DELETED: "Comment deleted", + COMMENTS_RESOURCE_TOAST_DELETE_ERROR: "Failed to delete", +}; diff --git a/packages/stack/src/plugins/comments/client/localization/comments-my.ts b/packages/stack/src/plugins/comments/client/localization/comments-my.ts new file mode 100644 index 00000000..c96c18a8 --- /dev/null +++ b/packages/stack/src/plugins/comments/client/localization/comments-my.ts @@ -0,0 +1,32 @@ +export const COMMENTS_MY = { + COMMENTS_MY_LOGIN_TITLE: "Please log in to view your comments", + COMMENTS_MY_LOGIN_DESCRIPTION: + "You need to be logged in to see your comment history.", + + COMMENTS_MY_EMPTY_TITLE: "No comments yet", + COMMENTS_MY_EMPTY_DESCRIPTION: "Comments you post will appear here.", + + COMMENTS_MY_PAGE_TITLE: "My Comments", + + COMMENTS_MY_COL_COMMENT: "Comment", + COMMENTS_MY_COL_RESOURCE: "Resource", + COMMENTS_MY_COL_STATUS: "Status", + COMMENTS_MY_COL_DATE: "Date", + + COMMENTS_MY_REPLY_INDICATOR: "↩ Reply", + COMMENTS_MY_VIEW_LINK: "View", + + COMMENTS_MY_STATUS_APPROVED: "Approved", + COMMENTS_MY_STATUS_PENDING: "Pending", + COMMENTS_MY_STATUS_SPAM: "Spam", + + COMMENTS_MY_TOAST_DELETED: "Comment deleted", + COMMENTS_MY_TOAST_DELETE_ERROR: "Failed to delete comment", + + COMMENTS_MY_DELETE_TITLE: "Delete comment?", + COMMENTS_MY_DELETE_DESCRIPTION: + "This action cannot be undone. The comment will be permanently removed.", + COMMENTS_MY_DELETE_CANCEL: "Cancel", + COMMENTS_MY_DELETE_CONFIRM: "Delete", + COMMENTS_MY_DELETE_BUTTON_SR: "Delete comment", +}; diff --git a/packages/stack/src/plugins/comments/client/localization/comments-thread.ts b/packages/stack/src/plugins/comments/client/localization/comments-thread.ts new file mode 100644 index 00000000..d53cbc44 --- /dev/null +++ b/packages/stack/src/plugins/comments/client/localization/comments-thread.ts @@ -0,0 +1,32 @@ +export const COMMENTS_THREAD = { + COMMENTS_TITLE: "Comments", + COMMENTS_EMPTY: "Be the first to comment.", + + COMMENTS_EDITED_BADGE: "(edited)", + COMMENTS_PENDING_BADGE: "Pending approval", + + COMMENTS_LIKE_ARIA: "Like", + COMMENTS_UNLIKE_ARIA: "Unlike", + COMMENTS_REPLY_BUTTON: "Reply", + COMMENTS_EDIT_BUTTON: "Edit", + COMMENTS_DELETE_BUTTON: "Delete", + COMMENTS_SAVE_EDIT: "Save", + + COMMENTS_REPLIES_SINGULAR: "reply", + COMMENTS_REPLIES_PLURAL: "replies", + COMMENTS_HIDE_REPLIES: "Hide replies", + COMMENTS_DELETE_CONFIRM: "Delete this comment?", + + COMMENTS_LOGIN_PROMPT: "Please sign in to leave a comment.", + COMMENTS_LOGIN_LINK: "Sign in", + + COMMENTS_FORM_PLACEHOLDER: "Write a comment…", + COMMENTS_FORM_CANCEL: "Cancel", + COMMENTS_FORM_POST_COMMENT: "Post comment", + COMMENTS_FORM_POST_REPLY: "Post reply", + COMMENTS_FORM_POSTING: "Posting…", + COMMENTS_FORM_SUBMIT_ERROR: "Failed to submit comment", + + COMMENTS_LOAD_MORE: "Load more comments", + COMMENTS_LOADING_MORE: "Loading…", +}; diff --git a/packages/stack/src/plugins/comments/client/localization/index.ts b/packages/stack/src/plugins/comments/client/localization/index.ts new file mode 100644 index 00000000..2d142ab9 --- /dev/null +++ b/packages/stack/src/plugins/comments/client/localization/index.ts @@ -0,0 +1,11 @@ +import { COMMENTS_THREAD } from "./comments-thread"; +import { COMMENTS_MODERATION } from "./comments-moderation"; +import { COMMENTS_MY } from "./comments-my"; + +export const COMMENTS_LOCALIZATION = { + ...COMMENTS_THREAD, + ...COMMENTS_MODERATION, + ...COMMENTS_MY, +}; + +export type CommentsLocalization = typeof COMMENTS_LOCALIZATION; diff --git a/packages/stack/src/plugins/comments/client/overrides.ts b/packages/stack/src/plugins/comments/client/overrides.ts new file mode 100644 index 00000000..843efa2e --- /dev/null +++ b/packages/stack/src/plugins/comments/client/overrides.ts @@ -0,0 +1,137 @@ +/** + * Context passed to lifecycle hooks + */ +export interface RouteContext { + /** Current route path */ + path: string; + /** Route parameters (e.g., { resourceId: "my-post", resourceType: "blog-post" }) */ + params?: Record; + /** Whether rendering on server (true) or client (false) */ + isSSR: boolean; + /** Additional context properties */ + [key: string]: unknown; +} + +import type { CommentsLocalization } from "./localization"; + +/** + * Overridable configuration and hooks for the Comments plugin. + * + * Provide these in the layout wrapping your pages via `PluginOverridesProvider`. + */ +export interface CommentsPluginOverrides { + /** + * Localization strings for all Comments plugin UI. + * Defaults to English when not provided. + */ + localization?: Partial; + /** + * Base URL for API calls (e.g., "https://example.com") + */ + apiBaseURL: string; + + /** + * Path where the API is mounted (e.g., "/api/data") + */ + apiBasePath: string; + + /** + * Optional headers for authenticated API calls (e.g., forwarding cookies) + */ + headers?: Record; + + /** + * Whether to show the "Powered by BTST" attribution on plugin pages. + * Defaults to true. + */ + showAttribution?: boolean; + + /** + * The ID of the currently authenticated user. + * + * Used by the My Comments page to scope the comment list to the current user. + * Can be a static string or an async function (useful when the user ID must + * be resolved from a session cookie at render time). + * + * When absent the My Comments page shows a "Please log in" prompt. + */ + currentUserId?: + | string + | (() => string | undefined | Promise); + + /** + * Default number of top-level comments to load per page in `CommentThread`. + * Can be overridden per-instance via the `pageSize` prop. + * Defaults to 100 when not set. + */ + defaultCommentPageSize?: number; + + /** + * Per-resource-type URL builders used to link each comment back to its + * original resource on the My Comments page. + * + * @example + * ```ts + * resourceLinks: { + * "blog-post": (slug) => `/pages/blog/${slug}`, + * "kanban-task": (id) => `/pages/kanban?task=${id}`, + * } + * ``` + * + * When a resource type has no entry the ID is shown as plain text. + */ + resourceLinks?: Record string>; + + // ============ Access Control Hooks ============ + + /** + * Called before the moderation dashboard page is rendered. + * Return false to block rendering (e.g., redirect to login or show 403). + * @param context - Route context + */ + onBeforeModerationPageRendered?: (context: RouteContext) => boolean; + + /** + * Called before the per-resource comments page is rendered. + * Return false to block rendering (e.g., for authorization). + * @param resourceType - The type of resource (e.g., "blog-post") + * @param resourceId - The ID of the resource + * @param context - Route context + */ + onBeforeResourceCommentsRendered?: ( + resourceType: string, + resourceId: string, + context: RouteContext, + ) => boolean; + + /** + * Called before the My Comments page is rendered. + * Throw to block rendering (e.g., when the user is not authenticated). + * @param context - Route context + */ + onBeforeMyCommentsPageRendered?: (context: RouteContext) => boolean | void; + + // ============ Lifecycle Hooks ============ + + /** + * Called when a route is rendered. + * @param routeName - Name of the route (e.g., 'moderation', 'resourceComments') + * @param context - Route context + */ + onRouteRender?: ( + routeName: string, + context: RouteContext, + ) => void | Promise; + + /** + * Called when a route encounters an error. + * @param routeName - Name of the route + * @param error - The error that occurred + * @param context - Route context + */ + onRouteError?: ( + routeName: string, + error: Error, + context: RouteContext, + ) => void | Promise; +} diff --git a/packages/stack/src/plugins/comments/client/plugin.tsx b/packages/stack/src/plugins/comments/client/plugin.tsx new file mode 100644 index 00000000..f1d2d7db --- /dev/null +++ b/packages/stack/src/plugins/comments/client/plugin.tsx @@ -0,0 +1,162 @@ +// NO "use client" here! This file runs on both server and client. +import { lazy } from "react"; +import { + defineClientPlugin, + isConnectionError, +} from "@btst/stack/plugins/client"; +import { createRoute } from "@btst/yar"; +import type { QueryClient } from "@tanstack/react-query"; + +// Lazy load page components for code splitting +const ModerationPageComponent = lazy(() => + import("./components/pages/moderation-page").then((m) => ({ + default: m.ModerationPageComponent, + })), +); + +const MyCommentsPageComponent = lazy(() => + import("./components/pages/my-comments-page").then((m) => ({ + default: m.MyCommentsPageComponent, + })), +); + +/** + * Context passed to loader hooks + */ +export interface LoaderContext { + /** Current route path */ + path: string; + /** Route parameters */ + params?: Record; + /** Whether rendering on server (true) or client (false) */ + isSSR: boolean; + /** Base URL for API calls */ + apiBaseURL: string; + /** Path where the API is mounted */ + apiBasePath: string; + /** Optional headers for the request */ + headers?: Headers; + /** Additional context properties */ + [key: string]: unknown; +} + +/** + * Hooks for Comments client plugin + */ +export interface CommentsClientHooks { + /** + * Called before loading the moderation page. Throw to cancel. + */ + beforeLoadModeration?: (context: LoaderContext) => Promise | void; + /** + * Called before loading the My Comments page. Throw to cancel. + */ + beforeLoadMyComments?: (context: LoaderContext) => Promise | void; + /** + * Called when a loading error occurs. + */ + onLoadError?: (error: Error, context: LoaderContext) => Promise | void; +} + +/** + * Configuration for the Comments client plugin + */ +export interface CommentsClientConfig { + /** Base URL for API calls (e.g., "http://localhost:3000") */ + apiBaseURL: string; + /** Path where the API is mounted (e.g., "/api/data") */ + apiBasePath: string; + /** Base URL of your site */ + siteBaseURL: string; + /** Path where pages are mounted (e.g., "/pages") */ + siteBasePath: string; + /** React Query client instance */ + queryClient: QueryClient; + /** Optional headers for SSR */ + headers?: Headers; + /** Optional lifecycle hooks */ + hooks?: CommentsClientHooks; +} + +function createModerationLoader(config: CommentsClientConfig) { + return async () => { + if (typeof window === "undefined") { + const { apiBasePath, apiBaseURL, headers, hooks } = config; + const context: LoaderContext = { + path: "/comments/moderation", + isSSR: true, + apiBaseURL, + apiBasePath, + headers, + }; + try { + if (hooks?.beforeLoadModeration) { + await hooks.beforeLoadModeration(context); + } + } catch (error) { + if (isConnectionError(error)) { + console.warn( + "[btst/comments] route.loader() failed — no server running at build time.", + ); + } + if (hooks?.onLoadError) { + await hooks.onLoadError(error as Error, context); + } + } + } + }; +} + +function createMyCommentsLoader(config: CommentsClientConfig) { + return async () => { + if (typeof window === "undefined") { + const { apiBasePath, apiBaseURL, headers, hooks } = config; + const context: LoaderContext = { + path: "/comments/my-comments", + isSSR: true, + apiBaseURL, + apiBasePath, + headers, + }; + try { + if (hooks?.beforeLoadMyComments) { + await hooks.beforeLoadMyComments(context); + } + } catch (error) { + if (isConnectionError(error)) { + console.warn( + "[btst/comments] route.loader() failed — no server running at build time.", + ); + } + if (hooks?.onLoadError) { + await hooks.onLoadError(error as Error, context); + } + } + } + }; +} + +/** + * Comments client plugin — registers admin moderation routes. + * + * The embeddable `CommentThread` and `CommentCount` components are standalone + * and do not require this plugin to be registered. Register them manually + * via the layout overrides pattern or use them directly in your pages. + */ +export const commentsClientPlugin = (config: CommentsClientConfig) => + defineClientPlugin({ + name: "comments", + + routes: () => ({ + moderation: createRoute("/comments/moderation", () => ({ + PageComponent: ModerationPageComponent, + loader: createModerationLoader(config), + meta: () => [{ title: "Comment Moderation" }], + })), + myComments: createRoute("/comments/my-comments", () => ({ + PageComponent: MyCommentsPageComponent, + loader: createMyCommentsLoader(config), + meta: () => [{ title: "My Comments" }], + })), + }), + }); diff --git a/packages/stack/src/plugins/comments/db.ts b/packages/stack/src/plugins/comments/db.ts new file mode 100644 index 00000000..10563800 --- /dev/null +++ b/packages/stack/src/plugins/comments/db.ts @@ -0,0 +1,77 @@ +import { createDbPlugin } from "@btst/db"; + +/** + * Comments plugin schema. + * Defines two tables: + * - comment: the main comment record (always authenticated, no anonymous) + * - commentLike: join table for per-user like deduplication + */ +export const commentsSchema = createDbPlugin("comments", { + comment: { + modelName: "comment", + fields: { + resourceId: { + type: "string", + required: true, + }, + resourceType: { + type: "string", + required: true, + }, + parentId: { + type: "string", + required: false, + }, + authorId: { + type: "string", + required: true, + }, + body: { + type: "string", + required: true, + }, + status: { + type: "string", + defaultValue: "pending", + }, + likes: { + type: "number", + defaultValue: 0, + }, + editedAt: { + type: "date", + required: false, + }, + createdAt: { + type: "date", + defaultValue: () => new Date(), + }, + updatedAt: { + type: "date", + defaultValue: () => new Date(), + }, + }, + }, + commentLike: { + modelName: "commentLike", + fields: { + commentId: { + type: "string", + required: true, + references: { + model: "comment", + field: "id", + onDelete: "cascade", + }, + }, + authorId: { + type: "string", + required: true, + }, + createdAt: { + type: "date", + defaultValue: () => new Date(), + }, + }, + }, +}); diff --git a/packages/stack/src/plugins/comments/query-keys.ts b/packages/stack/src/plugins/comments/query-keys.ts new file mode 100644 index 00000000..3af32569 --- /dev/null +++ b/packages/stack/src/plugins/comments/query-keys.ts @@ -0,0 +1,205 @@ +import { + mergeQueryKeys, + createQueryKeys, +} from "@lukemorales/query-key-factory"; +import type { CommentsApiRouter } from "./api"; +import { createApiClient } from "@btst/stack/plugins/client"; +import type { SerializedComment, CommentListResult } from "./types"; +import { + commentsListDiscriminator, + commentCountDiscriminator, + commentsThreadDiscriminator, +} from "./api/query-key-defs"; + +interface CommentsListParams { + resourceId?: string; + resourceType?: string; + parentId?: string | null; + status?: "pending" | "approved" | "spam"; + currentUserId?: string; + authorId?: string; + sort?: "asc" | "desc"; + limit?: number; + offset?: number; +} + +interface CommentCountParams { + resourceId: string; + resourceType: string; + status?: "pending" | "approved" | "spam"; +} + +function isErrorResponse( + response: unknown, +): response is { error: unknown; data?: never } { + return ( + typeof response === "object" && + response !== null && + "error" in response && + (response as Record).error !== null && + (response as Record).error !== undefined + ); +} + +function toError(error: unknown): Error { + if (error instanceof Error) return error; + if (typeof error === "object" && error !== null) { + const obj = error as Record; + const message = + (typeof obj.message === "string" ? obj.message : null) || + (typeof obj.error === "string" ? obj.error : null) || + JSON.stringify(error); + const err = new Error(message); + Object.assign(err, error); + return err; + } + return new Error(String(error)); +} + +export function createCommentsQueryKeys( + client: ReturnType>, + headers?: HeadersInit, +) { + return mergeQueryKeys( + createCommentsQueries(client, headers), + createCommentCountQueries(client, headers), + createCommentsThreadQueries(client, headers), + ); +} + +function createCommentsQueries( + client: ReturnType>, + headers?: HeadersInit, +) { + return createQueryKeys("comments", { + list: (params?: CommentsListParams) => ({ + queryKey: [commentsListDiscriminator(params)], + queryFn: async (): Promise => { + const response = await client("/comments", { + method: "GET", + query: { + resourceId: params?.resourceId, + resourceType: params?.resourceType, + parentId: params?.parentId === null ? "null" : params?.parentId, + status: params?.status, + currentUserId: params?.currentUserId, + authorId: params?.authorId, + sort: params?.sort, + limit: params?.limit ?? 20, + offset: params?.offset ?? 0, + }, + headers, + }); + + if (isErrorResponse(response)) { + throw toError((response as { error: unknown }).error); + } + + const data = (response as { data?: unknown }).data as + | CommentListResult + | undefined; + return data ?? { items: [], total: 0, limit: 20, offset: 0 }; + }, + }), + + detail: (id: string) => ({ + queryKey: [id], + queryFn: async (): Promise => { + if (!id) return null; + // Single comment fetch — reuse list with implicit filtering + // (the backend does not expose GET /comments/:id publicly, use list) + return null; + }, + }), + }); +} + +function createCommentCountQueries( + client: ReturnType>, + headers?: HeadersInit, +) { + return createQueryKeys("commentCount", { + byResource: (params: CommentCountParams) => ({ + queryKey: [commentCountDiscriminator(params)], + queryFn: async (): Promise => { + const response = await client("/comments/count", { + method: "GET", + query: { + resourceId: params.resourceId, + resourceType: params.resourceType, + status: params.status, + }, + headers, + }); + + if (isErrorResponse(response)) { + throw toError((response as { error: unknown }).error); + } + + const data = (response as { data?: unknown }).data as + | { count: number } + | undefined; + return data?.count ?? 0; + }, + }), + }); +} + +/** + * Factory for the infinite thread query key family. + * Mirrors the blog's `createPostsQueries` pattern: the key is stable (no offset), + * and pages are fetched via `pageParam` passed to the queryFn. + */ +function createCommentsThreadQueries( + client: ReturnType>, + headers?: HeadersInit, +) { + return createQueryKeys("commentsThread", { + list: (params?: { + resourceId?: string; + resourceType?: string; + parentId?: string | null; + status?: "pending" | "approved" | "spam"; + currentUserId?: string; + limit?: number; + }) => ({ + // Offset is excluded from the key — it is driven by pageParam. + queryKey: [commentsThreadDiscriminator(params)], + queryFn: async ({ + pageParam, + }: { + pageParam?: number; + } = {}): Promise => { + const response = await client("/comments", { + method: "GET", + query: { + resourceId: params?.resourceId, + resourceType: params?.resourceType, + parentId: params?.parentId === null ? "null" : params?.parentId, + status: params?.status, + currentUserId: params?.currentUserId, + limit: params?.limit ?? 20, + offset: pageParam ?? 0, + }, + headers, + }); + + if (isErrorResponse(response)) { + throw toError((response as { error: unknown }).error); + } + + const data = (response as { data?: unknown }).data as + | CommentListResult + | undefined; + return ( + data ?? { + items: [], + total: 0, + limit: params?.limit ?? 20, + offset: pageParam ?? 0, + } + ); + }, + }), + }); +} diff --git a/packages/stack/src/plugins/comments/schemas.ts b/packages/stack/src/plugins/comments/schemas.ts new file mode 100644 index 00000000..c0b3973c --- /dev/null +++ b/packages/stack/src/plugins/comments/schemas.ts @@ -0,0 +1,54 @@ +import { z } from "zod"; + +export const CommentStatusSchema = z.enum(["pending", "approved", "spam"]); + +// ============ Comment Schemas ============ + +/** + * Schema for the POST /comments request body. + * authorId is intentionally absent — the server resolves identity from the + * session inside onBeforePost and injects it. Never trust authorId from the + * client body. + */ +export const createCommentSchema = z.object({ + resourceId: z.string().min(1, "Resource ID is required"), + resourceType: z.string().min(1, "Resource type is required"), + parentId: z.string().optional().nullable(), + body: z.string().min(1, "Body is required").max(10000, "Comment too long"), +}); + +/** + * Internal schema used after the authorId has been resolved server-side. + * This is what gets passed to createComment() in mutations.ts. + */ +export const createCommentInternalSchema = createCommentSchema.extend({ + authorId: z.string().min(1, "Author ID is required"), +}); + +export const updateCommentSchema = z.object({ + body: z.string().min(1, "Body is required").max(10000, "Comment too long"), +}); + +export const updateCommentStatusSchema = z.object({ + status: CommentStatusSchema, +}); + +// ============ Query Schemas ============ + +export const CommentListQuerySchema = z.object({ + resourceId: z.string().optional(), + resourceType: z.string().optional(), + parentId: z.string().optional().nullable(), + status: CommentStatusSchema.optional(), + currentUserId: z.string().optional(), + authorId: z.string().optional(), + sort: z.enum(["asc", "desc"]).optional(), + limit: z.coerce.number().int().min(1).max(100).optional(), + offset: z.coerce.number().int().min(0).optional(), +}); + +export const CommentCountQuerySchema = z.object({ + resourceId: z.string().min(1), + resourceType: z.string().min(1), + status: CommentStatusSchema.optional(), +}); diff --git a/packages/stack/src/plugins/comments/style.css b/packages/stack/src/plugins/comments/style.css new file mode 100644 index 00000000..aca480b1 --- /dev/null +++ b/packages/stack/src/plugins/comments/style.css @@ -0,0 +1,15 @@ +@import "./client.css"; + +/* + * Comments Plugin CSS - Includes Tailwind class scanning + * + * When consumed from npm, Tailwind v4 will automatically scan this package's + * source files for Tailwind classes. Consumers only need: + * @import "@btst/stack/plugins/comments/css"; + */ + +/* Scan this package's source files for Tailwind classes */ +@source "../../../src/**/*.{ts,tsx}"; + +/* Scan UI package components (when installed as npm package the UI package will be in this dir) */ +@source "../../packages/ui/src"; diff --git a/packages/stack/src/plugins/comments/types.ts b/packages/stack/src/plugins/comments/types.ts new file mode 100644 index 00000000..006a86e4 --- /dev/null +++ b/packages/stack/src/plugins/comments/types.ts @@ -0,0 +1,73 @@ +/** + * Comment status values + */ +export type CommentStatus = "pending" | "approved" | "spam"; + +/** + * A comment record as stored in the database + */ +export type Comment = { + id: string; + resourceId: string; + resourceType: string; + parentId: string | null; + authorId: string; + body: string; + status: CommentStatus; + likes: number; + editedAt?: Date; + createdAt: Date; + updatedAt: Date; +}; + +/** + * A like record linking an author to a comment + */ +export type CommentLike = { + id: string; + commentId: string; + authorId: string; + createdAt: Date; +}; + +/** + * A comment enriched with server-resolved author info and like status. + * All dates are ISO strings (safe for serialisation over HTTP / React Query cache). + */ +export interface SerializedComment { + id: string; + resourceId: string; + resourceType: string; + parentId: string | null; + authorId: string; + /** Resolved from resolveUser(authorId). Falls back to "[deleted]" when user cannot be found. */ + resolvedAuthorName: string; + /** Resolved avatar URL or null */ + resolvedAvatarUrl: string | null; + body: string; + status: CommentStatus; + /** Denormalized counter — updated atomically on toggleLike */ + likes: number; + /** True when the currentUserId query param matches an existing commentLike row */ + isLikedByCurrentUser: boolean; + /** ISO string set when the comment body was edited; null for unedited comments */ + editedAt: string | null; + createdAt: string; + updatedAt: string; + /** + * Number of direct replies visible to the requesting user. + * Includes approved replies plus any pending replies authored by `currentUserId`. + * Always 0 for reply comments (non-null parentId). + */ + replyCount: number; +} + +/** + * Paginated list result for comments + */ +export interface CommentListResult { + items: SerializedComment[]; + total: number; + limit: number; + offset: number; +} diff --git a/packages/stack/src/plugins/kanban/client/components/pages/board-page.internal.tsx b/packages/stack/src/plugins/kanban/client/components/pages/board-page.internal.tsx index f5a0c180..26b25831 100644 --- a/packages/stack/src/plugins/kanban/client/components/pages/board-page.internal.tsx +++ b/packages/stack/src/plugins/kanban/client/components/pages/board-page.internal.tsx @@ -66,8 +66,11 @@ export function BoardPage({ boardId }: BoardPageProps) { throw error; } - const { Link: OverrideLink, navigate: overrideNavigate } = - usePluginOverrides("kanban"); + const { + Link: OverrideLink, + navigate: overrideNavigate, + taskDetailBottomSlot, + } = usePluginOverrides("kanban"); const navigate = overrideNavigate || ((path: string) => { @@ -540,33 +543,49 @@ export function BoardPage({ boardId }: BoardPageProps) { Update task details. {modalState.type === "editTask" && ( - c.id === modalState.columnId) - ?.tasks?.find((t) => t.id === modalState.taskId)} - columns={board.columns || []} - onClose={closeModal} - onSuccess={() => { - closeModal(); - refetch(); - }} - onDelete={async () => { - try { - await deleteTask(modalState.taskId); + <> + c.id === modalState.columnId) + ?.tasks?.find((t) => t.id === modalState.taskId)} + columns={board.columns || []} + onClose={closeModal} + onSuccess={() => { closeModal(); refetch(); - } catch (error) { - const message = - error instanceof Error - ? error.message - : "Failed to delete task"; - toast.error(message); - } - }} - /> + }} + onDelete={async () => { + try { + await deleteTask(modalState.taskId); + closeModal(); + refetch(); + } catch (error) { + const message = + error instanceof Error + ? error.message + : "Failed to delete task"; + toast.error(message); + } + }} + /> + {taskDetailBottomSlot && + (() => { + const task = board.columns + ?.find((c) => c.id === modalState.columnId) + ?.tasks?.find((t) => t.id === modalState.taskId); + return task ? ( + + {taskDetailBottomSlot(task)} + + ) : null; + })()} + > )} diff --git a/packages/stack/src/plugins/kanban/client/overrides.ts b/packages/stack/src/plugins/kanban/client/overrides.ts index 5f6d1200..9c90cc33 100644 --- a/packages/stack/src/plugins/kanban/client/overrides.ts +++ b/packages/stack/src/plugins/kanban/client/overrides.ts @@ -1,5 +1,6 @@ -import type { ComponentType } from "react"; +import type { ComponentType, ReactNode } from "react"; import type { KanbanLocalization } from "./localization"; +import type { SerializedTask } from "../types"; /** * User information for assignee display/selection @@ -142,4 +143,29 @@ export interface KanbanPluginOverrides { * @param context - Route context */ onBeforeNewBoardPageRendered?: (context: RouteContext) => boolean; + + // ============ Slot Overrides ============ + + /** + * Optional slot rendered at the bottom of the task detail dialog. + * Use this to inject a comment thread or any custom content without + * coupling the kanban plugin to the comments plugin. + * + * @example + * ```tsx + * kanban: { + * taskDetailBottomSlot: (task) => ( + * + * ), + * } + * ``` + */ + taskDetailBottomSlot?: (task: SerializedTask) => ReactNode; } diff --git a/packages/ui/src/components/pagination-controls.tsx b/packages/ui/src/components/pagination-controls.tsx new file mode 100644 index 00000000..1fa777f5 --- /dev/null +++ b/packages/ui/src/components/pagination-controls.tsx @@ -0,0 +1,80 @@ +"use client"; + +import { Button } from "@workspace/ui/components/button"; +import { ChevronLeft, ChevronRight } from "lucide-react"; + +export interface PaginationControlsProps { + /** Current page, 1-based */ + currentPage: number; + totalPages: number; + total: number; + limit: number; + offset: number; + onPageChange: (page: number) => void; + labels?: { + previous?: string; + next?: string; + /** Template string; use {from}, {to}, {total} as placeholders */ + showing?: string; + }; +} + +/** + * Generic Prev/Next pagination control with a "Showing X–Y of Z" label. + * Plugin-agnostic — pass localized labels as props. + * Returns null when totalPages ≤ 1. + */ +export function PaginationControls({ + currentPage, + totalPages, + total, + limit, + offset, + onPageChange, + labels, +}: PaginationControlsProps) { + const previous = labels?.previous ?? "Previous"; + const next = labels?.next ?? "Next"; + const showingTemplate = labels?.showing ?? "Showing {from}–{to} of {total}"; + + const from = offset + 1; + const to = Math.min(offset + limit, total); + + const showingText = showingTemplate + .replace("{from}", String(from)) + .replace("{to}", String(to)) + .replace("{total}", String(total)); + + if (totalPages <= 1) { + return null; + } + + return ( + + {showingText} + + onPageChange(currentPage - 1)} + disabled={currentPage === 1} + > + + {previous} + + + {currentPage} / {totalPages} + + onPageChange(currentPage + 1)} + disabled={currentPage === totalPages} + > + {next} + + + + + ); +} diff --git a/packages/ui/src/components/when-visible.tsx b/packages/ui/src/components/when-visible.tsx new file mode 100644 index 00000000..16777c8b --- /dev/null +++ b/packages/ui/src/components/when-visible.tsx @@ -0,0 +1,65 @@ +"use client"; + +import { useEffect, useRef, useState, type ReactNode } from "react"; + +export interface WhenVisibleProps { + /** Content to render once the element scrolls into view */ + children: ReactNode; + /** Optional placeholder rendered before the element enters the viewport */ + fallback?: ReactNode; + /** IntersectionObserver threshold (0–1). Defaults to 0 (any pixel visible). */ + threshold?: number; + /** Root margin passed to IntersectionObserver. Defaults to "200px" to preload slightly early. */ + rootMargin?: string; + /** Additional className applied to the sentinel wrapper div */ + className?: string; +} + +/** + * Lazy-mounts children only when the sentinel element scrolls into the viewport. + * Once mounted, children remain mounted even if the element scrolls out of view. + * + * Use this to defer expensive renders (comment threads, carousels, etc.) until + * the user actually scrolls to that section. + */ +export function WhenVisible({ + children, + fallback = null, + threshold = 0, + rootMargin = "200px", + className, +}: WhenVisibleProps) { + const [isVisible, setIsVisible] = useState(false); + const sentinelRef = useRef(null); + + useEffect(() => { + const el = sentinelRef.current; + if (!el) return; + + // If IntersectionObserver is not available (SSR/old browsers), show immediately + if (typeof IntersectionObserver === "undefined") { + setIsVisible(true); + return; + } + + const observer = new IntersectionObserver( + (entries) => { + const entry = entries[0]; + if (entry?.isIntersecting) { + setIsVisible(true); + observer.disconnect(); + } + }, + { threshold, rootMargin }, + ); + + observer.observe(el); + return () => observer.disconnect(); + }, [threshold, rootMargin]); + + return ( + + {isVisible ? children : fallback} + + ); +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 44f4c2ee..c22c0ac8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -587,9 +587,6 @@ importers: react-hook-form: specifier: '>=7.55.0' version: 7.66.1(react@19.2.0) - react-intersection-observer: - specifier: '>=9.0.0' - version: 10.0.0(react-dom@19.2.0(react@19.2.0))(react@19.2.0) react-markdown: specifier: '>=9.1.0' version: 9.1.0(@types/react@19.2.6)(react@19.2.0) @@ -8301,15 +8298,6 @@ packages: peerDependencies: react: ^19.2.0 - react-intersection-observer@10.0.0: - resolution: {integrity: sha512-JJRgcnFQoVXmbE5+GXr1OS1NDD1gHk0HyfpLcRf0575IbJz+io8yzs4mWVlfaqOQq1FiVjLvuYAdEEcrrCfveg==} - peerDependencies: - react: ^19.2.0 - react-dom: ^17.0.0 || ^18.0.0 || ^19.0.0 - peerDependenciesMeta: - react-dom: - optional: true - react-is@16.13.1: resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} @@ -19305,12 +19293,6 @@ snapshots: dependencies: react: 19.2.0 - react-intersection-observer@10.0.0(react-dom@19.2.0(react@19.2.0))(react@19.2.0): - dependencies: - react: 19.2.0 - optionalDependencies: - react-dom: 19.2.0(react@19.2.0) - react-is@16.13.1: {} react-markdown@9.1.0(@types/react@19.2.6)(react@19.2.0):
\n\t\t\t\t{localization.CMS_LIST_PAGINATION_SHOWING.replace(\n\t\t\t\t\t\"{from}\",\n\t\t\t\t\tString(from),\n\t\t\t\t)\n\t\t\t\t\t.replace(\"{to}\", String(to))\n\t\t\t\t\t.replace(\"{total}\", String(total))}\n\t\t\t
{showingText}
{error}
{body}
\n\t\t\t\t\t{loc.COMMENTS_EMPTY}\n\t\t\t\t
\n\t\t\t\t\t\t{loc.COMMENTS_LOGIN_PROMPT}\n\t\t\t\t\t
\n\t\t\t\t\t{loc.COMMENTS_MODERATION_DESCRIPTION}\n\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\t\t\t\t\t\t\t{comment.body}\n\t\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\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\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\t{viewComment.resourceType}/{viewComment.resourceId}\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
{viewComment.likes}
\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
{viewComment.parentId}
\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\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{loc.COMMENTS_MODERATION_DIALOG_BODY}\n\t\t\t\t\t\t\t\t
{loc.COMMENTS_MY_LOGIN_TITLE}
\n\t\t\t\t\t{loc.COMMENTS_MY_LOGIN_DESCRIPTION}\n\t\t\t\t
{loc.COMMENTS_MY_EMPTY_TITLE}
\n\t\t\t\t\t{loc.COMMENTS_MY_EMPTY_DESCRIPTION}\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
{comment.body}
\n\t\t\t\t\t{resourceType}/{resourceId}\n\t\t\t\t
\n\t\t\t\t\t{comment.body}\n\t\t\t\t
{board.description}
- {localization.CMS_LIST_PAGINATION_SHOWING.replace( - "{from}", - String(from), - ) - .replace("{to}", String(to)) - .replace("{total}", String(total))} -
+ {loc.COMMENTS_EMPTY} +
+ {loc.COMMENTS_LOGIN_PROMPT} +
+ {loc.COMMENTS_MODERATION_DESCRIPTION} +
+ {loc.COMMENTS_MODERATION_EMPTY.replace("{status}", activeTab)} +
+ {comment.body} +
+ {viewComment.resolvedAuthorName} +
+ {new Date(viewComment.createdAt).toLocaleString()} +
+ {loc.COMMENTS_MODERATION_DIALOG_RESOURCE} +
+ {viewComment.resourceType}/{viewComment.resourceId} +
+ {loc.COMMENTS_MODERATION_DIALOG_LIKES} +
+ {loc.COMMENTS_MODERATION_DIALOG_REPLY_TO} +
+ {loc.COMMENTS_MODERATION_DIALOG_EDITED} +
+ {new Date(viewComment.editedAt).toLocaleString()} +
+ {loc.COMMENTS_MODERATION_DIALOG_BODY} +
+ {loc.COMMENTS_MY_LOGIN_DESCRIPTION} +
+ {loc.COMMENTS_MY_EMPTY_DESCRIPTION} +
+ {total} {loc.COMMENTS_MY_COL_COMMENT.toLowerCase()} + {total !== 1 ? "s" : ""} +
+ {resourceType}/{resourceId} +