diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 45ba7628..fcb2be64 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -9,9 +9,10 @@ on: jobs: e2e: + name: E2E (${{ matrix.framework }}) runs-on: ubuntu-latest concurrency: - group: e2e-${{ github.ref }} + group: e2e-${{ matrix.framework }}-${{ github.ref }} cancel-in-progress: true permissions: contents: read @@ -19,6 +20,11 @@ jobs: OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} NODE_OPTIONS: --max-old-space-size=8192 + strategy: + fail-fast: false + matrix: + framework: [nextjs, tanstack, react-router] + steps: - name: Checkout uses: actions/checkout@v4 @@ -26,7 +32,6 @@ jobs: - name: Setup PNPM uses: pnpm/action-setup@v4 - - name: Setup Node.js uses: actions/setup-node@v4 with: @@ -42,8 +47,8 @@ jobs: - name: Build workspace run: pnpm -w build - - name: Run Playwright smoke tests - run: pnpm e2e:smoke + - name: Run Playwright smoke tests (${{ matrix.framework }}) + run: pnpm e2e:smoke:${{ matrix.framework }} env: OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} @@ -51,7 +56,7 @@ jobs: if: always() uses: actions/upload-artifact@v4 with: - name: playwright-report + name: playwright-report-${{ matrix.framework }} path: e2e/playwright-report if-no-files-found: ignore @@ -59,6 +64,6 @@ jobs: if: failure() uses: actions/upload-artifact@v4 with: - name: traces + name: traces-${{ matrix.framework }} path: test-results/**/*.zip if-no-files-found: ignore diff --git a/AGENTS.md b/AGENTS.md index df3b2095..ff44cac0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -588,6 +588,13 @@ export $(cat ../examples/nextjs/.env | xargs) pnpm e2e:smoke ``` +Run for a single framework only (starts only that framework's server): +```bash +pnpm e2e:smoke:nextjs +pnpm e2e:smoke:tanstack +pnpm e2e:smoke:react-router +``` + Run specific test file: ```bash pnpm e2e:smoke -- tests/smoke.chat.spec.ts @@ -605,7 +612,7 @@ The `playwright.config.ts` defines three projects: - `tanstack:memory` - port 3004 - `react-router:memory` - port 3005 -All three web servers start for every test run. Timeout is 300 seconds per server. +By default (`pnpm e2e:smoke`) all three web servers start. Set `BTST_FRAMEWORK=nextjs|tanstack|react-router` (or use the per-framework scripts above) to start only the matching server and run only its tests. The CI workflow uses a matrix to run each framework in a separate parallel job. ### API Key Requirements diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 4370efdb..595bc5fe 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -709,7 +709,7 @@ test.describe("Your Plugin", () => { }) ``` -Run the full E2E suite (requires all three example apps to start): +Run the full E2E suite (starts all three example apps): ```bash cd e2e @@ -717,19 +717,27 @@ export $(cat ../examples/nextjs/.env | xargs) pnpm e2e:smoke ``` +Run against a single framework only (starts only that framework's server — faster): + +```bash +pnpm e2e:smoke:nextjs +pnpm e2e:smoke:tanstack +pnpm e2e:smoke:react-router +``` + Run a single test file: ```bash pnpm e2e:smoke -- tests/smoke.your-plugin.spec.ts ``` -Run against a specific framework: +Run against a specific Playwright project: ```bash pnpm e2e:smoke -- --project="nextjs:memory" ``` -Tests run against three Playwright projects: `nextjs:memory` (port 3003), `tanstack:memory` (3004), `react-router:memory` (3005). +Tests run against three Playwright projects: `nextjs:memory` (port 3003), `tanstack:memory` (3004), `react-router:memory` (3005). In CI, each framework runs as a separate parallel job via a matrix strategy. --- 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..7dcbaad0 --- /dev/null +++ b/docs/content/docs/plugins/comments.mdx @@ -0,0 +1,571 @@ +--- +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({ + apiBaseURL: baseURL, + apiBasePath: "/api/data", + queryClient, + siteBaseURL: baseURL, + siteBasePath: "/pages", + // optional headers + lifecycle hooks: + // headers: { cookie: request.headers.get("cookie") ?? "" }, + // hooks: { + // beforeLoadModeration: async (ctx) => { ... }, + // beforeLoadUserComments: async (ctx) => { ... }, + // onLoadError: async (error, ctx) => { ... }, + // }, + }), + }, + 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 | +| `allowPosting` | `boolean` | `true` | When `false`, the `POST /comments` endpoint is not registered (read-only comments mode). | +| `allowEditing` | `boolean` | `true` | When `false`, the `PATCH /comments/:id` edit endpoint is not registered. | +| `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 when `allowPosting !== false`** | Called before a comment is saved. Must return `{ authorId: string }` derived from the authenticated session. Throw to reject. | +| `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 when `allowPosting !== false`** | 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. | + + +When `allowPosting` is enabled (default), **`onBeforePost` and `resolveCurrentUserId` are both required**. +When `allowPosting: false`, both hooks become optional because `POST /comments` is disabled. + +- `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" }) +``` + +## User Comments Page + +The comments plugin registers a `/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}`, + }, + + onBeforeUserCommentsPageRendered: (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 Factory + +`commentsClientPlugin(config)` accepts: + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `apiBaseURL` | `string` | ✓ | Base URL for API requests (e.g. `https://example.com`). | +| `apiBasePath` | `string` | ✓ | Path prefix where API routes are mounted (e.g. `/api/data`). | +| `siteBaseURL` | `string` | ✓ | Base URL of your site, used for route metadata/canonical URLs. | +| `siteBasePath` | `string` | ✓ | Base path where stack pages are mounted (e.g. `/pages`). | +| `queryClient` | `QueryClient` | ✓ | React Query client instance shared with the stack. | +| `headers` | `Headers` | — | Optional SSR headers for authenticated loader calls. | +| `hooks.beforeLoadModeration` | `(context) => Promise \| void` | — | Called before moderation page loader logic runs. Throw to cancel. | +| `hooks.beforeLoadUserComments` | `(context) => Promise \| void` | — | Called before User Comments page loader logic runs. Throw to cancel. | +| `hooks.onLoadError` | `(error, context) => Promise \| void` | — | Called when a loader hook throws/errors. | + +### Client Plugin Overrides + +Configure the comments plugin behavior from your layout: + +#### `CommentsPluginOverrides` + +| Field | Type | Description | +|-------|------|-------------| +| `localization` | `Partial` | Override any UI string in the plugin. Import `COMMENTS_LOCALIZATION` from `@btst/stack/plugins/comments/client` to see all available keys. | +| `apiBaseURL` | `string` | Base URL for API requests | +| `apiBasePath` | `string` | Path prefix for the API | +| `headers` | `Record` | Optional headers for authenticated plugin API calls. | +| `showAttribution` | `boolean` | Show/hide the "Powered by BTST" attribution on plugin pages (defaults to `true`). | +| `currentUserId` | `string \| (() => string \| undefined \| Promise)` | Authenticated user's ID — used by the User Comments page. Supports async functions for session-based resolution. | +| `loginHref` | `string` | Login route used by comment UIs when user is unauthenticated. | +| `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. | +| `allowPosting` | `boolean` | Hide/show comment form and reply actions globally in `CommentThread` instances (defaults to `true`). | +| `allowEditing` | `boolean` | Hide/show edit affordances globally in `CommentThread` instances (defaults to `true`). | +| `resourceLinks` | `Record string>` | Per-resource-type URL builders for linking comments back to their resource on the User Comments page (e.g. `{ "blog-post": (slug) => "/pages/blog/" + slug }`). The plugin appends `#comments` automatically so the page scrolls to the thread. | +| `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. | +| `onBeforeUserCommentsPageRendered` | hook | Called before rendering the User Comments page. Throw to deny access (e.g. when no session exists). | +| `onRouteRender` | `(routeName, context) => void \| Promise` | Called when a comments route renders. | +| `onRouteError` | `(routeName, error, context) => void \| Promise` | Called when a comments route hits an error. | + +### 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/package.json b/e2e/package.json index ec29abe8..3ec78b7a 100644 --- a/e2e/package.json +++ b/e2e/package.json @@ -6,6 +6,9 @@ "scripts": { "e2e:install": "playwright install --with-deps", "e2e:smoke": "playwright test", + "e2e:smoke:nextjs": "BTST_FRAMEWORK=nextjs playwright test", + "e2e:smoke:tanstack": "BTST_FRAMEWORK=tanstack playwright test", + "e2e:smoke:react-router": "BTST_FRAMEWORK=react-router playwright test", "e2e:ui": "playwright test --ui" }, "devDependencies": { diff --git a/e2e/playwright.config.ts b/e2e/playwright.config.ts index d5d4ba67..15523229 100644 --- a/e2e/playwright.config.ts +++ b/e2e/playwright.config.ts @@ -14,33 +14,21 @@ const reactRouterEnv = config({ path: resolve(__dirname, "../examples/react-router/.env") }) .parsed || {}; -export default defineConfig({ - testDir: "./tests", - timeout: 90_000, - forbidOnly: !!process.env.CI, - outputDir: "../test-results", - reporter: [["list"], ["html", { open: "never" }]], - expect: { - timeout: 10_000, - }, - retries: process.env["CI"] ? 2 : 0, - use: { - trace: "retain-on-failure", - video: "retain-on-failure", - screenshot: "only-on-failure", - actionTimeout: 15_000, - navigationTimeout: 30_000, - baseURL: "http://localhost:3000", - }, - webServer: [ - // Next.js with memory provider and custom plugin - { +// When BTST_FRAMEWORK is set, only the matching webServer and project are +// started — useful for running a single framework locally or in a matrix CI job. +type Framework = "nextjs" | "tanstack" | "react-router"; +const framework = process.env.BTST_FRAMEWORK as Framework | undefined; + +const allWebServers = [ + { + framework: "nextjs" as Framework, + config: { command: "pnpm -F examples/nextjs run start:e2e", port: 3003, reuseExistingServer: !process.env["CI"], timeout: 300_000, - stdout: "pipe", - stderr: "pipe", + stdout: "pipe" as const, + stderr: "pipe" as const, env: { ...process.env, ...nextjsEnv, @@ -50,13 +38,16 @@ export default defineConfig({ NEXT_PUBLIC_BASE_URL: "http://localhost:3003", }, }, - { + }, + { + framework: "tanstack" as Framework, + config: { command: "pnpm -F examples/tanstack run start:e2e", port: 3004, reuseExistingServer: !process.env["CI"], timeout: 300_000, - stdout: "pipe", - stderr: "pipe", + stdout: "pipe" as const, + stderr: "pipe" as const, env: { ...process.env, ...tanstackEnv, @@ -65,13 +56,16 @@ export default defineConfig({ BASE_URL: "http://localhost:3004", }, }, - { + }, + { + framework: "react-router" as Framework, + config: { command: "pnpm -F examples/react-router run start:e2e", port: 3005, reuseExistingServer: !process.env["CI"], timeout: 300_000, - stdout: "pipe", - stderr: "pipe", + stdout: "pipe" as const, + stderr: "pipe" as const, env: { ...process.env, ...reactRouterEnv, @@ -80,9 +74,13 @@ export default defineConfig({ BASE_URL: "http://localhost:3005", }, }, - ], - projects: [ - { + }, +]; + +const allProjects = [ + { + framework: "nextjs" as Framework, + config: { name: "nextjs:memory", fullyParallel: false, workers: 1, @@ -98,12 +96,16 @@ 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", ], }, - { + }, + { + framework: "tanstack" as Framework, + config: { name: "tanstack:memory", fullyParallel: false, workers: 1, @@ -114,10 +116,14 @@ export default defineConfig({ "**/*.cms.spec.ts", "**/*.relations-cms.spec.ts", "**/*.form-builder.spec.ts", + "**/*.comments.spec.ts", "**/*.page-context.spec.ts", ], }, - { + }, + { + framework: "react-router" as Framework, + config: { name: "react-router:memory", fullyParallel: false, workers: 1, @@ -128,8 +134,39 @@ export default defineConfig({ "**/*.cms.spec.ts", "**/*.relations-cms.spec.ts", "**/*.form-builder.spec.ts", + "**/*.comments.spec.ts", "**/*.page-context.spec.ts", ], }, - ], + }, +]; + +const webServers = framework + ? allWebServers.filter((s) => s.framework === framework).map((s) => s.config) + : allWebServers.map((s) => s.config); + +const projects = framework + ? allProjects.filter((p) => p.framework === framework).map((p) => p.config) + : allProjects.map((p) => p.config); + +export default defineConfig({ + testDir: "./tests", + timeout: 90_000, + forbidOnly: !!process.env.CI, + outputDir: "../test-results", + reporter: [["list"], ["html", { open: "never" }]], + expect: { + timeout: 10_000, + }, + retries: process.env["CI"] ? 2 : 0, + use: { + trace: "retain-on-failure", + video: "retain-on-failure", + screenshot: "only-on-failure", + actionTimeout: 15_000, + navigationTimeout: 30_000, + baseURL: "http://localhost:3000", + }, + webServer: webServers, + projects, }); diff --git a/e2e/tests/smoke.comments.spec.ts b/e2e/tests/smoke.comments.spec.ts new file mode 100644 index 00000000..d783401c --- /dev/null +++ b/e2e/tests/smoke.comments.spec.ts @@ -0,0 +1,914 @@ +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("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 + // x-user-id header authenticates the session — own pending comments must be included. + const response = await request.get( + `/api/data/comments?resourceId=${encodeURIComponent(resourceId)}&resourceType=${encodeURIComponent(resourceType)}¤tUserId=${CURRENT_USER_ID}`, + { headers: { "x-user-id": 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 authenticated user should not see this pending comment. + // The query param is spoofed as the author's ID, but the server resolves + // currentUserId from the authenticated header (x-user-id). + const response = await request.get( + `/api/data/comments?resourceId=${encodeURIComponent(resourceId)}&resourceType=${encodeURIComponent(resourceType)}¤tUserId=${CURRENT_USER_ID}`, + { headers: { "x-user-id": "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 (x-user-id header authenticates the session) + const withUserResponse = await request.get( + `/api/data/comments?resourceId=${encodeURIComponent(resourceId)}&resourceType=${encodeURIComponent(resourceType)}&parentId=null¤tUserId=${CURRENT_USER_ID}`, + { headers: { "x-user-id": 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: + // x-user-id header authenticates the session — own pending reply must be included + const response = await request.get( + `/api/data/comments?resourceId=${encodeURIComponent(resourceId)}&resourceType=${encodeURIComponent(resourceType)}&parentId=${encodeURIComponent(parent.id)}¤tUserId=${CURRENT_USER_ID}`, + { headers: { "x-user-id": 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(); + }); +}); + +// ─── 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", { + 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", { + 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", { + 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..c264a844 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,19 @@ 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 +282,18 @@ 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 +309,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 + }, + onBeforeUserCommentsPageRendered: (context) => { + console.log(`[${context.isSSR ? 'SSR' : 'CSR'}] onBeforeUserCommentsPageRendered`); + 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..21047ea7 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,72 @@ 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) + }, + onBeforeEdit: async (commentId, update, ctx) => { + // In production: verify the caller owns the comment they are editing, e.g.: + // 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") + console.log("onBeforeEdit: comment", commentId) + }, + 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 + // Demo only: read from x-user-id header so E2E tests can simulate + // authenticated vs unauthenticated requests independently. + return ctx?.headers?.get?.("x-user-id") ?? null + }, + }), // Kanban plugin for project management boards kanban: kanbanBackendPlugin({ onBeforeListBoards: async (filter, context) => { diff --git a/examples/nextjs/next.config.ts b/examples/nextjs/next.config.ts index 56299e46..041a470a 100644 --- a/examples/nextjs/next.config.ts +++ b/examples/nextjs/next.config.ts @@ -2,6 +2,10 @@ import type { NextConfig } from "next"; const nextConfig: NextConfig = { reactCompiler: false, + experimental: { + turbopackFileSystemCacheForDev: true, + turbopackFileSystemCacheForBuild: true, + }, images: { remotePatterns: [ { diff --git a/examples/nextjs/package.json b/examples/nextjs/package.json index a5cab594..9a4dda95 100644 --- a/examples/nextjs/package.json +++ b/examples/nextjs/package.json @@ -36,7 +36,7 @@ "kysely": "^0.28.0", "lucide-react": "^0.545.0", "mongodb": "^6.0.0", - "next": "16.0.10", + "next": "16.1.6", "next-themes": "^0.4.6", "react": "19.2.0", "react-dom": "19.2.0", @@ -47,13 +47,13 @@ "devDependencies": { "@eslint/eslintrc": "^3", "@tailwindcss/postcss": "^4", + "@tailwindcss/typography": "^0.5.19", "@types/node": "^20", "@types/react": "^19", "@types/react-dom": "^19", "eslint": "^9", "eslint-config-next": "15.5.5", "tailwindcss": "^4", - "@tailwindcss/typography": "^0.5.19", "tw-animate-css": "^1.4.0", "typescript": "catalog:" } 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..17965fb6 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" @@ -16,25 +17,25 @@ import { ProductSchema, TestimonialSchema, CategorySchema, ResourceSchema, Comme const blogHooks: BlogBackendHooks = { onBeforeCreatePost: async (data) => { console.log("onBeforeCreatePost hook called", data.title); - return true; // Allow for now + // throw new Error("Unauthorized") to deny }, onBeforeUpdatePost: async (postId) => { // Example: Check if user owns the post or is admin console.log("onBeforeUpdatePost hook called for post:", postId); - return true; // Allow for now + // throw new Error("Unauthorized") to deny }, onBeforeDeletePost: async (postId) => { // Example: Check if user can delete this post console.log("onBeforeDeletePost hook called for post:", postId); - return true; // Allow for now + // throw new Error("Unauthorized") to deny }, onBeforeListPosts: async (filter) => { // Example: Allow public posts, require auth for drafts if (filter.published === false) { // Check authentication for drafts console.log("onBeforeListPosts: checking auth for drafts"); + // throw new Error("Authentication required") to deny } - return true; // Allow for now }, // Lifecycle hooks - perform actions after operations @@ -148,12 +149,67 @@ const { handler, dbSchema } = stack({ kanban: kanbanBackendPlugin({ onBeforeListBoards: async (filter, context) => { console.log("onBeforeListBoards hook called", filter); - return true; }, onBoardCreated: async (board, context) => { 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 } + }, + onBeforeEdit: async (commentId, update, ctx) => { + // In production: verify the caller owns the comment they are editing, e.g.: + // 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") + console.log("onBeforeEdit: comment", commentId) + }, + 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 + // Demo only: read from x-user-id header so E2E tests can simulate + // authenticated vs unauthenticated requests independently. + return ctx?.headers?.get?.("x-user-id") ?? null + }, + }), }, 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..dc7ccb6a 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,19 @@ 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 +218,40 @@ 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 + }, + onBeforeUserCommentsPageRendered: (context) => { + console.log(`[${context.isSSR ? 'SSR' : 'CSR'}] onBeforeUserCommentsPageRendered`); + 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..a0d84b65 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" @@ -15,25 +16,25 @@ import { ProductSchema, TestimonialSchema, CategorySchema, ResourceSchema, Comme const blogHooks: BlogBackendHooks = { onBeforeCreatePost: async (data) => { console.log("onBeforeCreatePost hook called", data.title); - return true; // Allow for now + // throw new Error("Unauthorized") to deny }, onBeforeUpdatePost: async (postId) => { // Example: Check if user owns the post or is admin console.log("onBeforeUpdatePost hook called for post:", postId); - return true; // Allow for now + // throw new Error("Unauthorized") to deny }, onBeforeDeletePost: async (postId) => { // Example: Check if user can delete this post console.log("onBeforeDeletePost hook called for post:", postId); - return true; // Allow for now + // throw new Error("Unauthorized") to deny }, onBeforeListPosts: async (filter) => { // Example: Allow public posts, require auth for drafts if (filter.published === false) { // Check authentication for drafts console.log("onBeforeListPosts: checking auth for drafts"); + // throw new Error("Authentication required") to deny } - return true; // Allow for now }, // Lifecycle hooks - perform actions after operations @@ -147,12 +148,67 @@ const { handler, dbSchema } = stack({ kanban: kanbanBackendPlugin({ onBeforeListBoards: async (filter, context) => { console.log("onBeforeListBoards hook called", filter); - return true; }, onBoardCreated: async (board, context) => { 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 } + }, + onBeforeEdit: async (commentId, update, ctx) => { + // In production: verify the caller owns the comment they are editing, e.g.: + // 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") + console.log("onBeforeEdit: comment", commentId) + }, + 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 + // Demo only: read from x-user-id header so E2E tests can simulate + // authenticated vs unauthenticated requests independently. + return ctx?.headers?.get?.("x-user-id") ?? null + }, + }), }, 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..f45412f5 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,19 @@ 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 +227,40 @@ 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 + }, + onBeforeUserCommentsPageRendered: (context) => { + console.log(`[${context.isSSR ? 'SSR' : 'CSR'}] onBeforeUserCommentsPageRendered`); + 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/package.json b/package.json index 16ec9a89..57b84652 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,9 @@ "bump": "bumpp", "test": "turbo --filter \"./packages/*\" test", "e2e:smoke": "turbo --filter \"./e2e\" e2e:smoke", + "e2e:smoke:nextjs": "turbo --filter \"./e2e\" e2e:smoke:nextjs", + "e2e:smoke:tanstack": "turbo --filter \"./e2e\" e2e:smoke:tanstack", + "e2e:smoke:react-router": "turbo --filter \"./e2e\" e2e:smoke:react-router", "e2e:integration": "turbo --filter \"./e2e/*\" e2e:integration", "typecheck": "turbo --filter \"./packages/*\" typecheck", "knip": "turbo --filter \"./packages/*\" knip" 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..5e394512 --- /dev/null +++ b/packages/stack/registry/btst-comments.json @@ -0,0 +1,164 @@ +{ + "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\n/**\n * Schema for GET /comments query parameters.\n *\n * `currentUserId` is intentionally absent — it is never accepted from the client.\n * The server always resolves the caller's identity via the `resolveCurrentUserId`\n * hook and injects it internally. Accepting it from the client would allow any\n * anonymous caller to supply an arbitrary user ID and read that user's pending\n * (pre-moderation) comments.\n */\nexport const CommentListQuerySchema = z.object({\n\tresourceId: z.string().optional(),\n\tresourceType: z.string().optional(),\n\tparentId: z.string().optional().nullable(),\n\tstatus: CommentStatusSchema.optional(),\n\tauthorId: z.string().optional(),\n\tsort: z.enum([\"asc\", \"desc\"]).optional(),\n\tlimit: z.coerce.number().int().min(1).max(100).optional(),\n\toffset: z.coerce.number().int().min(0).optional(),\n});\n\n/**\n * Internal params schema used by `listComments()` and the `api` factory.\n * Extends the HTTP query schema with `currentUserId`, which is always injected\n * server-side (either by the HTTP handler via `resolveCurrentUserId`, or by a\n * trusted server-side caller such as a Server Component or cron job).\n */\nexport const CommentListParamsSchema = CommentListQuerySchema.extend({\n\tcurrentUserId: z.string().optional(),\n});\n\nexport const CommentCountQuerySchema = z.object({\n\tresourceId: z.string().min(1),\n\tresourceType: z.string().min(1),\n\tstatus: CommentStatusSchema.optional(),\n});\n", + "target": "src/components/btst/comments/schemas.ts" + }, + { + "path": "btst/comments/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 { useEffect, useState, type ComponentType } from \"react\";\nimport { WhenVisible } from \"@/components/ui/when-visible\";\nimport {\n\tAvatar,\n\tAvatarFallback,\n\tAvatarImage,\n} from \"@/components/ui/avatar\";\nimport { Badge } from \"@/components/ui/badge\";\nimport { Button } from \"@/components/ui/button\";\nimport { Separator } from \"@/components/ui/separator\";\nimport {\n\tHeart,\n\tMessageSquare,\n\tPencil,\n\tX,\n\tLogIn,\n\tChevronDown,\n\tChevronUp,\n} from \"lucide-react\";\nimport { formatDistanceToNow } from \"date-fns\";\nimport type { SerializedComment } from \"../../types\";\nimport { getInitials } from \"../utils\";\nimport { CommentForm } from \"./comment-form\";\nimport {\n\tuseComments,\n\tuseInfiniteComments,\n\tusePostComment,\n\tuseUpdateComment,\n\tuseDeleteComment,\n\tuseToggleLike,\n} from \"@btst/stack/plugins/comments/client/hooks\";\nimport {\n\tCOMMENTS_LOCALIZATION,\n\ttype CommentsLocalization,\n} from \"../localization\";\nimport { usePluginOverrides } from \"@btst/stack/context\";\nimport type { CommentsPluginOverrides } from \"../overrides\";\n\n/** Custom input component props */\nexport interface CommentInputProps {\n\tvalue: string;\n\tonChange: (value: string) => void;\n\tdisabled?: boolean;\n\tplaceholder?: string;\n}\n\n/** Custom renderer component props */\nexport interface CommentRendererProps {\n\tbody: string;\n}\n\n/** Override slot for custom input + renderer */\nexport interface CommentComponents {\n\tInput?: ComponentType;\n\tRenderer?: ComponentType;\n}\n\nexport interface CommentThreadProps {\n\t/** The resource this thread is attached to (e.g. post slug, task ID) */\n\tresourceId: string;\n\t/** Discriminates resources across plugins (e.g. \"blog-post\", \"kanban-task\") */\n\tresourceType: string;\n\t/** Base URL for API calls */\n\tapiBaseURL: string;\n\t/** Path where the API is mounted */\n\tapiBasePath: string;\n\t/** Currently authenticated user ID. Omit for read-only / unauthenticated. */\n\tcurrentUserId?: string;\n\t/**\n\t * URL to redirect unauthenticated users to.\n\t * When provided and currentUserId is absent, shows a \"Please login to comment\" prompt.\n\t */\n\tloginHref?: string;\n\t/** Optional HTTP headers for API calls (e.g. forwarding cookies) */\n\theaders?: HeadersInit;\n\t/** Swap in custom Input / Renderer components */\n\tcomponents?: CommentComponents;\n\t/** Optional className applied to the root wrapper */\n\tclassName?: string;\n\t/** Localization strings — defaults to English */\n\tlocalization?: Partial;\n\t/**\n\t * Number of top-level comments to load per page.\n\t * Clicking \"Load more\" fetches the next page. Default: 10.\n\t */\n\tpageSize?: number;\n\t/**\n\t * When false, the comment form and reply buttons are hidden.\n\t * Overrides the global `allowPosting` from `CommentsPluginOverrides`.\n\t * Defaults to true.\n\t */\n\tallowPosting?: boolean;\n\t/**\n\t * When false, the edit button is hidden on comment cards.\n\t * Overrides the global `allowEditing` from `CommentsPluginOverrides`.\n\t * Defaults to true.\n\t */\n\tallowEditing?: boolean;\n}\n\nconst DEFAULT_RENDERER: ComponentType = ({ body }) => (\n\t{body}\n);\n\n// ─── Comment Card ─────────────────────────────────────────────────────────────\n\nfunction CommentCard({\n\tcomment,\n\tcurrentUserId,\n\tapiBaseURL,\n\tapiBasePath,\n\tresourceId,\n\tresourceType,\n\theaders,\n\tcomponents,\n\tloc,\n\tinfiniteKey,\n\tonReplyClick,\n\tallowPosting,\n\tallowEditing,\n}: {\n\tcomment: SerializedComment;\n\tcurrentUserId?: string;\n\tapiBaseURL: string;\n\tapiBasePath: string;\n\tresourceId: string;\n\tresourceType: string;\n\theaders?: HeadersInit;\n\tcomponents?: CommentComponents;\n\tloc: CommentsLocalization;\n\t/** Infinite thread query key — pass for top-level comments so like optimistic\n\t * updates target the correct InfiniteData cache entry. */\n\tinfiniteKey?: readonly unknown[];\n\tonReplyClick: (parentId: string) => void;\n\tallowPosting: boolean;\n\tallowEditing: boolean;\n}) {\n\tconst [isEditing, setIsEditing] = useState(false);\n\tconst Renderer = components?.Renderer ?? DEFAULT_RENDERER;\n\n\tconst config = { apiBaseURL, apiBasePath, headers };\n\n\tconst updateMutation = useUpdateComment(config);\n\tconst deleteMutation = useDeleteComment(config);\n\tconst toggleLikeMutation = useToggleLike(config, {\n\t\tresourceId,\n\t\tresourceType,\n\t\tparentId: comment.parentId,\n\t\tcurrentUserId,\n\t\tinfiniteKey,\n\t});\n\n\tconst isOwn = currentUserId && comment.authorId === currentUserId;\n\tconst isPending = comment.status === \"pending\";\n\tconst isApproved = comment.status === \"approved\";\n\n\tconst handleEdit = async (body: string) => {\n\t\tawait updateMutation.mutateAsync({ id: comment.id, body });\n\t\tsetIsEditing(false);\n\t};\n\n\tconst handleDelete = async () => {\n\t\tif (!window.confirm(loc.COMMENTS_DELETE_CONFIRM)) return;\n\t\tawait deleteMutation.mutateAsync(comment.id);\n\t};\n\n\tconst handleLike = () => {\n\t\tif (!currentUserId) return;\n\t\ttoggleLikeMutation.mutate({\n\t\t\tcommentId: comment.id,\n\t\t\tauthorId: currentUserId,\n\t\t});\n\t};\n\n\treturn (\n\t\t\n\t\t\t\n\t\t\t\t{comment.resolvedAvatarUrl && (\n\t\t\t\t\t\n\t\t\t\t)}\n\t\t\t\t\n\t\t\t\t\t{getInitials(comment.resolvedAuthorName)}\n\t\t\t\t\n\t\t\t\n\n\t\t\t\n\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\t{comment.resolvedAuthorName}\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\t{formatDistanceToNow(new Date(comment.createdAt), {\n\t\t\t\t\t\t\taddSuffix: true,\n\t\t\t\t\t\t})}\n\t\t\t\t\t\n\t\t\t\t\t{comment.editedAt && (\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t{loc.COMMENTS_EDITED_BADGE}\n\t\t\t\t\t\t\n\t\t\t\t\t)}\n\t\t\t\t\t{isPending && isOwn && (\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t{loc.COMMENTS_PENDING_BADGE}\n\t\t\t\t\t\t\n\t\t\t\t\t)}\n\t\t\t\t\n\n\t\t\t\t{isEditing ? (\n\t\t\t\t\t setIsEditing(false)}\n\t\t\t\t\t/>\n\t\t\t\t) : (\n\t\t\t\t\t\n\t\t\t\t)}\n\n\t\t\t\t{!isEditing && (\n\t\t\t\t\t\n\t\t\t\t\t\t{currentUserId && isApproved && (\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t{comment.likes > 0 && (\n\t\t\t\t\t\t\t\t\t{comment.likes}\n\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t)}\n\n\t\t\t\t\t\t{allowPosting &&\n\t\t\t\t\t\t\tcurrentUserId &&\n\t\t\t\t\t\t\t!comment.parentId &&\n\t\t\t\t\t\t\tisApproved && (\n\t\t\t\t\t\t\t\t onReplyClick(comment.id)}\n\t\t\t\t\t\t\t\t\tdata-testid=\"reply-button\"\n\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t{loc.COMMENTS_REPLY_BUTTON}\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t)}\n\n\t\t\t\t\t\t{isOwn && (\n\t\t\t\t\t\t\t<>\n\t\t\t\t\t\t\t\t{allowEditing && isApproved && (\n\t\t\t\t\t\t\t\t\t setIsEditing(true)}\n\t\t\t\t\t\t\t\t\t\tdata-testid=\"edit-button\"\n\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t{loc.COMMENTS_EDIT_BUTTON}\n\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t{loc.COMMENTS_DELETE_BUTTON}\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t)}\n\t\t\t\t\t\n\t\t\t\t)}\n\t\t\t\n\t\t\n\t);\n}\n\n// ─── Thread Inner (handles data) ──────────────────────────────────────────────\n\nconst DEFAULT_PAGE_SIZE = 100;\nconst REPLIES_PAGE_SIZE = 20;\nconst OPTIMISTIC_ID_PREFIX = \"optimistic-\";\n\nfunction CommentThreadInner({\n\tresourceId,\n\tresourceType,\n\tapiBaseURL,\n\tapiBasePath,\n\tcurrentUserId,\n\tloginHref,\n\theaders,\n\tcomponents,\n\tlocalization: localizationProp,\n\tpageSize: pageSizeProp,\n\tallowPosting: allowPostingProp,\n\tallowEditing: allowEditingProp,\n}: CommentThreadProps) {\n\tconst overrides = usePluginOverrides<\n\t\tCommentsPluginOverrides,\n\t\tPartial\n\t>(\"comments\", {});\n\tconst pageSize =\n\t\tpageSizeProp ?? overrides.defaultCommentPageSize ?? DEFAULT_PAGE_SIZE;\n\tconst allowPosting = allowPostingProp ?? overrides.allowPosting ?? true;\n\tconst allowEditing = allowEditingProp ?? overrides.allowEditing ?? true;\n\tconst loc = { ...COMMENTS_LOCALIZATION, ...localizationProp };\n\tconst [replyingTo, setReplyingTo] = useState(null);\n\tconst [expandedReplies, setExpandedReplies] = useState>(\n\t\tnew Set(),\n\t);\n\tconst [replyOffsets, setReplyOffsets] = useState>({});\n\n\tconst config = { apiBaseURL, apiBasePath, headers };\n\n\tconst {\n\t\tcomments,\n\t\ttotal,\n\t\tisLoading,\n\t\tloadMore,\n\t\thasMore,\n\t\tisLoadingMore,\n\t\tqueryKey: threadQueryKey,\n\t} = useInfiniteComments(config, {\n\t\tresourceId,\n\t\tresourceType,\n\t\tstatus: \"approved\",\n\t\tparentId: null,\n\t\tcurrentUserId,\n\t\tpageSize,\n\t});\n\n\tconst postMutation = usePostComment(config, {\n\t\tresourceId,\n\t\tresourceType,\n\t\tcurrentUserId,\n\t\tinfiniteKey: threadQueryKey,\n\t\tpageSize,\n\t});\n\n\tconst handlePost = async (body: string) => {\n\t\tif (!currentUserId) return;\n\t\tawait postMutation.mutateAsync({\n\t\t\tbody,\n\t\t\tparentId: null,\n\t\t});\n\t};\n\n\tconst handleReply = async (body: string, parentId: string) => {\n\t\tif (!currentUserId) return;\n\t\tawait postMutation.mutateAsync({\n\t\t\tbody,\n\t\t\tparentId,\n\t\t\tlimit: REPLIES_PAGE_SIZE,\n\t\t\toffset: replyOffsets[parentId] ?? 0,\n\t\t});\n\t\tsetReplyingTo(null);\n\t\tsetExpandedReplies((prev) => new Set(prev).add(parentId));\n\t};\n\n\treturn (\n\t\t\n\t\t\t\n\t\t\t\t\n\t\t\t\t\n\t\t\t\t\t{total === 0 ? loc.COMMENTS_TITLE : `${total} ${loc.COMMENTS_TITLE}`}\n\t\t\t\t\n\t\t\t\n\n\t\t\t{isLoading && (\n\t\t\t\t\n\t\t\t\t\t{[1, 2].map((i) => (\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t))}\n\t\t\t\t\n\t\t\t)}\n\n\t\t\t{!isLoading && comments.length > 0 && (\n\t\t\t\t\n\t\t\t\t\t{comments.map((comment) => (\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t {\n\t\t\t\t\t\t\t\t\tsetReplyingTo(replyingTo === parentId ? null : parentId);\n\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\tallowPosting={allowPosting}\n\t\t\t\t\t\t\t\tallowEditing={allowEditing}\n\t\t\t\t\t\t\t/>\n\n\t\t\t\t\t\t\t{/* Replies */}\n\t\t\t\t\t\t\t {\n\t\t\t\t\t\t\t\t\tconst isExpanded = expandedReplies.has(comment.id);\n\t\t\t\t\t\t\t\t\tif (!isExpanded) {\n\t\t\t\t\t\t\t\t\t\tsetReplyOffsets((prev) => {\n\t\t\t\t\t\t\t\t\t\t\tif ((prev[comment.id] ?? 0) === 0) return prev;\n\t\t\t\t\t\t\t\t\t\t\treturn { ...prev, [comment.id]: 0 };\n\t\t\t\t\t\t\t\t\t\t});\n\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\tsetExpandedReplies((prev) => {\n\t\t\t\t\t\t\t\t\t\tconst next = new Set(prev);\n\t\t\t\t\t\t\t\t\t\tnext.has(comment.id)\n\t\t\t\t\t\t\t\t\t\t\t? next.delete(comment.id)\n\t\t\t\t\t\t\t\t\t\t\t: next.add(comment.id);\n\t\t\t\t\t\t\t\t\t\treturn next;\n\t\t\t\t\t\t\t\t\t});\n\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\tonOffsetChange={(offset) => {\n\t\t\t\t\t\t\t\t\tsetReplyOffsets((prev) => {\n\t\t\t\t\t\t\t\t\t\tif (prev[comment.id] === offset) return prev;\n\t\t\t\t\t\t\t\t\t\treturn { ...prev, [comment.id]: offset };\n\t\t\t\t\t\t\t\t\t});\n\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\tallowEditing={allowEditing}\n\t\t\t\t\t\t\t/>\n\n\t\t\t\t\t\t\t{allowPosting && replyingTo === comment.id && currentUserId && (\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t handleReply(body, comment.id)}\n\t\t\t\t\t\t\t\t\t\tonCancel={() => setReplyingTo(null)}\n\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\n\t\t\t\t\t))}\n\t\t\t\t\n\t\t\t)}\n\n\t\t\t{!isLoading && comments.length === 0 && (\n\t\t\t\t\n\t\t\t\t\t{loc.COMMENTS_EMPTY}\n\t\t\t\t\n\t\t\t)}\n\n\t\t\t{hasMore && (\n\t\t\t\t\n\t\t\t\t\t loadMore()}\n\t\t\t\t\t\tdisabled={isLoadingMore}\n\t\t\t\t\t\tdata-testid=\"load-more-comments\"\n\t\t\t\t\t>\n\t\t\t\t\t\t{isLoadingMore ? loc.COMMENTS_LOADING_MORE : loc.COMMENTS_LOAD_MORE}\n\t\t\t\t\t\n\t\t\t\t\n\t\t\t)}\n\n\t\t\t{allowPosting && (\n\t\t\t\t<>\n\t\t\t\t\t\n\n\t\t\t\t\t{currentUserId ? (\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t) : (\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t{loc.COMMENTS_LOGIN_PROMPT}\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t{loginHref && (\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t{loc.COMMENTS_LOGIN_LINK}\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\n\t\t\t\t\t)}\n\t\t\t\t>\n\t\t\t)}\n\t\t\n\t);\n}\n\n// ─── Replies Section ───────────────────────────────────────────────────────────\n\nfunction RepliesSection({\n\tparentId,\n\tresourceId,\n\tresourceType,\n\tapiBaseURL,\n\tapiBasePath,\n\tcurrentUserId,\n\theaders,\n\tcomponents,\n\tloc,\n\texpanded,\n\treplyCount,\n\tonToggle,\n\tonOffsetChange,\n\tallowEditing,\n}: {\n\tparentId: string;\n\tresourceId: string;\n\tresourceType: string;\n\tapiBaseURL: string;\n\tapiBasePath: string;\n\tcurrentUserId?: string;\n\theaders?: HeadersInit;\n\tcomponents?: CommentComponents;\n\tloc: CommentsLocalization;\n\texpanded: boolean;\n\t/** Pre-computed from the parent comment — avoids an extra fetch on mount. */\n\treplyCount: number;\n\tonToggle: () => void;\n\tonOffsetChange: (offset: number) => void;\n\tallowEditing: boolean;\n}) {\n\tconst config = { apiBaseURL, apiBasePath, headers };\n\tconst [replyOffset, setReplyOffset] = useState(0);\n\tconst [loadedReplies, setLoadedReplies] = useState([]);\n\t// Only fetch reply bodies once the section is expanded.\n\tconst {\n\t\tcomments: repliesPage,\n\t\ttotal: repliesTotal,\n\t\tisFetching: isFetchingReplies,\n\t} = useComments(\n\t\tconfig,\n\t\t{\n\t\t\tresourceId,\n\t\t\tresourceType,\n\t\t\tparentId,\n\t\t\tstatus: \"approved\",\n\t\t\tcurrentUserId,\n\t\t\tlimit: REPLIES_PAGE_SIZE,\n\t\t\toffset: replyOffset,\n\t\t},\n\t\t{ enabled: expanded },\n\t);\n\n\tuseEffect(() => {\n\t\tif (expanded) {\n\t\t\tsetReplyOffset(0);\n\t\t\tsetLoadedReplies([]);\n\t\t}\n\t}, [expanded, parentId]);\n\n\tuseEffect(() => {\n\t\tonOffsetChange(replyOffset);\n\t}, [onOffsetChange, replyOffset]);\n\n\tuseEffect(() => {\n\t\tif (!expanded) return;\n\t\tsetLoadedReplies((prev) => {\n\t\t\tconst byId = new Map(prev.map((item) => [item.id, item]));\n\t\t\tfor (const reply of repliesPage) {\n\t\t\t\tbyId.set(reply.id, reply);\n\t\t\t}\n\n\t\t\t// Reconcile optimistic replies once the real server reply arrives with\n\t\t\t// a different id. Without this, both entries can persist in local state\n\t\t\t// until the section is collapsed and re-opened.\n\t\t\tconst currentPageIds = new Set(repliesPage.map((reply) => reply.id));\n\t\t\tconst currentPageRealReplies = repliesPage.filter(\n\t\t\t\t(reply) => !reply.id.startsWith(OPTIMISTIC_ID_PREFIX),\n\t\t\t);\n\n\t\t\treturn Array.from(byId.values()).filter((reply) => {\n\t\t\t\tif (!reply.id.startsWith(OPTIMISTIC_ID_PREFIX)) return true;\n\t\t\t\t// Keep optimistic items still present in the current cache page.\n\t\t\t\tif (currentPageIds.has(reply.id)) return true;\n\t\t\t\t// Drop stale optimistic rows that have been replaced by a real reply.\n\t\t\t\treturn !currentPageRealReplies.some(\n\t\t\t\t\t(realReply) =>\n\t\t\t\t\t\trealReply.parentId === reply.parentId &&\n\t\t\t\t\t\trealReply.authorId === reply.authorId &&\n\t\t\t\t\t\trealReply.body === reply.body,\n\t\t\t\t);\n\t\t\t});\n\t\t});\n\t}, [expanded, repliesPage]);\n\n\t// Hide when there are no known replies — but keep rendered when already\n\t// expanded so a freshly-posted first reply (which increments replyCount\n\t// only after the server responds) stays visible in the same session.\n\tif (replyCount === 0 && !expanded) return null;\n\n\t// Prefer the fetched count (accurate after optimistic inserts); fall back to\n\t// the server-provided replyCount before the fetch completes.\n\tconst displayCount = expanded\n\t\t? loadedReplies.length || replyCount\n\t\t: replyCount;\n\tconst effectiveReplyTotal = repliesTotal || replyCount;\n\tconst hasMoreReplies = loadedReplies.length < effectiveReplyTotal;\n\n\treturn (\n\t\t\n\t\t\t{/* Toggle button — always at the top so collapse is reachable without scrolling */}\n\t\t\t\n\t\t\t\t{expanded ? (\n\t\t\t\t\t\n\t\t\t\t) : (\n\t\t\t\t\t\n\t\t\t\t)}\n\t\t\t\t{expanded\n\t\t\t\t\t? loc.COMMENTS_HIDE_REPLIES\n\t\t\t\t\t: `${displayCount} ${displayCount === 1 ? loc.COMMENTS_REPLIES_SINGULAR : loc.COMMENTS_REPLIES_PLURAL}`}\n\t\t\t\n\t\t\t{expanded && (\n\t\t\t\t\n\t\t\t\t\t{loadedReplies.map((reply) => (\n\t\t\t\t\t\t {}} // No nested replies in v1\n\t\t\t\t\t\t\tallowPosting={false}\n\t\t\t\t\t\t\tallowEditing={allowEditing}\n\t\t\t\t\t\t/>\n\t\t\t\t\t))}\n\t\t\t\t\t{hasMoreReplies && (\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\tsetReplyOffset((prev) => prev + REPLIES_PAGE_SIZE)\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\tdisabled={isFetchingReplies}\n\t\t\t\t\t\t\t\tdata-testid=\"load-more-replies\"\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t{isFetchingReplies\n\t\t\t\t\t\t\t\t\t? loc.COMMENTS_LOADING_MORE\n\t\t\t\t\t\t\t\t\t: loc.COMMENTS_LOAD_MORE}\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t)}\n\t\t\t\t\n\t\t\t)}\n\t\t\n\t);\n}\n\n// ─── Public export: lazy-mounts on scroll into view ───────────────────────────\n\n/**\n * Embeddable threaded comment section.\n *\n * Lazy-mounts when the component scrolls into the viewport (via WhenVisible).\n * Requires `currentUserId` to allow posting; shows a \"Please login\" prompt otherwise.\n *\n * @example\n * ```tsx\n * \n * ```\n */\nfunction CommentThreadSkeleton() {\n\treturn (\n\t\t\n\t\t\t{/* Header */}\n\t\t\t\n\t\t\t\t\n\t\t\t\t\n\t\t\t\n\n\t\t\t{/* Comment rows */}\n\t\t\t{[1, 2, 3].map((i) => (\n\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\n\t\t\t))}\n\n\t\t\t{/* Separator */}\n\t\t\t\n\n\t\t\t{/* Textarea placeholder */}\n\t\t\t\n\t\t\t\t\n\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\n\t\t\t\n\t\t\n\t);\n}\n\nexport function CommentThread(props: CommentThreadProps) {\n\treturn (\n\t\t\n\t\t\t} rootMargin=\"300px\">\n\t\t\t\t\n\t\t\t\n\t\t\n\t);\n}\n", + "target": "src/components/btst/comments/client/components/comment-thread.tsx" + }, + { + "path": "btst/comments/client/components/pages/moderation-page.internal.tsx", + "type": "registry:component", + "content": "\"use client\";\n\nimport { useState } from \"react\";\nimport {\n\tTable,\n\tTableBody,\n\tTableCell,\n\tTableHead,\n\tTableHeader,\n\tTableRow,\n} from \"@/components/ui/table\";\nimport {\n\tDialog,\n\tDialogContent,\n\tDialogHeader,\n\tDialogTitle,\n} from \"@/components/ui/dialog\";\nimport {\n\tAlertDialog,\n\tAlertDialogAction,\n\tAlertDialogCancel,\n\tAlertDialogContent,\n\tAlertDialogDescription,\n\tAlertDialogFooter,\n\tAlertDialogHeader,\n\tAlertDialogTitle,\n} from \"@/components/ui/alert-dialog\";\nimport { Button } from \"@/components/ui/button\";\nimport { Badge } from \"@/components/ui/badge\";\nimport { Tabs, TabsList, TabsTrigger } from \"@/components/ui/tabs\";\nimport { Checkbox } from \"@/components/ui/checkbox\";\nimport {\n\tAvatar,\n\tAvatarFallback,\n\tAvatarImage,\n} from \"@/components/ui/avatar\";\nimport { CheckCircle, ShieldOff, Trash2, Eye } from \"lucide-react\";\nimport { toast } from \"sonner\";\nimport { formatDistanceToNow } from \"date-fns\";\nimport { useRegisterPageAIContext } from \"@btst/stack/plugins/ai-chat/client/context\";\nimport type { SerializedComment, CommentStatus } from \"../../../types\";\nimport {\n\tuseSuspenseModerationComments,\n\tuseUpdateCommentStatus,\n\tuseDeleteComment,\n} from \"@btst/stack/plugins/comments/client/hooks\";\nimport {\n\tCOMMENTS_LOCALIZATION,\n\ttype CommentsLocalization,\n} from \"../../localization\";\nimport { getInitials } from \"../../utils\";\nimport { Pagination } from \"../shared/pagination\";\n\ninterface ModerationPageProps {\n\tapiBaseURL: string;\n\tapiBasePath: string;\n\theaders?: HeadersInit;\n\tlocalization?: CommentsLocalization;\n}\n\nfunction StatusBadge({ status }: { status: CommentStatus }) {\n\tconst variants: Record<\n\t\tCommentStatus,\n\t\t\"secondary\" | \"default\" | \"destructive\"\n\t> = {\n\t\tpending: \"secondary\",\n\t\tapproved: \"default\",\n\t\tspam: \"destructive\",\n\t};\n\treturn {status};\n}\n\nexport function ModerationPage({\n\tapiBaseURL,\n\tapiBasePath,\n\theaders,\n\tlocalization: localizationProp,\n}: ModerationPageProps) {\n\tconst loc = { ...COMMENTS_LOCALIZATION, ...localizationProp };\n\tconst [activeTab, setActiveTab] = useState(\"pending\");\n\tconst [currentPage, setCurrentPage] = useState(1);\n\tconst [selected, setSelected] = useState>(new Set());\n\tconst [viewComment, setViewComment] = useState(\n\t\tnull,\n\t);\n\tconst [deleteIds, setDeleteIds] = useState([]);\n\n\tconst config = { apiBaseURL, apiBasePath, headers };\n\n\tconst { comments, total, limit, offset, totalPages, refetch } =\n\t\tuseSuspenseModerationComments(config, {\n\t\t\tstatus: activeTab,\n\t\t\tpage: currentPage,\n\t\t});\n\n\tconst updateStatus = useUpdateCommentStatus(config);\n\tconst deleteMutation = useDeleteComment(config);\n\n\t// Register AI context with pending comment previews\n\tuseRegisterPageAIContext({\n\t\trouteName: \"comments-moderation\",\n\t\tpageDescription: `${total} ${activeTab} comments in the moderation queue.\\n\\nTop ${activeTab} comments:\\n${comments\n\t\t\t.slice(0, 5)\n\t\t\t.map(\n\t\t\t\t(c) =>\n\t\t\t\t\t`- \"${c.body.slice(0, 80)}${c.body.length > 80 ? \"…\" : \"\"}\" by ${c.resolvedAuthorName} on ${c.resourceType}/${c.resourceId}`,\n\t\t\t)\n\t\t\t.join(\"\\n\")}`,\n\t\tsuggestions: [\n\t\t\t\"Approve all safe-looking comments\",\n\t\t\t\"Flag spam comments\",\n\t\t\t\"Summarize today's discussion\",\n\t\t],\n\t});\n\n\tconst toggleSelect = (id: string) => {\n\t\tsetSelected((prev) => {\n\t\t\tconst next = new Set(prev);\n\t\t\tnext.has(id) ? next.delete(id) : next.add(id);\n\t\t\treturn next;\n\t\t});\n\t};\n\n\tconst toggleSelectAll = () => {\n\t\tif (selected.size === comments.length) {\n\t\t\tsetSelected(new Set());\n\t\t} else {\n\t\t\tsetSelected(new Set(comments.map((c) => c.id)));\n\t\t}\n\t};\n\n\tconst handleApprove = async (id: string) => {\n\t\ttry {\n\t\t\tawait updateStatus.mutateAsync({ id, status: \"approved\" });\n\t\t\ttoast.success(loc.COMMENTS_MODERATION_TOAST_APPROVED);\n\t\t\tawait refetch();\n\t\t} catch {\n\t\t\ttoast.error(loc.COMMENTS_MODERATION_TOAST_APPROVE_ERROR);\n\t\t}\n\t};\n\n\tconst handleSpam = async (id: string) => {\n\t\ttry {\n\t\t\tawait updateStatus.mutateAsync({ id, status: \"spam\" });\n\t\t\ttoast.success(loc.COMMENTS_MODERATION_TOAST_SPAM);\n\t\t\tawait refetch();\n\t\t} catch {\n\t\t\ttoast.error(loc.COMMENTS_MODERATION_TOAST_SPAM_ERROR);\n\t\t}\n\t};\n\n\tconst handleDelete = async (ids: string[]) => {\n\t\ttry {\n\t\t\tawait Promise.all(ids.map((id) => deleteMutation.mutateAsync(id)));\n\t\t\ttoast.success(\n\t\t\t\tids.length === 1\n\t\t\t\t\t? loc.COMMENTS_MODERATION_TOAST_DELETED\n\t\t\t\t\t: loc.COMMENTS_MODERATION_TOAST_DELETED_PLURAL.replace(\n\t\t\t\t\t\t\t\"{n}\",\n\t\t\t\t\t\t\tString(ids.length),\n\t\t\t\t\t\t),\n\t\t\t);\n\t\t\tsetSelected(new Set());\n\t\t\tsetDeleteIds([]);\n\t\t\tawait refetch();\n\t\t} catch {\n\t\t\ttoast.error(loc.COMMENTS_MODERATION_TOAST_DELETE_ERROR);\n\t\t}\n\t};\n\n\tconst handleBulkApprove = async () => {\n\t\tconst ids = [...selected];\n\t\ttry {\n\t\t\tawait Promise.all(\n\t\t\t\tids.map((id) => updateStatus.mutateAsync({ id, status: \"approved\" })),\n\t\t\t);\n\t\t\ttoast.success(\n\t\t\t\tloc.COMMENTS_MODERATION_TOAST_BULK_APPROVED.replace(\n\t\t\t\t\t\"{n}\",\n\t\t\t\t\tString(ids.length),\n\t\t\t\t),\n\t\t\t);\n\t\t\tsetSelected(new Set());\n\t\t\tawait refetch();\n\t\t} catch {\n\t\t\ttoast.error(loc.COMMENTS_MODERATION_TOAST_BULK_APPROVE_ERROR);\n\t\t}\n\t};\n\n\treturn (\n\t\t\n\t\t\t\n\t\t\t\t{loc.COMMENTS_MODERATION_TITLE}\n\t\t\t\t\n\t\t\t\t\t{loc.COMMENTS_MODERATION_DESCRIPTION}\n\t\t\t\t\n\t\t\t\n\n\t\t\t {\n\t\t\t\t\tsetActiveTab(v as CommentStatus);\n\t\t\t\t\tsetCurrentPage(1);\n\t\t\t\t\tsetSelected(new Set());\n\t\t\t\t}}\n\t\t\t>\n\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\t{loc.COMMENTS_MODERATION_TAB_PENDING}\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\t{loc.COMMENTS_MODERATION_TAB_APPROVED}\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\t{loc.COMMENTS_MODERATION_TAB_SPAM}\n\t\t\t\t\t\n\t\t\t\t\n\t\t\t\n\n\t\t\t{/* Bulk actions toolbar */}\n\t\t\t{selected.size > 0 && (\n\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\t{loc.COMMENTS_MODERATION_SELECTED.replace(\n\t\t\t\t\t\t\t\"{n}\",\n\t\t\t\t\t\t\tString(selected.size),\n\t\t\t\t\t\t)}\n\t\t\t\t\t\n\t\t\t\t\t{activeTab !== \"approved\" && (\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t{loc.COMMENTS_MODERATION_APPROVE_SELECTED}\n\t\t\t\t\t\t\n\t\t\t\t\t)}\n\t\t\t\t\t setDeleteIds([...selected])}\n\t\t\t\t\t>\n\t\t\t\t\t\t\n\t\t\t\t\t\t{loc.COMMENTS_MODERATION_DELETE_SELECTED}\n\t\t\t\t\t\n\t\t\t\t\n\t\t\t)}\n\n\t\t\t{comments.length === 0 ? (\n\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\t{loc.COMMENTS_MODERATION_EMPTY.replace(\"{status}\", activeTab)}\n\t\t\t\t\t\n\t\t\t\t\n\t\t\t) : (\n\t\t\t\t<>\n\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t 0\n\t\t\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\t\t\tonCheckedChange={toggleSelectAll}\n\t\t\t\t\t\t\t\t\t\t\taria-label={loc.COMMENTS_MODERATION_SELECT_ALL}\n\t\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t{loc.COMMENTS_MODERATION_COL_AUTHOR}\n\t\t\t\t\t\t\t\t\t{loc.COMMENTS_MODERATION_COL_COMMENT}\n\t\t\t\t\t\t\t\t\t{loc.COMMENTS_MODERATION_COL_RESOURCE}\n\t\t\t\t\t\t\t\t\t{loc.COMMENTS_MODERATION_COL_DATE}\n\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t{loc.COMMENTS_MODERATION_COL_ACTIONS}\n\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t{comments.map((comment) => (\n\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t toggleSelect(comment.id)}\n\t\t\t\t\t\t\t\t\t\t\t\taria-label={loc.COMMENTS_MODERATION_SELECT_ONE}\n\t\t\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t\t\t{comment.resolvedAvatarUrl && (\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t{getInitials(comment.resolvedAuthorName)}\n\t\t\t\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t\t\t{comment.resolvedAuthorName}\n\t\t\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t\t{comment.body}\n\t\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t\t{comment.resourceType}/{comment.resourceId}\n\t\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t{formatDistanceToNow(new Date(comment.createdAt), {\n\t\t\t\t\t\t\t\t\t\t\t\taddSuffix: true,\n\t\t\t\t\t\t\t\t\t\t\t})}\n\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t\t setViewComment(comment)}\n\t\t\t\t\t\t\t\t\t\t\t\t\tdata-testid=\"view-button\"\n\t\t\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t\t{activeTab !== \"approved\" && (\n\t\t\t\t\t\t\t\t\t\t\t\t\t handleApprove(comment.id)}\n\t\t\t\t\t\t\t\t\t\t\t\t\t\tdisabled={updateStatus.isPending}\n\t\t\t\t\t\t\t\t\t\t\t\t\t\tdata-testid=\"approve-button\"\n\t\t\t\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t\t\t\t\t{activeTab !== \"spam\" && (\n\t\t\t\t\t\t\t\t\t\t\t\t\t handleSpam(comment.id)}\n\t\t\t\t\t\t\t\t\t\t\t\t\t\tdisabled={updateStatus.isPending}\n\t\t\t\t\t\t\t\t\t\t\t\t\t\tdata-testid=\"spam-button\"\n\t\t\t\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t\t\t\t\t setDeleteIds([comment.id])}\n\t\t\t\t\t\t\t\t\t\t\t\t\tdata-testid=\"delete-button\"\n\t\t\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t))}\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t>\n\t\t\t)}\n\n\t\t\t{/* View comment dialog */}\n\t\t\t setViewComment(null)}>\n\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\t{loc.COMMENTS_MODERATION_DIALOG_TITLE}\n\t\t\t\t\t\n\t\t\t\t\t{viewComment && (\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t{viewComment.resolvedAvatarUrl && (\n\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t{getInitials(viewComment.resolvedAuthorName)}\n\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t{viewComment.resolvedAuthorName}\n\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t{new Date(viewComment.createdAt).toLocaleString()}\n\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t{loc.COMMENTS_MODERATION_DIALOG_RESOURCE}\n\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t{viewComment.resourceType}/{viewComment.resourceId}\n\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t{loc.COMMENTS_MODERATION_DIALOG_LIKES}\n\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t{viewComment.likes}\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t{viewComment.parentId && (\n\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t{loc.COMMENTS_MODERATION_DIALOG_REPLY_TO}\n\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t{viewComment.parentId}\n\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t{viewComment.editedAt && (\n\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t{loc.COMMENTS_MODERATION_DIALOG_EDITED}\n\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t{new Date(viewComment.editedAt).toLocaleString()}\n\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\n\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t{loc.COMMENTS_MODERATION_DIALOG_BODY}\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t{viewComment.body}\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t{viewComment.status !== \"approved\" && (\n\t\t\t\t\t\t\t\t\t {\n\t\t\t\t\t\t\t\t\t\t\tawait handleApprove(viewComment.id);\n\t\t\t\t\t\t\t\t\t\t\tsetViewComment(null);\n\t\t\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t\t\tdisabled={updateStatus.isPending}\n\t\t\t\t\t\t\t\t\t\tdata-testid=\"dialog-approve-button\"\n\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t{loc.COMMENTS_MODERATION_DIALOG_APPROVE}\n\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t{viewComment.status !== \"spam\" && (\n\t\t\t\t\t\t\t\t\t {\n\t\t\t\t\t\t\t\t\t\t\tawait handleSpam(viewComment.id);\n\t\t\t\t\t\t\t\t\t\t\tsetViewComment(null);\n\t\t\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t\t\tdisabled={updateStatus.isPending}\n\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t{loc.COMMENTS_MODERATION_DIALOG_MARK_SPAM}\n\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t {\n\t\t\t\t\t\t\t\t\t\tsetDeleteIds([viewComment.id]);\n\t\t\t\t\t\t\t\t\t\tsetViewComment(null);\n\t\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t{loc.COMMENTS_MODERATION_DIALOG_DELETE}\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t)}\n\t\t\t\t\n\t\t\t\n\n\t\t\t{/* Delete confirmation dialog */}\n\t\t\t 0}\n\t\t\t\tonOpenChange={(open) => !open && setDeleteIds([])}\n\t\t\t>\n\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t{deleteIds.length === 1\n\t\t\t\t\t\t\t\t? loc.COMMENTS_MODERATION_DELETE_TITLE_SINGULAR\n\t\t\t\t\t\t\t\t: loc.COMMENTS_MODERATION_DELETE_TITLE_PLURAL.replace(\n\t\t\t\t\t\t\t\t\t\t\"{n}\",\n\t\t\t\t\t\t\t\t\t\tString(deleteIds.length),\n\t\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t{deleteIds.length === 1\n\t\t\t\t\t\t\t\t? loc.COMMENTS_MODERATION_DELETE_DESCRIPTION_SINGULAR\n\t\t\t\t\t\t\t\t: loc.COMMENTS_MODERATION_DELETE_DESCRIPTION_PLURAL}\n\t\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t{loc.COMMENTS_MODERATION_DELETE_CANCEL}\n\t\t\t\t\t\t\n\t\t\t\t\t\t handleDelete(deleteIds)}\n\t\t\t\t\t\t\tdata-testid=\"confirm-delete-button\"\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t{deleteMutation.isPending\n\t\t\t\t\t\t\t\t? loc.COMMENTS_MODERATION_DELETE_DELETING\n\t\t\t\t\t\t\t\t: loc.COMMENTS_MODERATION_DELETE_CONFIRM}\n\t\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\n\t\t\t\n\t\t\n\t);\n}\n", + "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 } from \"react\";\nimport {\n\tTable,\n\tTableBody,\n\tTableCell,\n\tTableHead,\n\tTableHeader,\n\tTableRow,\n} from \"@/components/ui/table\";\nimport {\n\tAlertDialog,\n\tAlertDialogAction,\n\tAlertDialogCancel,\n\tAlertDialogContent,\n\tAlertDialogDescription,\n\tAlertDialogFooter,\n\tAlertDialogHeader,\n\tAlertDialogTitle,\n} from \"@/components/ui/alert-dialog\";\nimport { Button } from \"@/components/ui/button\";\nimport { Badge } from \"@/components/ui/badge\";\nimport {\n\tAvatar,\n\tAvatarFallback,\n\tAvatarImage,\n} from \"@/components/ui/avatar\";\nimport { Trash2, ExternalLink, LogIn, MessageSquareOff } from \"lucide-react\";\nimport { toast } from \"sonner\";\nimport { formatDistanceToNow } from \"date-fns\";\nimport type { CommentsPluginOverrides } from \"../../overrides\";\nimport { PaginationControls } from \"@/components/ui/pagination-controls\";\nimport type { SerializedComment, CommentStatus } from \"../../../types\";\nimport {\n\tuseSuspenseComments,\n\tuseDeleteComment,\n} from \"@btst/stack/plugins/comments/client/hooks\";\nimport {\n\tCOMMENTS_LOCALIZATION,\n\ttype CommentsLocalization,\n} from \"../../localization\";\nimport { getInitials, useResolvedCurrentUserId } from \"../../utils\";\n\nconst PAGE_LIMIT = 20;\n\ninterface UserCommentsPageProps {\n\tapiBaseURL: string;\n\tapiBasePath: string;\n\theaders?: HeadersInit;\n\tcurrentUserId?: CommentsPluginOverrides[\"currentUserId\"];\n\tresourceLinks?: CommentsPluginOverrides[\"resourceLinks\"];\n\tlocalization?: CommentsLocalization;\n}\n\nfunction StatusBadge({\n\tstatus,\n\tloc,\n}: {\n\tstatus: CommentStatus;\n\tloc: CommentsLocalization;\n}) {\n\tif (status === \"approved\") {\n\t\treturn (\n\t\t\t\n\t\t\t\t{loc.COMMENTS_MY_STATUS_APPROVED}\n\t\t\t\n\t\t);\n\t}\n\tif (status === \"pending\") {\n\t\treturn (\n\t\t\t\n\t\t\t\t{loc.COMMENTS_MY_STATUS_PENDING}\n\t\t\t\n\t\t);\n\t}\n\treturn (\n\t\t\n\t\t\t{loc.COMMENTS_MY_STATUS_SPAM}\n\t\t\n\t);\n}\n\n// ─── Main export ──────────────────────────────────────────────────────────────\n\nexport function UserCommentsPage({\n\tapiBaseURL,\n\tapiBasePath,\n\theaders,\n\tcurrentUserId: currentUserIdProp,\n\tresourceLinks,\n\tlocalization: localizationProp,\n}: UserCommentsPageProps) {\n\tconst loc = { ...COMMENTS_LOCALIZATION, ...localizationProp };\n\tconst resolvedUserId = useResolvedCurrentUserId(currentUserIdProp);\n\n\tif (!resolvedUserId) {\n\t\treturn (\n\t\t\t\n\t\t\t\t\n\t\t\t\t{loc.COMMENTS_MY_LOGIN_TITLE}\n\t\t\t\t\n\t\t\t\t\t{loc.COMMENTS_MY_LOGIN_DESCRIPTION}\n\t\t\t\t\n\t\t\t\n\t\t);\n\t}\n\n\treturn (\n\t\t\n\t);\n}\n\n// ─── List (suspense boundary is in ComposedRoute) ─────────────────────────────\n\nfunction UserCommentsList({\n\tapiBaseURL,\n\tapiBasePath,\n\theaders,\n\tcurrentUserId,\n\tresourceLinks,\n\tloc,\n}: {\n\tapiBaseURL: string;\n\tapiBasePath: string;\n\theaders?: HeadersInit;\n\tcurrentUserId: string;\n\tresourceLinks?: CommentsPluginOverrides[\"resourceLinks\"];\n\tloc: CommentsLocalization;\n}) {\n\tconst [page, setPage] = useState(1);\n\tconst [deleteId, setDeleteId] = useState(null);\n\n\tconst config = { apiBaseURL, apiBasePath, headers };\n\tconst offset = (page - 1) * PAGE_LIMIT;\n\n\tconst { comments, total, refetch } = useSuspenseComments(config, {\n\t\tauthorId: currentUserId,\n\t\tsort: \"desc\",\n\t\tlimit: PAGE_LIMIT,\n\t\toffset,\n\t});\n\n\tconst deleteMutation = useDeleteComment(config);\n\n\tconst totalPages = Math.max(1, Math.ceil(total / PAGE_LIMIT));\n\n\tconst handleDelete = async () => {\n\t\tif (!deleteId) return;\n\t\ttry {\n\t\t\tawait deleteMutation.mutateAsync(deleteId);\n\t\t\ttoast.success(loc.COMMENTS_MY_TOAST_DELETED);\n\t\t\trefetch();\n\t\t} catch {\n\t\t\ttoast.error(loc.COMMENTS_MY_TOAST_DELETE_ERROR);\n\t\t} finally {\n\t\t\tsetDeleteId(null);\n\t\t}\n\t};\n\n\tif (comments.length === 0 && page === 1) {\n\t\treturn (\n\t\t\t\n\t\t\t\t\n\t\t\t\t{loc.COMMENTS_MY_EMPTY_TITLE}\n\t\t\t\t\n\t\t\t\t\t{loc.COMMENTS_MY_EMPTY_DESCRIPTION}\n\t\t\t\t\n\t\t\t\n\t\t);\n\t}\n\n\treturn (\n\t\t\n\t\t\t\n\t\t\t\t\n\t\t\t\t\t{loc.COMMENTS_MY_PAGE_TITLE}\n\t\t\t\t\n\t\t\t\t\n\t\t\t\t\t{total} {loc.COMMENTS_MY_COL_COMMENT.toLowerCase()}\n\t\t\t\t\t{total !== 1 ? \"s\" : \"\"}\n\t\t\t\t\n\t\t\t\n\n\t\t\t\n\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t{loc.COMMENTS_MY_COL_COMMENT}\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t{loc.COMMENTS_MY_COL_RESOURCE}\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t{loc.COMMENTS_MY_COL_STATUS}\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t{loc.COMMENTS_MY_COL_DATE}\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\t{comments.map((comment) => (\n\t\t\t\t\t\t\t setDeleteId(comment.id)}\n\t\t\t\t\t\t\t\tisDeleting={deleteMutation.isPending && deleteId === comment.id}\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t))}\n\t\t\t\t\t\n\t\t\t\t\n\n\t\t\t\t {\n\t\t\t\t\t\tsetPage(p);\n\t\t\t\t\t\twindow.scrollTo({ top: 0, behavior: \"smooth\" });\n\t\t\t\t\t}}\n\t\t\t\t/>\n\t\t\t\n\n\t\t\t !open && setDeleteId(null)}\n\t\t\t>\n\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\t{loc.COMMENTS_MY_DELETE_TITLE}\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t{loc.COMMENTS_MY_DELETE_DESCRIPTION}\n\t\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t{loc.COMMENTS_MY_DELETE_CANCEL}\n\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t{loc.COMMENTS_MY_DELETE_CONFIRM}\n\t\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\n\t\t\t\n\t\t\n\t);\n}\n\n// ─── Row ──────────────────────────────────────────────────────────────────────\n\nfunction CommentRow({\n\tcomment,\n\tresourceLinks,\n\tloc,\n\tonDelete,\n\tisDeleting,\n}: {\n\tcomment: SerializedComment;\n\tresourceLinks?: CommentsPluginOverrides[\"resourceLinks\"];\n\tloc: CommentsLocalization;\n\tonDelete: () => void;\n\tisDeleting: boolean;\n}) {\n\tconst resourceUrlBase = resourceLinks?.[comment.resourceType]?.(\n\t\tcomment.resourceId,\n\t);\n\tconst resourceUrl = resourceUrlBase\n\t\t? `${resourceUrlBase}#comments`\n\t\t: undefined;\n\n\treturn (\n\t\t\n\t\t\t\n\t\t\t\t\n\t\t\t\t\t{comment.resolvedAvatarUrl && (\n\t\t\t\t\t\t\n\t\t\t\t\t)}\n\t\t\t\t\t\n\t\t\t\t\t\t{getInitials(comment.resolvedAuthorName)}\n\t\t\t\t\t\n\t\t\t\t\n\t\t\t\n\n\t\t\t\n\t\t\t\t{comment.body}\n\t\t\t\t{comment.parentId && (\n\t\t\t\t\t\n\t\t\t\t\t\t{loc.COMMENTS_MY_REPLY_INDICATOR}\n\t\t\t\t\t\n\t\t\t\t)}\n\t\t\t\n\n\t\t\t\n\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\t{comment.resourceType.replace(/-/g, \" \")}\n\t\t\t\t\t\n\t\t\t\t\t{resourceUrl ? (\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t{loc.COMMENTS_MY_VIEW_LINK}\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t) : (\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t{comment.resourceId}\n\t\t\t\t\t\t\n\t\t\t\t\t)}\n\t\t\t\t\n\t\t\t\n\n\t\t\t\n\t\t\t\t\n\t\t\t\n\n\t\t\t\n\t\t\t\t{formatDistanceToNow(new Date(comment.createdAt), { addSuffix: true })}\n\t\t\t\n\n\t\t\t\n\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t{loc.COMMENTS_MY_DELETE_BUTTON_SR}\n\t\t\t\t\n\t\t\t\n\t\t\n\t);\n}\n", + "target": "src/components/btst/comments/client/components/pages/my-comments-page.internal.tsx" + }, + { + "path": "btst/comments/client/components/pages/my-comments-page.tsx", + "type": "registry:page", + "content": "\"use client\";\n\nimport { lazy } from \"react\";\nimport { ComposedRoute } from \"@btst/stack/client/components\";\nimport { usePluginOverrides } from \"@btst/stack/context\";\nimport type { CommentsPluginOverrides } from \"../../overrides\";\nimport { COMMENTS_LOCALIZATION } from \"../../localization\";\nimport { useRouteLifecycle } from \"@/hooks/use-route-lifecycle\";\nimport { PageWrapper } from \"../shared/page-wrapper\";\n\nconst UserCommentsPageInternal = lazy(() =>\n\timport(\"./my-comments-page.internal\").then((m) => ({\n\t\tdefault: m.UserCommentsPage,\n\t})),\n);\n\nfunction UserCommentsPageSkeleton() {\n\treturn (\n\t\t\n\t\t\t\n\t\t\t\n\t\t\t\n\t\t\n\t);\n}\n\nexport function UserCommentsPageComponent() {\n\treturn (\n\t\t\n\t\t\t\tconsole.error(\"[btst/comments] User Comments error:\", error)\n\t\t\t}\n\t\t/>\n\t);\n}\n\nfunction UserCommentsPageWrapper() {\n\tconst overrides = usePluginOverrides(\"comments\");\n\tconst loc = { ...COMMENTS_LOCALIZATION, ...overrides.localization };\n\n\tuseRouteLifecycle({\n\t\trouteName: \"userComments\",\n\t\tcontext: {\n\t\t\tpath: \"/comments\",\n\t\t\tisSSR: typeof window === \"undefined\",\n\t\t},\n\t\toverrides,\n\t\tbeforeRenderHook: (o, context) => {\n\t\t\tif (o.onBeforeUserCommentsPageRendered) {\n\t\t\t\tconst result = o.onBeforeUserCommentsPageRendered(context);\n\t\t\t\treturn result === false ? false : true;\n\t\t\t}\n\t\t\treturn true;\n\t\t},\n\t});\n\n\treturn (\n\t\t\n\t\t\t\n\t\t\n\t);\n}\n", + "target": "src/components/btst/comments/client/components/pages/my-comments-page.tsx" + }, + { + "path": "btst/comments/client/components/pages/resource-comments-page.internal.tsx", + "type": "registry:component", + "content": "\"use client\";\n\nimport type { SerializedComment } from \"../../../types\";\nimport {\n\tuseSuspenseComments,\n\tuseUpdateCommentStatus,\n\tuseDeleteComment,\n} from \"@btst/stack/plugins/comments/client/hooks\";\nimport { CommentThread } from \"../comment-thread\";\nimport { Button } from \"@/components/ui/button\";\nimport { Badge } from \"@/components/ui/badge\";\nimport {\n\tAvatar,\n\tAvatarFallback,\n\tAvatarImage,\n} from \"@/components/ui/avatar\";\nimport { CheckCircle, ShieldOff, Trash2 } from \"lucide-react\";\nimport { formatDistanceToNow } from \"date-fns\";\nimport { toast } from \"sonner\";\nimport {\n\tCOMMENTS_LOCALIZATION,\n\ttype CommentsLocalization,\n} from \"../../localization\";\nimport { getInitials } from \"../../utils\";\n\ninterface ResourceCommentsPageProps {\n\tresourceId: string;\n\tresourceType: string;\n\tapiBaseURL: string;\n\tapiBasePath: string;\n\theaders?: HeadersInit;\n\tcurrentUserId?: string;\n\tloginHref?: string;\n\tlocalization?: CommentsLocalization;\n}\n\nexport function ResourceCommentsPage({\n\tresourceId,\n\tresourceType,\n\tapiBaseURL,\n\tapiBasePath,\n\theaders,\n\tcurrentUserId,\n\tloginHref,\n\tlocalization: localizationProp,\n}: ResourceCommentsPageProps) {\n\tconst loc = { ...COMMENTS_LOCALIZATION, ...localizationProp };\n\tconst config = { apiBaseURL, apiBasePath, headers };\n\n\tconst {\n\t\tcomments: pendingComments,\n\t\ttotal: pendingTotal,\n\t\trefetch,\n\t} = useSuspenseComments(config, {\n\t\tresourceId,\n\t\tresourceType,\n\t\tstatus: \"pending\",\n\t});\n\n\tconst updateStatus = useUpdateCommentStatus(config);\n\tconst deleteMutation = useDeleteComment(config);\n\n\tconst handleApprove = async (id: string) => {\n\t\ttry {\n\t\t\tawait updateStatus.mutateAsync({ id, status: \"approved\" });\n\t\t\ttoast.success(loc.COMMENTS_RESOURCE_TOAST_APPROVED);\n\t\t\trefetch();\n\t\t} catch {\n\t\t\ttoast.error(loc.COMMENTS_RESOURCE_TOAST_APPROVE_ERROR);\n\t\t}\n\t};\n\n\tconst handleSpam = async (id: string) => {\n\t\ttry {\n\t\t\tawait updateStatus.mutateAsync({ id, status: \"spam\" });\n\t\t\ttoast.success(loc.COMMENTS_RESOURCE_TOAST_SPAM);\n\t\t\trefetch();\n\t\t} catch {\n\t\t\ttoast.error(loc.COMMENTS_RESOURCE_TOAST_SPAM_ERROR);\n\t\t}\n\t};\n\n\tconst handleDelete = async (id: string) => {\n\t\tif (!window.confirm(loc.COMMENTS_RESOURCE_DELETE_CONFIRM)) return;\n\t\ttry {\n\t\t\tawait deleteMutation.mutateAsync(id);\n\t\t\ttoast.success(loc.COMMENTS_RESOURCE_TOAST_DELETED);\n\t\t\trefetch();\n\t\t} catch {\n\t\t\ttoast.error(loc.COMMENTS_RESOURCE_TOAST_DELETE_ERROR);\n\t\t}\n\t};\n\n\treturn (\n\t\t\n\t\t\t\n\t\t\t\t{loc.COMMENTS_RESOURCE_TITLE}\n\t\t\t\t\n\t\t\t\t\t{resourceType}/{resourceId}\n\t\t\t\t\n\t\t\t\n\n\t\t\t{pendingTotal > 0 && (\n\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\t{loc.COMMENTS_RESOURCE_PENDING_SECTION}\n\t\t\t\t\t\t{pendingTotal}\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\t{pendingComments.map((comment) => (\n\t\t\t\t\t\t\t handleApprove(comment.id)}\n\t\t\t\t\t\t\t\tonSpam={() => handleSpam(comment.id)}\n\t\t\t\t\t\t\t\tonDelete={() => handleDelete(comment.id)}\n\t\t\t\t\t\t\t\tisUpdating={updateStatus.isPending}\n\t\t\t\t\t\t\t\tisDeleting={deleteMutation.isPending}\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t))}\n\t\t\t\t\t\n\t\t\t\t\n\t\t\t)}\n\n\t\t\t\n\t\t\t\t\n\t\t\t\t\t{loc.COMMENTS_RESOURCE_THREAD_SECTION}\n\t\t\t\t\n\t\t\t\t\n\t\t\t\n\t\t\n\t);\n}\n\nfunction PendingCommentRow({\n\tcomment,\n\tloc,\n\tonApprove,\n\tonSpam,\n\tonDelete,\n\tisUpdating,\n\tisDeleting,\n}: {\n\tcomment: SerializedComment;\n\tloc: CommentsLocalization;\n\tonApprove: () => void;\n\tonSpam: () => void;\n\tonDelete: () => void;\n\tisUpdating: boolean;\n\tisDeleting: boolean;\n}) {\n\treturn (\n\t\t\n\t\t\t\n\t\t\t\t{comment.resolvedAvatarUrl && (\n\t\t\t\t\t\n\t\t\t\t)}\n\t\t\t\t\n\t\t\t\t\t{getInitials(comment.resolvedAuthorName)}\n\t\t\t\t\n\t\t\t\n\t\t\t\n\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\t{comment.resolvedAuthorName}\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\t{formatDistanceToNow(new Date(comment.createdAt), {\n\t\t\t\t\t\t\taddSuffix: true,\n\t\t\t\t\t\t})}\n\t\t\t\t\t\n\t\t\t\t\n\t\t\t\t\n\t\t\t\t\t{comment.body}\n\t\t\t\t\n\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t{loc.COMMENTS_RESOURCE_ACTION_APPROVE}\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t{loc.COMMENTS_RESOURCE_ACTION_SPAM}\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t{loc.COMMENTS_RESOURCE_ACTION_DELETE}\n\t\t\t\t\t\n\t\t\t\t\n\t\t\t\n\t\t\n\t);\n}\n", + "target": "src/components/btst/comments/client/components/pages/resource-comments-page.internal.tsx" + }, + { + "path": "btst/comments/client/components/pages/resource-comments-page.tsx", + "type": "registry:page", + "content": "\"use client\";\n\nimport { lazy } from \"react\";\nimport { ComposedRoute } from \"@btst/stack/client/components\";\nimport { usePluginOverrides } from \"@btst/stack/context\";\nimport type { CommentsPluginOverrides } from \"../../overrides\";\nimport { COMMENTS_LOCALIZATION } from \"../../localization\";\nimport { useRouteLifecycle } from \"@/hooks/use-route-lifecycle\";\nimport { PageWrapper } from \"../shared/page-wrapper\";\nimport { useResolvedCurrentUserId } from \"../../utils\";\n\nconst ResourceCommentsPageInternal = lazy(() =>\n\timport(\"./resource-comments-page.internal\").then((m) => ({\n\t\tdefault: m.ResourceCommentsPage,\n\t})),\n);\n\nfunction ResourceCommentsSkeleton() {\n\treturn (\n\t\t\n\t\t\t\n\t\t\t\n\t\t\t\n\t\t\n\t);\n}\n\nexport function ResourceCommentsPageComponent({\n\tresourceId,\n\tresourceType,\n}: {\n\tresourceId: string;\n\tresourceType: string;\n}) {\n\treturn (\n\t\t (\n\t\t\t\t\n\t\t\t)}\n\t\t\tLoadingComponent={ResourceCommentsSkeleton}\n\t\t\tonError={(error) =>\n\t\t\t\tconsole.error(\"[btst/comments] Resource comments error:\", error)\n\t\t\t}\n\t\t/>\n\t);\n}\n\nfunction ResourceCommentsPageWrapper({\n\tresourceId,\n\tresourceType,\n}: {\n\tresourceId: string;\n\tresourceType: string;\n}) {\n\tconst overrides = usePluginOverrides(\"comments\");\n\tconst loc = { ...COMMENTS_LOCALIZATION, ...overrides.localization };\n\tconst resolvedUserId = useResolvedCurrentUserId(overrides.currentUserId);\n\n\tuseRouteLifecycle({\n\t\trouteName: \"resourceComments\",\n\t\tcontext: {\n\t\t\tpath: `/comments/${resourceType}/${resourceId}`,\n\t\t\tparams: { resourceId, resourceType },\n\t\t\tisSSR: typeof window === \"undefined\",\n\t\t},\n\t\toverrides,\n\t\tbeforeRenderHook: (o, context) => {\n\t\t\tif (o.onBeforeResourceCommentsRendered) {\n\t\t\t\treturn o.onBeforeResourceCommentsRendered(\n\t\t\t\t\tresourceType,\n\t\t\t\t\tresourceId,\n\t\t\t\t\tcontext,\n\t\t\t\t);\n\t\t\t}\n\t\t\treturn true;\n\t\t},\n\t});\n\n\treturn (\n\t\t\n\t\t\t\n\t\t\n\t);\n}\n", + "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/components/shared/pagination.tsx", + "type": "registry:component", + "content": "\"use client\";\n\nimport { usePluginOverrides } from \"@btst/stack/context\";\nimport type { CommentsPluginOverrides } from \"../../overrides\";\nimport { COMMENTS_LOCALIZATION } from \"../../localization\";\nimport { PaginationControls } from \"@/components/ui/pagination-controls\";\n\ninterface PaginationProps {\n\tcurrentPage: number;\n\ttotalPages: number;\n\tonPageChange: (page: number) => void;\n\ttotal: number;\n\tlimit: number;\n\toffset: number;\n}\n\nexport function Pagination({\n\tcurrentPage,\n\ttotalPages,\n\tonPageChange,\n\ttotal,\n\tlimit,\n\toffset,\n}: PaginationProps) {\n\tconst { localization: customLocalization } =\n\t\tusePluginOverrides(\"comments\");\n\tconst localization = { ...COMMENTS_LOCALIZATION, ...customLocalization };\n\n\treturn (\n\t\t\n\t);\n}\n", + "target": "src/components/btst/comments/client/components/shared/pagination.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_MODERATION_PAGINATION_PREVIOUS: \"Previous\",\n\tCOMMENTS_MODERATION_PAGINATION_NEXT: \"Next\",\n\tCOMMENTS_MODERATION_PAGINATION_SHOWING: \"Showing {from}–{to} of {total}\",\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 User Comments page and the per-resource comments admin view to\n\t * scope the comment list to the current user and to enable posting.\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 both pages show a \"Please log in\" prompt.\n\t */\n\tcurrentUserId?:\n\t\t| string\n\t\t| (() => string | undefined | Promise);\n\n\t/**\n\t * URL to redirect unauthenticated users to when they try to post a comment.\n\t *\n\t * Forwarded to every embedded `CommentThread` (including the one on the\n\t * per-resource admin comments view). When absent no login link is shown.\n\t */\n\tloginHref?: string;\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 * When false, the comment form and reply buttons are hidden in all\n\t * `CommentThread` instances. Users can still read existing comments.\n\t * Defaults to true.\n\t *\n\t * Can be overridden per-instance via the `allowPosting` prop on `CommentThread`.\n\t */\n\tallowPosting?: boolean;\n\n\t/**\n\t * When false, the edit button is hidden on all comment cards in all\n\t * `CommentThread` instances.\n\t * Defaults to true.\n\t *\n\t * Can be overridden per-instance via the `allowEditing` prop on `CommentThread`.\n\t */\n\tallowEditing?: boolean;\n\n\t/**\n\t * Per-resource-type URL builders used to link each comment back to its\n\t * original resource on the User 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 User 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\tonBeforeUserCommentsPageRendered?: (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": "btst/comments/client/utils.ts", + "type": "registry:lib", + "content": "import { useState, useEffect } from \"react\";\nimport type { CommentsPluginOverrides } from \"./overrides\";\n\n/**\n * Resolves `currentUserId` from the plugin overrides, supporting both a static\n * string and a sync/async function. Returns `undefined` until resolution completes.\n */\nexport function useResolvedCurrentUserId(\n\traw: CommentsPluginOverrides[\"currentUserId\"],\n): string | undefined {\n\tconst [resolved, setResolved] = useState(\n\t\ttypeof raw === \"string\" ? raw : undefined,\n\t);\n\n\tuseEffect(() => {\n\t\tif (typeof raw === \"function\") {\n\t\t\tvoid Promise.resolve(raw())\n\t\t\t\t.then((id) => setResolved(id ?? undefined))\n\t\t\t\t.catch((err: unknown) => {\n\t\t\t\t\tconsole.error(\n\t\t\t\t\t\t\"[btst/comments] Failed to resolve currentUserId:\",\n\t\t\t\t\t\terr,\n\t\t\t\t\t);\n\t\t\t\t});\n\t\t} else {\n\t\t\tsetResolved(raw ?? undefined);\n\t\t}\n\t}, [raw]);\n\n\treturn resolved;\n}\n\n/**\n * Normalise any thrown value into an Error.\n *\n * Handles three shapes:\n * 1. Already an Error — returned as-is.\n * 2. A plain object — message is taken from `.message`, then `.error` (API\n * error-response shape), then JSON.stringify. All original properties are\n * copied onto the Error via Object.assign so callers can inspect them.\n * 3. Anything else — converted via String().\n */\nexport function toError(error: unknown): Error {\n\tif (error instanceof Error) return error;\n\tif (typeof error === \"object\" && error !== null) {\n\t\tconst obj = error as Record;\n\t\tconst message =\n\t\t\t(typeof obj.message === \"string\" ? obj.message : null) ||\n\t\t\t(typeof obj.error === \"string\" ? obj.error : null) ||\n\t\t\tJSON.stringify(error);\n\t\tconst err = new Error(message);\n\t\tObject.assign(err, error);\n\t\treturn err;\n\t}\n\treturn new Error(String(error));\n}\n\nexport function getInitials(name: string | null | undefined): string {\n\tif (!name) return \"?\";\n\treturn name\n\t\t.split(\" \")\n\t\t.filter(Boolean)\n\t\t.slice(0, 2)\n\t\t.map((n) => n[0])\n\t\t.join(\"\")\n\t\t.toUpperCase();\n}\n", + "target": "src/components/btst/comments/client/utils.ts" + }, + { + "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..18f06e5f 100644 --- a/packages/stack/registry/btst-kanban.json +++ b/packages/stack/registry/btst-kanban.json @@ -61,7 +61,7 @@ { "path": "btst/kanban/client/components/forms/task-form.tsx", "type": "registry:component", - "content": "\"use client\";\n\nimport { useState } from \"react\";\nimport { Trash2 } from \"lucide-react\";\nimport { Button } from \"@/components/ui/button\";\nimport { Input } from \"@/components/ui/input\";\nimport { Label } from \"@/components/ui/label\";\nimport {\n\tSelect,\n\tSelectContent,\n\tSelectItem,\n\tSelectTrigger,\n\tSelectValue,\n} from \"@/components/ui/select\";\nimport { MinimalTiptapEditor } from \"@/components/ui/minimal-tiptap\";\nimport SearchSelect from \"@/components/ui/search-select\";\nimport { useTaskMutations, useSearchUsers } from \"@btst/stack/plugins/kanban/client/hooks\";\nimport { PRIORITY_OPTIONS } from \"../../../utils\";\nimport type {\n\tSerializedColumn,\n\tSerializedTask,\n\tPriority,\n} from \"../../../types\";\n\ninterface TaskFormProps {\n\tcolumnId: string;\n\tboardId: string;\n\ttaskId?: string;\n\ttask?: SerializedTask;\n\tcolumns: SerializedColumn[];\n\tonClose: () => void;\n\tonSuccess: () => void;\n\tonDelete?: () => void;\n}\n\nexport function TaskForm({\n\tcolumnId,\n\tboardId,\n\ttaskId,\n\ttask,\n\tcolumns,\n\tonClose,\n\tonSuccess,\n\tonDelete,\n}: TaskFormProps) {\n\tconst isEditing = !!taskId;\n\tconst {\n\t\tcreateTask,\n\t\tupdateTask,\n\t\tmoveTask,\n\t\tisCreating,\n\t\tisUpdating,\n\t\tisDeleting,\n\t\tisMoving,\n\t} = useTaskMutations();\n\n\tconst [title, setTitle] = useState(task?.title || \"\");\n\tconst [description, setDescription] = useState(task?.description || \"\");\n\tconst [priority, setPriority] = useState(\n\t\ttask?.priority || \"MEDIUM\",\n\t);\n\tconst [selectedColumnId, setSelectedColumnId] = useState(\n\t\ttask?.columnId || columnId,\n\t);\n\tconst [assigneeId, setAssigneeId] = useState(task?.assigneeId || \"\");\n\tconst [error, setError] = useState(null);\n\n\t// Fetch available users for assignment\n\tconst { data: users = [] } = useSearchUsers(\"\", boardId);\n\tconst userOptions = [\n\t\t{ value: \"\", label: \"Unassigned\" },\n\t\t...users.map((user) => ({ value: user.id, label: user.name })),\n\t];\n\n\tconst isPending = isCreating || isUpdating || isDeleting || isMoving;\n\n\tconst handleSubmit = async (e: React.FormEvent) => {\n\t\te.preventDefault();\n\t\tsetError(null);\n\n\t\tif (!title.trim()) {\n\t\t\tsetError(\"Title is required\");\n\t\t\treturn;\n\t\t}\n\n\t\ttry {\n\t\t\tif (isEditing && taskId) {\n\t\t\t\tconst isColumnChanging =\n\t\t\t\t\ttask?.columnId && selectedColumnId !== task.columnId;\n\n\t\t\t\tif (isColumnChanging) {\n\t\t\t\t\t// When changing columns, we need two operations:\n\t\t\t\t\t// 1. Update task properties (title, description, priority, assigneeId)\n\t\t\t\t\t// 2. Move task to new column with proper order calculation\n\t\t\t\t\t//\n\t\t\t\t\t// To avoid partial failure confusion, we attempt both operations\n\t\t\t\t\t// but provide clear messaging if one succeeds and the other fails.\n\n\t\t\t\t\t// First update the task properties (title, description, priority, assigneeId)\n\t\t\t\t\t// If this fails, nothing is saved and the outer catch handles it\n\t\t\t\t\tawait updateTask(taskId, {\n\t\t\t\t\t\ttitle,\n\t\t\t\t\t\tdescription,\n\t\t\t\t\t\tpriority,\n\t\t\t\t\t\tassigneeId: assigneeId || null,\n\t\t\t\t\t});\n\n\t\t\t\t\t// Then move the task to the new column with calculated order\n\t\t\t\t\t// Place at the end of the destination column\n\t\t\t\t\ttry {\n\t\t\t\t\t\tconst targetColumn = columns.find((c) => c.id === selectedColumnId);\n\t\t\t\t\t\tconst targetTasks = targetColumn?.tasks || [];\n\t\t\t\t\t\tconst targetOrder =\n\t\t\t\t\t\t\ttargetTasks.length > 0\n\t\t\t\t\t\t\t\t? Math.max(...targetTasks.map((t) => t.order)) + 1\n\t\t\t\t\t\t\t\t: 0;\n\n\t\t\t\t\t\tawait moveTask(taskId, selectedColumnId, targetOrder);\n\t\t\t\t\t} catch (moveErr) {\n\t\t\t\t\t\t// Properties were saved but column move failed\n\t\t\t\t\t\t// Provide specific error message about partial success\n\t\t\t\t\t\tconst moveErrorMsg =\n\t\t\t\t\t\t\tmoveErr instanceof Error ? moveErr.message : \"Unknown error\";\n\t\t\t\t\t\tsetError(\n\t\t\t\t\t\t\t`Task properties were saved, but moving to the new column failed: ${moveErrorMsg}. ` +\n\t\t\t\t\t\t\t\t`You can try dragging the task to the desired column.`,\n\t\t\t\t\t\t);\n\t\t\t\t\t\t// Don't call onSuccess since the operation wasn't fully completed\n\t\t\t\t\t\t// but also don't throw - we want to show the specific error\n\t\t\t\t\t\treturn;\n\t\t\t\t\t}\n\t\t\t\t} else {\n\t\t\t\t\t// Same column - just update the task properties\n\t\t\t\t\tawait updateTask(taskId, {\n\t\t\t\t\t\ttitle,\n\t\t\t\t\t\tdescription,\n\t\t\t\t\t\tpriority,\n\t\t\t\t\t\tcolumnId: selectedColumnId,\n\t\t\t\t\t\tassigneeId: assigneeId || null,\n\t\t\t\t\t});\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\tawait createTask({\n\t\t\t\t\ttitle,\n\t\t\t\t\tdescription,\n\t\t\t\t\tpriority,\n\t\t\t\t\tcolumnId: selectedColumnId,\n\t\t\t\t\tassigneeId: assigneeId || undefined,\n\t\t\t\t});\n\t\t\t}\n\t\t\tonSuccess();\n\t\t} catch (err) {\n\t\t\tsetError(err instanceof Error ? err.message : \"An error occurred\");\n\t\t}\n\t};\n\n\treturn (\n\t\t\n\t\t\t\n\t\t\t\tTitle *\n\t\t\t\t) =>\n\t\t\t\t\t\tsetTitle(e.target.value)\n\t\t\t\t\t}\n\t\t\t\t\tplaceholder=\"e.g., Fix login bug\"\n\t\t\t\t\tdisabled={isPending}\n\t\t\t\t/>\n\t\t\t\n\n\t\t\t\n\t\t\t\t\n\t\t\t\t\tPriority\n\t\t\t\t\t setPriority(v as Priority)}\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\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t{PRIORITY_OPTIONS.map((option) => (\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t{option.label}\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\n\t\t\t\t\n\t\t\t\t\tColumn\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\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t{columns.map((col) => (\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t{col.title}\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\n\t\t\t\tAssignee\n\t\t\t\t\n\t\t\t\n\n\t\t\t\n\t\t\t\tDescription\n\t\t\t\t\n\t\t\t\t\t\tsetDescription(typeof value === \"string\" ? value : \"\")\n\t\t\t\t\t}\n\t\t\t\t\toutput=\"markdown\"\n\t\t\t\t\tplaceholder=\"Describe the task...\"\n\t\t\t\t\teditable={!isPending}\n\t\t\t\t\tclassName=\"min-h-[150px]\"\n\t\t\t\t/>\n\t\t\t\n\n\t\t\t{error && (\n\t\t\t\t\n\t\t\t\t\t{error}\n\t\t\t\t\n\t\t\t)}\n\n\t\t\t\n\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\t{isPending\n\t\t\t\t\t\t\t? isEditing\n\t\t\t\t\t\t\t\t? \"Updating...\"\n\t\t\t\t\t\t\t\t: \"Creating...\"\n\t\t\t\t\t\t\t: isEditing\n\t\t\t\t\t\t\t\t? \"Update Task\"\n\t\t\t\t\t\t\t\t: \"Create Task\"}\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\n\t\t\t\t\n\t\t\t\t{isEditing && onDelete && (\n\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\tDelete\n\t\t\t\t\t\n\t\t\t\t)}\n\t\t\t\n\t\t\n\t);\n}\n", + "content": "\"use client\";\n\nimport { useState } from \"react\";\nimport { Trash2 } from \"lucide-react\";\nimport { Button } from \"@/components/ui/button\";\nimport { Input } from \"@/components/ui/input\";\nimport { Label } from \"@/components/ui/label\";\nimport {\n\tSelect,\n\tSelectContent,\n\tSelectItem,\n\tSelectTrigger,\n\tSelectValue,\n} from \"@/components/ui/select\";\nimport { MinimalTiptapEditor } from \"@/components/ui/minimal-tiptap\";\nimport SearchSelect from \"@/components/ui/search-select\";\nimport { useTaskMutations, useSearchUsers } from \"@btst/stack/plugins/kanban/client/hooks\";\nimport { PRIORITY_OPTIONS } from \"../../../utils\";\nimport type {\n\tSerializedColumn,\n\tSerializedTask,\n\tPriority,\n} from \"../../../types\";\n\ninterface TaskFormProps {\n\tcolumnId: string;\n\tboardId: string;\n\ttaskId?: string;\n\ttask?: SerializedTask;\n\tcolumns: SerializedColumn[];\n\tonClose: () => void;\n\tonSuccess: () => void;\n\tonDelete?: () => void;\n}\n\nexport function TaskForm({\n\tcolumnId,\n\tboardId,\n\ttaskId,\n\ttask,\n\tcolumns,\n\tonClose,\n\tonSuccess,\n\tonDelete,\n}: TaskFormProps) {\n\tconst isEditing = !!taskId;\n\tconst {\n\t\tcreateTask,\n\t\tupdateTask,\n\t\tmoveTask,\n\t\tisCreating,\n\t\tisUpdating,\n\t\tisDeleting,\n\t\tisMoving,\n\t} = useTaskMutations();\n\n\tconst [title, setTitle] = useState(task?.title || \"\");\n\tconst [description, setDescription] = useState(task?.description || \"\");\n\tconst [priority, setPriority] = useState(\n\t\ttask?.priority || \"MEDIUM\",\n\t);\n\tconst [selectedColumnId, setSelectedColumnId] = useState(\n\t\ttask?.columnId || columnId,\n\t);\n\tconst [assigneeId, setAssigneeId] = useState(task?.assigneeId || \"\");\n\tconst [error, setError] = useState(null);\n\n\t// Fetch available users for assignment\n\tconst { data: users = [] } = useSearchUsers(\"\", boardId);\n\tconst userOptions = [\n\t\t{ value: \"\", label: \"Unassigned\" },\n\t\t...users.map((user) => ({ value: user.id, label: user.name })),\n\t];\n\n\tconst isPending = isCreating || isUpdating || isDeleting || isMoving;\n\n\tconst handleSubmit = async (e: React.FormEvent) => {\n\t\te.preventDefault();\n\t\tsetError(null);\n\n\t\tif (!title.trim()) {\n\t\t\tsetError(\"Title is required\");\n\t\t\treturn;\n\t\t}\n\n\t\ttry {\n\t\t\tif (isEditing && taskId) {\n\t\t\t\tconst isColumnChanging =\n\t\t\t\t\ttask?.columnId && selectedColumnId !== task.columnId;\n\n\t\t\t\tif (isColumnChanging) {\n\t\t\t\t\t// When changing columns, we need two operations:\n\t\t\t\t\t// 1. Update task properties (title, description, priority, assigneeId)\n\t\t\t\t\t// 2. Move task to new column with proper order calculation\n\t\t\t\t\t//\n\t\t\t\t\t// To avoid partial failure confusion, we attempt both operations\n\t\t\t\t\t// but provide clear messaging if one succeeds and the other fails.\n\n\t\t\t\t\t// First update the task properties (title, description, priority, assigneeId)\n\t\t\t\t\t// If this fails, nothing is saved and the outer catch handles it\n\t\t\t\t\tawait updateTask(taskId, {\n\t\t\t\t\t\ttitle,\n\t\t\t\t\t\tdescription,\n\t\t\t\t\t\tpriority,\n\t\t\t\t\t\tassigneeId: assigneeId || null,\n\t\t\t\t\t});\n\n\t\t\t\t\t// Then move the task to the new column with calculated order\n\t\t\t\t\t// Place at the end of the destination column\n\t\t\t\t\ttry {\n\t\t\t\t\t\tconst targetColumn = columns.find((c) => c.id === selectedColumnId);\n\t\t\t\t\t\tconst targetTasks = targetColumn?.tasks || [];\n\t\t\t\t\t\tconst targetOrder =\n\t\t\t\t\t\t\ttargetTasks.length > 0\n\t\t\t\t\t\t\t\t? Math.max(...targetTasks.map((t) => t.order)) + 1\n\t\t\t\t\t\t\t\t: 0;\n\n\t\t\t\t\t\tawait moveTask(taskId, selectedColumnId, targetOrder);\n\t\t\t\t\t} catch (moveErr) {\n\t\t\t\t\t\t// Properties were saved but column move failed\n\t\t\t\t\t\t// Provide specific error message about partial success\n\t\t\t\t\t\tconst moveErrorMsg =\n\t\t\t\t\t\t\tmoveErr instanceof Error ? moveErr.message : \"Unknown error\";\n\t\t\t\t\t\tsetError(\n\t\t\t\t\t\t\t`Task properties were saved, but moving to the new column failed: ${moveErrorMsg}. ` +\n\t\t\t\t\t\t\t\t`You can try dragging the task to the desired column.`,\n\t\t\t\t\t\t);\n\t\t\t\t\t\t// Don't call onSuccess since the operation wasn't fully completed\n\t\t\t\t\t\t// but also don't throw - we want to show the specific error\n\t\t\t\t\t\treturn;\n\t\t\t\t\t}\n\t\t\t\t} else {\n\t\t\t\t\t// Same column - just update the task properties\n\t\t\t\t\tawait updateTask(taskId, {\n\t\t\t\t\t\ttitle,\n\t\t\t\t\t\tdescription,\n\t\t\t\t\t\tpriority,\n\t\t\t\t\t\tcolumnId: selectedColumnId,\n\t\t\t\t\t\tassigneeId: assigneeId || null,\n\t\t\t\t\t});\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\tawait createTask({\n\t\t\t\t\ttitle,\n\t\t\t\t\tdescription,\n\t\t\t\t\tpriority,\n\t\t\t\t\tcolumnId: selectedColumnId,\n\t\t\t\t\tassigneeId: assigneeId || undefined,\n\t\t\t\t});\n\t\t\t}\n\t\t\tonSuccess();\n\t\t} catch (err) {\n\t\t\tsetError(err instanceof Error ? err.message : \"An error occurred\");\n\t\t}\n\t};\n\n\treturn (\n\t\t\n\t\t\t\n\t\t\t\tTitle *\n\t\t\t\t) =>\n\t\t\t\t\t\tsetTitle(e.target.value)\n\t\t\t\t\t}\n\t\t\t\t\tplaceholder=\"e.g., Fix login bug\"\n\t\t\t\t\tdisabled={isPending}\n\t\t\t\t/>\n\t\t\t\n\n\t\t\t\n\t\t\t\t\n\t\t\t\t\tPriority\n\t\t\t\t\t setPriority(v as Priority)}\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\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t{PRIORITY_OPTIONS.map((option) => (\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t{option.label}\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\n\t\t\t\t\n\t\t\t\t\tColumn\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\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t{columns.map((col) => (\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t{col.title}\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\n\t\t\t\tAssignee\n\t\t\t\t\n\t\t\t\n\n\t\t\t\n\t\t\t\tDescription\n\t\t\t\t\n\t\t\t\t\t\tsetDescription(typeof value === \"string\" ? value : \"\")\n\t\t\t\t\t}\n\t\t\t\t\toutput=\"markdown\"\n\t\t\t\t\tplaceholder=\"Describe the task...\"\n\t\t\t\t\tclassName=\"min-h-[150px]\"\n\t\t\t\t/>\n\t\t\t\n\n\t\t\t{error && (\n\t\t\t\t\n\t\t\t\t\t{error}\n\t\t\t\t\n\t\t\t)}\n\n\t\t\t\n\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\t{isPending\n\t\t\t\t\t\t\t? isEditing\n\t\t\t\t\t\t\t\t? \"Updating...\"\n\t\t\t\t\t\t\t\t: \"Creating...\"\n\t\t\t\t\t\t\t: isEditing\n\t\t\t\t\t\t\t\t? \"Update Task\"\n\t\t\t\t\t\t\t\t: \"Create Task\"}\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\n\t\t\t\t\n\t\t\t\t{isEditing && onDelete && (\n\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\tDelete\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/forms/task-form.tsx" }, { @@ -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..474ac406 100755 --- a/packages/stack/scripts/test-registry.sh +++ b/packages/stack/scripts/test-registry.sh @@ -47,7 +47,7 @@ SERVER_PORT=8766 SERVER_PID="" TEST_PASSED=false -PLUGIN_NAMES=("blog" "ai-chat" "cms" "form-builder" "kanban" "ui-builder") +PLUGIN_NAMES=("blog" "ai-chat" "cms" "form-builder" "kanban" "comments" "ui-builder") # --------------------------------------------------------------------------- # Cleanup @@ -71,6 +71,15 @@ cleanup() { } trap cleanup EXIT +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- +pause() { + local seconds="${1:-20}" + echo "Waiting ${seconds}s…" + sleep "$seconds" +} + # --------------------------------------------------------------------------- # Main # --------------------------------------------------------------------------- @@ -112,7 +121,7 @@ main() { npx --yes http-server "$REGISTRY_DIR" -p $SERVER_PORT -c-1 --silent & SERVER_PID=$! - # Wait for server to be ready (up to 15s) + # Wait for server to be ready (up to 15s), then an extra 20s for stability for i in $(seq 1 15); do if curl -sf "http://localhost:$SERVER_PORT/btst-blog.json" > /dev/null 2>&1; then break @@ -124,6 +133,7 @@ main() { fi done success "HTTP server running (PID: $SERVER_PID)" + pause 20 # ------------------------------------------------------------------ step "4 — Packing @btst/stack with npm pack" @@ -219,7 +229,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 +240,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" @@ -246,7 +256,48 @@ console.log('tsconfig.json patched'); fi # ------------------------------------------------------------------ - step "7b — Patching external registry files with known type errors" + step "7b — Pinning tiptap packages to 3.20.1" + # ------------------------------------------------------------------ + # Must run AFTER all `shadcn add` calls so that tiptap packages are already + # present as direct dependencies — setting npm overrides for packages that + # are not yet direct deps and then having shadcn add them afterwards causes + # EOVERRIDE, which silently aborts the shadcn install and leaves plugin + # files (boards-list-page, page-list-page, …) unwritten. + node -e " +const fs = require('fs'); +const pkg = JSON.parse(fs.readFileSync('./package.json', 'utf8')); +const V = '3.20.1'; +const pkgs = [ + '@tiptap/core','@tiptap/react','@tiptap/pm','@tiptap/starter-kit', + '@tiptap/extensions','@tiptap/markdown', + '@tiptap/extension-blockquote','@tiptap/extension-bold', + '@tiptap/extension-bubble-menu','@tiptap/extension-bullet-list', + '@tiptap/extension-code','@tiptap/extension-code-block', + '@tiptap/extension-code-block-lowlight','@tiptap/extension-color', + '@tiptap/extension-document','@tiptap/extension-dropcursor', + '@tiptap/extension-floating-menu','@tiptap/extension-gapcursor', + '@tiptap/extension-hard-break','@tiptap/extension-heading', + '@tiptap/extension-horizontal-rule','@tiptap/extension-image', + '@tiptap/extension-italic','@tiptap/extension-link', + '@tiptap/extension-list','@tiptap/extension-list-item', + '@tiptap/extension-list-keymap','@tiptap/extension-ordered-list', + '@tiptap/extension-paragraph','@tiptap/extension-strike', + '@tiptap/extension-table','@tiptap/extension-text', + '@tiptap/extension-text-style','@tiptap/extension-typography', + '@tiptap/extension-underline' +]; +pkg.overrides = pkg.overrides || {}; +for (const p of pkgs) { + if (pkg.dependencies?.[p]) pkg.dependencies[p] = V; + pkg.overrides[p] = V; +} +fs.writeFileSync('./package.json', JSON.stringify(pkg, null, 2)); +console.log('package.json updated with tiptap overrides'); +" + success "Tiptap overrides written (npm install runs in step 8)" + + # ------------------------------------------------------------------ + step "7c — Patching external registry files with known type errors" # ------------------------------------------------------------------ # Some files installed from external registries (e.g. the ui-builder component) # have TypeScript issues we cannot fix in their source. Add @ts-nocheck to @@ -262,7 +313,7 @@ console.log('tsconfig.json patched'); add_ts_nocheck "src/components/ui/minimal-tiptap/components/image/image-edit-block.tsx" # ------------------------------------------------------------------ - step "7c — Creating smoke-import page to force TypeScript to compile all plugin files" + step "7d — Creating smoke-import page to force TypeScript to compile all plugin files" # ------------------------------------------------------------------ # Without this page, `next build` only type-checks files reachable from # existing pages. Installed plugin components are never imported, so missing @@ -280,11 +331,12 @@ import { ChatPageComponent } from "@/components/btst/ai-chat/client/components/p import { DashboardPageComponent } from "@/components/btst/cms/client/components/pages/dashboard-page"; import { FormListPageComponent } from "@/components/btst/form-builder/client/components/pages/form-list-page"; import { BoardsListPageComponent } from "@/components/btst/kanban/client/components/pages/boards-list-page"; +import { ModerationPageComponent } from "@/components/btst/comments/client/components/pages/moderation-page"; import { PageListPage } from "@/components/btst/ui-builder/client/components/pages/page-list-page"; // Suppress unused-import warnings while still forcing TS to resolve everything. void [HomePageComponent, ChatPageComponent, DashboardPageComponent, - FormListPageComponent, BoardsListPageComponent, PageListPage]; + FormListPageComponent, BoardsListPageComponent, ModerationPageComponent, PageListPage]; export default function SmokeTestPage() { return Registry smoke test — all plugin imports resolved.; 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..1656ee2a --- /dev/null +++ b/packages/stack/src/plugins/comments/api/getters.ts @@ -0,0 +1,444 @@ +import type { DBAdapter as Adapter } from "@btst/db"; +import type { + Comment, + CommentLike, + CommentListResult, + SerializedComment, +} from "../types"; +import type { z } from "zod"; +import type { + CommentListParamsSchema, + 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" | "lt" | "gt"; +}; + +/** + * 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 the current user's own pending comments (always a small, bounded + // set — typically 0–5 per user per resource). Then paginate approved + // comments entirely at the DB level by computing each pending comment's + // exact position in the merged sorted list. + // + // Algorithm: + // For each pending p_i (sorted, 0-indexed): + // mergedPosition[i] = countApprovedBefore(p_i) + i + // where countApprovedBefore uses a `lt`/`gt` DB count on createdAt. + // This lets us derive the exact approvedOffset and approvedLimit for + // the requested page without loading the full approved set. + const [ownPendingAll, approvedCount] = await Promise.all([ + adapter.findMany({ + model: "comment", + where: [ + ...baseConditions, + { field: "status", value: "pending", operator: "eq" }, + { field: "authorId", value: params.currentUserId, operator: "eq" }, + ], + sortBy: { field: "createdAt", direction: sortDirection }, + }), + adapter.count({ + model: "comment", + where: [ + ...baseConditions, + { field: "status", value: "approved", operator: "eq" }, + ], + }), + ]); + + total = approvedCount + ownPendingAll.length; + + if (ownPendingAll.length === 0) { + // Fast path: no pending — paginate approved directly. + comments = await adapter.findMany({ + model: "comment", + limit, + offset, + where: [ + ...baseConditions, + { field: "status", value: "approved", operator: "eq" }, + ], + sortBy: { field: "createdAt", direction: sortDirection }, + }); + } else { + // For each pending comment, count how many approved records precede + // it in the merged sort order. The adapter supports `lt`/`gt` on + // date fields, so this is a single count query per pending comment + // (N_pending is tiny, so O(N_pending) queries is acceptable). + const dateOp = sortDirection === "asc" ? "lt" : "gt"; + const pendingWithPositions = await Promise.all( + ownPendingAll.map(async (p, i) => { + const approvedBefore = await adapter.count({ + model: "comment", + where: [ + ...baseConditions, + { field: "status", value: "approved", operator: "eq" }, + { + field: "createdAt", + value: p.createdAt, + operator: dateOp, + }, + ], + }); + return { comment: p, mergedPosition: approvedBefore + i }; + }), + ); + + // Partition pending into those that fall within [offset, offset+limit). + const pendingInWindow = pendingWithPositions.filter( + ({ mergedPosition }) => + mergedPosition >= offset && mergedPosition < offset + limit, + ); + const countPendingBeforeWindow = pendingWithPositions.filter( + ({ mergedPosition }) => mergedPosition < offset, + ).length; + + const approvedOffset = Math.max(0, offset - countPendingBeforeWindow); + const approvedLimit = limit - pendingInWindow.length; + + const approvedPage = + approvedLimit > 0 + ? await adapter.findMany({ + model: "comment", + limit: approvedLimit, + offset: approvedOffset, + where: [ + ...baseConditions, + { field: "status", value: "approved", operator: "eq" }, + ], + sortBy: { field: "createdAt", direction: sortDirection }, + }) + : []; + + // Merge the approved page with the pending slice and re-sort. + const merged = [ + ...approvedPage, + ...pendingInWindow.map(({ comment }) => comment), + ]; + merged.sort((a, b) => { + const diff = a.createdAt.getTime() - b.createdAt.getTime(); + return sortDirection === "desc" ? -diff : diff; + }); + comments = merged; + } + } 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..32feca66 --- /dev/null +++ b/packages/stack/src/plugins/comments/api/mutations.ts @@ -0,0 +1,206 @@ +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, cascading to any child replies. + * + * Replies reference the parent via `parentId`. Because the schema declares no + * DB-level cascade on `comment.parentId`, orphaned replies must be removed here + * in the application layer. `commentLike` rows are covered by the FK cascade + * on `commentLike.commentId` (declared in `db.ts`). + * + * Comments are only one level deep (the UI prevents replying to replies), so a + * single-level cascade is sufficient — no recursive walk is needed. + * + * @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.transaction(async (tx) => { + // Remove child replies first so they don't become orphans. + // Their commentLike rows are cleaned up by the FK cascade on commentLike.commentId. + await tx.delete({ + model: "comment", + where: [{ field: "parentId", value: id, operator: "eq" }], + }); + + // Remove the comment itself (its commentLike rows cascade via FK). + await tx.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..4f826941 --- /dev/null +++ b/packages/stack/src/plugins/comments/api/plugin.ts @@ -0,0 +1,628 @@ +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, + CommentListParamsSchema, + 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; +} + +/** Shared hook and config fields that are always present regardless of allowPosting. */ +interface CommentsBackendOptionsBase { + /** + * When true, new comments are automatically approved (status: "approved"). + * Default: false — all comments start as "pending" until a moderator approves. + */ + autoApprove?: boolean; + + /** + * When false, the `PATCH /comments/:id` endpoint is not registered and + * comment bodies cannot be edited. + * Default: true. + */ + allowEditing?: 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 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. + * 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. + */ + onBeforeListByAuthor?: ( + authorId: string, + query: z.infer, + context: CommentsApiContext, + ) => Promise | void; +} + +/** + * Configuration options for the comments backend plugin. + * + * TypeScript enforces the security-critical hooks based on `allowPosting`: + * - When `allowPosting` is absent or `true`, `onBeforePost` and + * `resolveCurrentUserId` are **required**. + * - When `allowPosting` is `false`, both become optional (the POST endpoint + * is not registered so neither hook is ever called). + */ +export type CommentsBackendOptions = CommentsBackendOptionsBase & + ( + | { + /** + * Posting is enabled (default). `onBeforePost` and `resolveCurrentUserId` + * are required to prevent anonymous authorship and impersonation. + */ + allowPosting?: true; + + /** + * 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. + */ + onBeforePost: ( + input: z.infer, + context: CommentsApiContext, + ) => Promise<{ authorId: string }> | { authorId: string }; + + /** + * Resolve the current authenticated user's ID from the request context + * (e.g. session cookie or JWT). Used to include the user's own pending + * comments alongside approved ones in `GET /comments` responses so they + * remain visible immediately after posting. + * + * Return `null` or `undefined` for unauthenticated requests. + * + * ```ts + * resolveCurrentUserId: async (ctx) => { + * const session = await getSession(ctx.headers) + * return session?.user?.id ?? null + * } + * ``` + */ + resolveCurrentUserId: ( + context: CommentsApiContext, + ) => Promise | string | null | undefined; + } + | { + /** + * When `false`, the `POST /comments` endpoint is not registered. + * No new comments or replies can be submitted — users can only read + * existing comments. `onBeforePost` and `resolveCurrentUserId` become + * optional because they are never called. + */ + allowPosting: false; + onBeforePost?: ( + input: z.infer, + context: CommentsApiContext, + ) => Promise<{ authorId: string }> | { authorId: string }; + resolveCurrentUserId?: ( + context: CommentsApiContext, + ) => Promise | string | null | undefined; + } + ); + +export const commentsBackendPlugin = (options: CommentsBackendOptions) => { + const postingEnabled = options.allowPosting !== false; + const editingEnabled = options.allowEditing !== false; + + // Narrow once so closures below see fully-typed (non-optional) hooks. + // TypeScript resolves onBeforePost / resolveCurrentUserId as required in + // the allowPosting?: true branch, so these will be Hook | undefined — but + // we only call them when postingEnabled is true. + const onBeforePost = + options.allowPosting !== false ? options.onBeforePost : undefined; + const resolveCurrentUserId = + options.allowPosting !== false ? options.resolveCurrentUserId : undefined; + + 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, + request: ctx.request, + headers: ctx.headers, + }; + + 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", + ); + } + + 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) { + await runHookWithShim( + () => options.onBeforeList!(ctx.query, context), + ctx.error, + "Forbidden: Cannot list comments", + ); + } + + let resolvedCurrentUserId: string | undefined; + if (resolveCurrentUserId) { + try { + const result = await resolveCurrentUserId(context); + resolvedCurrentUserId = result ?? undefined; + } catch { + resolvedCurrentUserId = undefined; + } + } + + return await listComments( + adapter, + { ...ctx.query, currentUserId: resolvedCurrentUserId }, + options?.resolveUser, + ); + }, + ); + + // POST /comments + const createCommentEndpoint = createEndpoint( + "/comments", + { + method: "POST", + body: createCommentSchema, + }, + async (ctx) => { + if (!postingEnabled) { + throw ctx.error(403, { message: "Posting comments is disabled" }); + } + + const context: CommentsApiContext = { + body: ctx.body, + headers: ctx.headers, + }; + + const { authorId } = await runHookWithShim( + () => 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); + } + + const serialized = await getCommentById( + adapter, + comment.id, + options?.resolveUser, + ); + if (!serialized) { + throw ctx.error(500, { + message: "Failed to retrieve created comment", + }); + } + return serialized; + }, + ); + + // PATCH /comments/:id (edit body) + const updateCommentEndpoint = createEndpoint( + "/comments/:id", + { + method: "PATCH", + body: updateCommentSchema, + }, + async (ctx) => { + if (!editingEnabled) { + throw ctx.error(403, { message: "Editing comments is disabled" }); + } + + const { id } = ctx.params; + const context: CommentsApiContext = { + params: ctx.params, + body: ctx.body, + headers: ctx.headers, + }; + + 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); + } + + const serialized = await getCommentById( + adapter, + updated.id, + options?.resolveUser, + ); + if (!serialized) { + throw ctx.error(500, { + message: "Failed to retrieve updated comment", + }); + } + return serialized; + }, + ); + + // GET /comments/count + const getCommentCountEndpoint = createEndpoint( + "/comments/count", + { + method: "GET", + query: CommentCountQuerySchema, + }, + async (ctx) => { + const context: CommentsApiContext = { + query: ctx.query, + headers: ctx.headers, + }; + + 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 }; + }, + ); + + // 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, + }; + + 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; + }, + ); + + // 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, + }; + + 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); + } + + const serialized = await getCommentById( + adapter, + updated.id, + options?.resolveUser, + ); + if (!serialized) { + throw ctx.error(500, { + message: "Failed to retrieve updated comment", + }); + } + return serialized; + }, + ); + + // 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, + }; + + 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 }; + }, + ); + + return { + listComments: listCommentsEndpoint, + ...(postingEnabled && { createComment: createCommentEndpoint }), + ...(editingEnabled && { 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..56df86c6 --- /dev/null +++ b/packages/stack/src/plugins/comments/client/components/comment-form.tsx @@ -0,0 +1,112 @@ +"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..ddca9433 --- /dev/null +++ b/packages/stack/src/plugins/comments/client/components/comment-thread.tsx @@ -0,0 +1,799 @@ +"use client"; + +import { useEffect, 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, + X, + LogIn, + ChevronDown, + ChevronUp, +} from "lucide-react"; +import { formatDistanceToNow } from "date-fns"; +import type { SerializedComment } from "../../types"; +import { getInitials } from "../utils"; +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; + /** + * When false, the comment form and reply buttons are hidden. + * Overrides the global `allowPosting` from `CommentsPluginOverrides`. + * Defaults to true. + */ + allowPosting?: boolean; + /** + * When false, the edit button is hidden on comment cards. + * Overrides the global `allowEditing` from `CommentsPluginOverrides`. + * Defaults to true. + */ + allowEditing?: boolean; +} + +const DEFAULT_RENDERER: ComponentType = ({ body }) => ( + {body} +); + +// ─── Comment Card ───────────────────────────────────────────────────────────── + +function CommentCard({ + comment, + currentUserId, + apiBaseURL, + apiBasePath, + resourceId, + resourceType, + headers, + components, + loc, + infiniteKey, + onReplyClick, + allowPosting, + allowEditing, +}: { + 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; + allowPosting: boolean; + allowEditing: boolean; +}) { + 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} + )} + + )} + + {allowPosting && + currentUserId && + !comment.parentId && + isApproved && ( + onReplyClick(comment.id)} + data-testid="reply-button" + > + + {loc.COMMENTS_REPLY_BUTTON} + + )} + + {isOwn && ( + <> + {allowEditing && isApproved && ( + setIsEditing(true)} + data-testid="edit-button" + > + + {loc.COMMENTS_EDIT_BUTTON} + + )} + + + {loc.COMMENTS_DELETE_BUTTON} + + > + )} + + )} + + + ); +} + +// ─── Thread Inner (handles data) ────────────────────────────────────────────── + +const DEFAULT_PAGE_SIZE = 100; +const REPLIES_PAGE_SIZE = 20; +const OPTIMISTIC_ID_PREFIX = "optimistic-"; + +function CommentThreadInner({ + resourceId, + resourceType, + apiBaseURL, + apiBasePath, + currentUserId, + loginHref, + headers, + components, + localization: localizationProp, + pageSize: pageSizeProp, + allowPosting: allowPostingProp, + allowEditing: allowEditingProp, +}: CommentThreadProps) { + const overrides = usePluginOverrides< + CommentsPluginOverrides, + Partial + >("comments", {}); + const pageSize = + pageSizeProp ?? overrides.defaultCommentPageSize ?? DEFAULT_PAGE_SIZE; + const allowPosting = allowPostingProp ?? overrides.allowPosting ?? true; + const allowEditing = allowEditingProp ?? overrides.allowEditing ?? true; + const loc = { ...COMMENTS_LOCALIZATION, ...localizationProp }; + const [replyingTo, setReplyingTo] = useState(null); + const [expandedReplies, setExpandedReplies] = useState>( + new Set(), + ); + const [replyOffsets, setReplyOffsets] = useState>({}); + + 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, + pageSize, + }); + + 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, + limit: REPLIES_PAGE_SIZE, + offset: replyOffsets[parentId] ?? 0, + }); + 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); + }} + allowPosting={allowPosting} + allowEditing={allowEditing} + /> + + {/* Replies */} + { + const isExpanded = expandedReplies.has(comment.id); + if (!isExpanded) { + setReplyOffsets((prev) => { + if ((prev[comment.id] ?? 0) === 0) return prev; + return { ...prev, [comment.id]: 0 }; + }); + } + setExpandedReplies((prev) => { + const next = new Set(prev); + next.has(comment.id) + ? next.delete(comment.id) + : next.add(comment.id); + return next; + }); + }} + onOffsetChange={(offset) => { + setReplyOffsets((prev) => { + if (prev[comment.id] === offset) return prev; + return { ...prev, [comment.id]: offset }; + }); + }} + allowEditing={allowEditing} + /> + + {allowPosting && 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} + + + )} + + {allowPosting && ( + <> + + + {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, + onOffsetChange, + allowEditing, +}: { + 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; + onOffsetChange: (offset: number) => void; + allowEditing: boolean; +}) { + const config = { apiBaseURL, apiBasePath, headers }; + const [replyOffset, setReplyOffset] = useState(0); + const [loadedReplies, setLoadedReplies] = useState([]); + // Only fetch reply bodies once the section is expanded. + const { + comments: repliesPage, + total: repliesTotal, + isFetching: isFetchingReplies, + } = useComments( + config, + { + resourceId, + resourceType, + parentId, + status: "approved", + currentUserId, + limit: REPLIES_PAGE_SIZE, + offset: replyOffset, + }, + { enabled: expanded }, + ); + + useEffect(() => { + if (expanded) { + setReplyOffset(0); + setLoadedReplies([]); + } + }, [expanded, parentId]); + + useEffect(() => { + onOffsetChange(replyOffset); + }, [onOffsetChange, replyOffset]); + + useEffect(() => { + if (!expanded) return; + setLoadedReplies((prev) => { + const byId = new Map(prev.map((item) => [item.id, item])); + for (const reply of repliesPage) { + byId.set(reply.id, reply); + } + + // Reconcile optimistic replies once the real server reply arrives with + // a different id. Without this, both entries can persist in local state + // until the section is collapsed and re-opened. + const currentPageIds = new Set(repliesPage.map((reply) => reply.id)); + const currentPageRealReplies = repliesPage.filter( + (reply) => !reply.id.startsWith(OPTIMISTIC_ID_PREFIX), + ); + + return Array.from(byId.values()).filter((reply) => { + if (!reply.id.startsWith(OPTIMISTIC_ID_PREFIX)) return true; + // Keep optimistic items still present in the current cache page. + if (currentPageIds.has(reply.id)) return true; + // Drop stale optimistic rows that have been replaced by a real reply. + return !currentPageRealReplies.some( + (realReply) => + realReply.parentId === reply.parentId && + realReply.authorId === reply.authorId && + realReply.body === reply.body, + ); + }); + }); + }, [expanded, repliesPage]); + + // 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 + ? loadedReplies.length || replyCount + : replyCount; + const effectiveReplyTotal = repliesTotal || replyCount; + const hasMoreReplies = loadedReplies.length < effectiveReplyTotal; + + return ( + + {/* Toggle button — always at the top so collapse is reachable without scrolling */} + + {expanded ? ( + + ) : ( + + )} + {expanded + ? loc.COMMENTS_HIDE_REPLIES + : `${displayCount} ${displayCount === 1 ? loc.COMMENTS_REPLIES_SINGULAR : loc.COMMENTS_REPLIES_PLURAL}`} + + {expanded && ( + + {loadedReplies.map((reply) => ( + {}} // No nested replies in v1 + allowPosting={false} + allowEditing={allowEditing} + /> + ))} + {hasMoreReplies && ( + + + setReplyOffset((prev) => prev + REPLIES_PAGE_SIZE) + } + disabled={isFetchingReplies} + data-testid="load-more-replies" + > + {isFetchingReplies + ? loc.COMMENTS_LOADING_MORE + : loc.COMMENTS_LOAD_MORE} + + + )} + + )} + + ); +} + +// ─── 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"> + + + + ); +} 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..9ddf021e --- /dev/null +++ b/packages/stack/src/plugins/comments/client/components/pages/moderation-page.internal.tsx @@ -0,0 +1,550 @@ +"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 { + useSuspenseModerationComments, + useUpdateCommentStatus, + useDeleteComment, +} from "../../hooks/use-comments"; +import { + COMMENTS_LOCALIZATION, + type CommentsLocalization, +} from "../../localization"; +import { getInitials } from "../../utils"; +import { Pagination } from "../shared/pagination"; + +interface ModerationPageProps { + apiBaseURL: string; + apiBasePath: string; + headers?: HeadersInit; + localization?: CommentsLocalization; +} + +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 [currentPage, setCurrentPage] = useState(1); + const [selected, setSelected] = useState>(new Set()); + const [viewComment, setViewComment] = useState( + null, + ); + const [deleteIds, setDeleteIds] = useState([]); + + const config = { apiBaseURL, apiBasePath, headers }; + + const { comments, total, limit, offset, totalPages, refetch } = + useSuspenseModerationComments(config, { + status: activeTab, + page: currentPage, + }); + + 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); + setCurrentPage(1); + 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..5cf09cc7 --- /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..cdebbdbd --- /dev/null +++ b/packages/stack/src/plugins/comments/client/components/pages/my-comments-page.internal.tsx @@ -0,0 +1,367 @@ +"use client"; + +import { useState } 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"; +import { getInitials, useResolvedCurrentUserId } from "../../utils"; + +const PAGE_LIMIT = 20; + +interface UserCommentsPageProps { + apiBaseURL: string; + apiBasePath: string; + headers?: HeadersInit; + currentUserId?: CommentsPluginOverrides["currentUserId"]; + resourceLinks?: CommentsPluginOverrides["resourceLinks"]; + localization?: CommentsLocalization; +} + +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} + + ); +} + +// ─── Main export ────────────────────────────────────────────────────────────── + +export function UserCommentsPage({ + apiBaseURL, + apiBasePath, + headers, + currentUserId: currentUserIdProp, + resourceLinks, + localization: localizationProp, +}: UserCommentsPageProps) { + 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 UserCommentsList({ + 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 resourceUrlBase = resourceLinks?.[comment.resourceType]?.( + comment.resourceId, + ); + const resourceUrl = resourceUrlBase + ? `${resourceUrlBase}#comments` + : undefined; + + 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..b3049d65 --- /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 UserCommentsPageInternal = lazy(() => + import("./my-comments-page.internal").then((m) => ({ + default: m.UserCommentsPage, + })), +); + +function UserCommentsPageSkeleton() { + return ( + + + + + + ); +} + +export function UserCommentsPageComponent() { + return ( + + console.error("[btst/comments] User Comments error:", error) + } + /> + ); +} + +function UserCommentsPageWrapper() { + const overrides = usePluginOverrides("comments"); + const loc = { ...COMMENTS_LOCALIZATION, ...overrides.localization }; + + useRouteLifecycle({ + routeName: "userComments", + context: { + path: "/comments", + isSSR: typeof window === "undefined", + }, + overrides, + beforeRenderHook: (o, context) => { + if (o.onBeforeUserCommentsPageRendered) { + const result = o.onBeforeUserCommentsPageRendered(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..f9db8e28 --- /dev/null +++ b/packages/stack/src/plugins/comments/client/components/pages/resource-comments-page.internal.tsx @@ -0,0 +1,225 @@ +"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"; +import { getInitials } from "../../utils"; + +interface ResourceCommentsPageProps { + resourceId: string; + resourceType: string; + apiBaseURL: string; + apiBasePath: string; + headers?: HeadersInit; + currentUserId?: string; + loginHref?: string; + localization?: CommentsLocalization; +} + +export function ResourceCommentsPage({ + resourceId, + resourceType, + apiBaseURL, + apiBasePath, + headers, + currentUserId, + loginHref, + 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..69ecc627 --- /dev/null +++ b/packages/stack/src/plugins/comments/client/components/pages/resource-comments-page.tsx @@ -0,0 +1,97 @@ +"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"; +import { useResolvedCurrentUserId } from "../../utils"; + +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 }; + const resolvedUserId = useResolvedCurrentUserId(overrides.currentUserId); + + 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/components/shared/pagination.tsx b/packages/stack/src/plugins/comments/client/components/shared/pagination.tsx new file mode 100644 index 00000000..5d1b7e50 --- /dev/null +++ b/packages/stack/src/plugins/comments/client/components/shared/pagination.tsx @@ -0,0 +1,44 @@ +"use client"; + +import { usePluginOverrides } from "@btst/stack/context"; +import type { CommentsPluginOverrides } from "../../overrides"; +import { COMMENTS_LOCALIZATION } from "../../localization"; +import { PaginationControls } from "@workspace/ui/components/pagination-controls"; + +interface PaginationProps { + currentPage: number; + totalPages: number; + onPageChange: (page: number) => void; + total: number; + limit: number; + offset: number; +} + +export function Pagination({ + currentPage, + totalPages, + onPageChange, + total, + limit, + offset, +}: PaginationProps) { + const { localization: customLocalization } = + usePluginOverrides("comments"); + const localization = { ...COMMENTS_LOCALIZATION, ...customLocalization }; + + return ( + + ); +} 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..5309c263 --- /dev/null +++ b/packages/stack/src/plugins/comments/client/hooks/index.tsx @@ -0,0 +1,13 @@ +export { + useComments, + useSuspenseComments, + useSuspenseModerationComments, + 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..a2779ee0 --- /dev/null +++ b/packages/stack/src/plugins/comments/client/hooks/use-comments.tsx @@ -0,0 +1,717 @@ +"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"; +import { toError } from "../utils"; + +interface CommentsClientConfig { + apiBaseURL: string; + apiBasePath: string; + headers?: HeadersInit; +} + +function getClient(config: CommentsClientConfig) { + return createApiClient({ + baseURL: config.apiBaseURL, + basePath: config.apiBasePath, + }); +} + +/** + * 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, + }; +} + +/** + * Page-based variant for the moderation dashboard. + * Uses useSuspenseQuery with explicit offset so the table always shows exactly + * one page of results and navigation is handled by Prev / Next controls. + */ +export function useSuspenseModerationComments( + config: CommentsClientConfig, + params: { + status?: "pending" | "approved" | "spam"; + limit?: number; + page?: number; + }, +) { + const limit = params.limit ?? 20; + const page = params.page ?? 1; + const offset = (page - 1) * limit; + + const client = getClient(config); + const queries = createCommentsQueryKeys(client, config.headers); + + const { data, refetch, error, isFetching } = useSuspenseQuery({ + ...queries.comments.list({ status: params.status, limit, offset }), + staleTime: 30_000, + retry: false, + }); + + if (error && !isFetching) { + throw error; + } + + const comments = data?.items ?? []; + const total = data?.total ?? 0; + const totalPages = Math.max(1, Math.ceil(total / limit)); + + return { + comments, + total, + limit, + offset, + totalPages, + 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[]; + /** + * Page size used by the corresponding `useInfiniteComments` call. + * Used only when the infinite-query cache is empty at the time of the + * optimistic update — ensures `getNextPageParam` computes the correct + * `nextOffset` from `lastPage.limit` instead of a hardcoded fallback. + */ + pageSize?: number; + }, +) { + 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, + offset?: number, + limit?: number, + ) => { + // 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, + limit, + offset, + }).queryKey; + }; + + const isInfinitePost = (parentId: string | null | undefined) => + !!params.infiniteKey && (parentId ?? null) === null; + + return useMutation({ + mutationFn: async (input: { + body: string; + parentId?: string | null; + limit?: number; + offset?: number; + }) => { + 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, input.offset, input.limit); + 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: params.pageSize ?? 10, + offset: 0, + }, + ], + pageParams: [0], + }; + } + const lastIdx = old.pages.length - 1; + return { + ...old, + // Increment `total` on every page so the header count (which reads + // pages[0].total) stays in sync even after multiple pages are loaded. + pages: old.pages.map((page, idx) => + idx === lastIdx + ? { + ...page, + items: [...page.items, optimistic], + total: page.total + 1, + } + : { ...page, total: page.total + 1 }, + ), + }; + }, + ); + + 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. + // + // For replies (non-infinite path): do NOT call invalidateQueries here. + // The setQueryData below already puts the authoritative server response + // (including the pending reply) in the cache. Invalidating would trigger + // a background refetch that goes to the server without auth context and + // returns only approved replies — overwriting the cache and making the + // pending reply disappear. + if (context.isInfinite) { + queryClient.setQueryData>( + context.listKey, + (old) => { + if (!old) { + // Cache was cleared between onMutate and onSuccess (rare). + // Seed with the real server response so the thread keeps the new comment. + return { + pages: [ + { + items: [data], + total: 1, + limit: _input.limit ?? params.pageSize ?? 10, + offset: _input.offset ?? 0, + }, + ], + pageParams: [_input.offset ?? 0], + }; + } + 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) { + // Cache was cleared between onMutate and onSuccess (rare). + // Seed it with the real server response so the reply stays visible. + return { + items: [data], + total: 1, + limit: _input.limit ?? params.pageSize ?? 20, + offset: _input.offset ?? 0, + }; + } + return { + ...old, + items: old.items.map((item) => + item.id === context.optimisticId ? data : item, + ), + }; + }); + } + }, + onError: (_err, _input, context) => { + if (!context) return; + queryClient.setQueryData(context.listKey, context.previous); + }, + }); +} + +/** + * 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, + }); + // Also invalidate the infinite thread cache so status changes are reflected there. + queryClient.invalidateQueries({ queryKey: ["commentsThread"] }); + }, + }); +} + +/** + * 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, + }); + // Also invalidate the infinite thread cache so status changes are reflected there. + queryClient.invalidateQueries({ queryKey: ["commentsThread"] }); + }, + }); +} + +/** + * 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..c294992d --- /dev/null +++ b/packages/stack/src/plugins/comments/client/localization/comments-moderation.ts @@ -0,0 +1,75 @@ +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_MODERATION_PAGINATION_PREVIOUS: "Previous", + COMMENTS_MODERATION_PAGINATION_NEXT: "Next", + COMMENTS_MODERATION_PAGINATION_SHOWING: "Showing {from}–{to} of {total}", + + 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..dbd82e3e --- /dev/null +++ b/packages/stack/src/plugins/comments/client/overrides.ts @@ -0,0 +1,164 @@ +/** + * 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 User Comments page and the per-resource comments admin view to + * scope the comment list to the current user and to enable posting. + * 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 both pages show a "Please log in" prompt. + */ + currentUserId?: + | string + | (() => string | undefined | Promise); + + /** + * URL to redirect unauthenticated users to when they try to post a comment. + * + * Forwarded to every embedded `CommentThread` (including the one on the + * per-resource admin comments view). When absent no login link is shown. + */ + loginHref?: string; + + /** + * 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; + + /** + * When false, the comment form and reply buttons are hidden in all + * `CommentThread` instances. Users can still read existing comments. + * Defaults to true. + * + * Can be overridden per-instance via the `allowPosting` prop on `CommentThread`. + */ + allowPosting?: boolean; + + /** + * When false, the edit button is hidden on all comment cards in all + * `CommentThread` instances. + * Defaults to true. + * + * Can be overridden per-instance via the `allowEditing` prop on `CommentThread`. + */ + allowEditing?: boolean; + + /** + * Per-resource-type URL builders used to link each comment back to its + * original resource on the User 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 User Comments page is rendered. + * Throw to block rendering (e.g., when the user is not authenticated). + * @param context - Route context + */ + onBeforeUserCommentsPageRendered?: (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..af3f4de9 --- /dev/null +++ b/packages/stack/src/plugins/comments/client/plugin.tsx @@ -0,0 +1,195 @@ +// 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 UserCommentsPageComponent = lazy(() => + import("./components/pages/my-comments-page").then((m) => ({ + default: m.UserCommentsPageComponent, + })), +); + +/** + * 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 User Comments page. Throw to cancel. + */ + beforeLoadUserComments?: (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 createUserCommentsLoader(config: CommentsClientConfig) { + return async () => { + if (typeof window === "undefined") { + const { apiBasePath, apiBaseURL, headers, hooks } = config; + const context: LoaderContext = { + path: "/comments", + isSSR: true, + apiBaseURL, + apiBasePath, + headers, + }; + try { + if (hooks?.beforeLoadUserComments) { + await hooks.beforeLoadUserComments(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 createCommentsRouteMeta( + config: CommentsClientConfig, + path: "/comments/moderation" | "/comments", + title: string, + description: string, +) { + return () => { + const fullUrl = `${config.siteBaseURL}${config.siteBasePath}${path}`; + return [ + { title }, + { name: "title", content: title }, + { name: "description", content: description }, + { name: "robots", content: "noindex, nofollow" }, + { property: "og:title", content: title }, + { property: "og:description", content: description }, + { property: "og:url", content: fullUrl }, + { name: "twitter:card", content: "summary" }, + { name: "twitter:title", content: title }, + { name: "twitter:description", content: description }, + ]; + }; +} + +/** + * 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: createCommentsRouteMeta( + config, + "/comments/moderation", + "Comment Moderation", + "Review and manage comments across all resources.", + ), + })), + userComments: createRoute("/comments", () => ({ + PageComponent: UserCommentsPageComponent, + loader: createUserCommentsLoader(config), + meta: createCommentsRouteMeta( + config, + "/comments", + "User Comments", + "View and manage your comments across resources.", + ), + })), + }), + }); diff --git a/packages/stack/src/plugins/comments/client/utils.ts b/packages/stack/src/plugins/comments/client/utils.ts new file mode 100644 index 00000000..898c73ac --- /dev/null +++ b/packages/stack/src/plugins/comments/client/utils.ts @@ -0,0 +1,67 @@ +import { useState, useEffect } from "react"; +import type { CommentsPluginOverrides } from "./overrides"; + +/** + * Resolves `currentUserId` from the plugin overrides, supporting both a static + * string and a sync/async function. Returns `undefined` until resolution completes. + */ +export 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)) + .catch((err: unknown) => { + console.error( + "[btst/comments] Failed to resolve currentUserId:", + err, + ); + }); + } else { + setResolved(raw ?? undefined); + } + }, [raw]); + + return resolved; +} + +/** + * Normalise any thrown value into an Error. + * + * Handles three shapes: + * 1. Already an Error — returned as-is. + * 2. A plain object — message is taken from `.message`, then `.error` (API + * error-response shape), then JSON.stringify. All original properties are + * copied onto the Error via Object.assign so callers can inspect them. + * 3. Anything else — converted via String(). + */ +export 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 getInitials(name: string | null | undefined): string { + if (!name) return "?"; + return name + .split(" ") + .filter(Boolean) + .slice(0, 2) + .map((n) => n[0]) + .join("") + .toUpperCase(); +} 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..8cb1c111 --- /dev/null +++ b/packages/stack/src/plugins/comments/query-keys.ts @@ -0,0 +1,189 @@ +import { + mergeQueryKeys, + createQueryKeys, +} from "@lukemorales/query-key-factory"; +import type { CommentsApiRouter } from "./api"; +import { createApiClient } from "@btst/stack/plugins/client"; +import type { CommentListResult } from "./types"; +import { + commentsListDiscriminator, + commentCountDiscriminator, + commentsThreadDiscriminator, +} from "./api/query-key-defs"; +import { toError } from "./client/utils"; + +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 + ); +} + +export function createCommentsQueryKeys( + client: ReturnType>, + headers?: HeadersInit, +) { + return mergeQueryKeys( + createCommentsQueries(client, headers), + createCommentCountQueries(client, headers), + createCommentsThreadQueries(client, headers), + ); +} + +function createCommentsQueries( + client: ReturnType
`) | + +### 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 | +| `allowPosting` | `boolean` | `true` | When `false`, the `POST /comments` endpoint is not registered (read-only comments mode). | +| `allowEditing` | `boolean` | `true` | When `false`, the `PATCH /comments/:id` edit endpoint is not registered. | +| `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 when `allowPosting !== false`** | Called before a comment is saved. Must return `{ authorId: string }` derived from the authenticated session. Throw to reject. | +| `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 when `allowPosting !== false`** | 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. | + + +When `allowPosting` is enabled (default), **`onBeforePost` and `resolveCurrentUserId` are both required**. +When `allowPosting: false`, both hooks become optional because `POST /comments` is disabled. + +- `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" }) +``` + +## User Comments Page + +The comments plugin registers a `/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}`, + }, + + onBeforeUserCommentsPageRendered: (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 Factory + +`commentsClientPlugin(config)` accepts: + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `apiBaseURL` | `string` | ✓ | Base URL for API requests (e.g. `https://example.com`). | +| `apiBasePath` | `string` | ✓ | Path prefix where API routes are mounted (e.g. `/api/data`). | +| `siteBaseURL` | `string` | ✓ | Base URL of your site, used for route metadata/canonical URLs. | +| `siteBasePath` | `string` | ✓ | Base path where stack pages are mounted (e.g. `/pages`). | +| `queryClient` | `QueryClient` | ✓ | React Query client instance shared with the stack. | +| `headers` | `Headers` | — | Optional SSR headers for authenticated loader calls. | +| `hooks.beforeLoadModeration` | `(context) => Promise \| void` | — | Called before moderation page loader logic runs. Throw to cancel. | +| `hooks.beforeLoadUserComments` | `(context) => Promise \| void` | — | Called before User Comments page loader logic runs. Throw to cancel. | +| `hooks.onLoadError` | `(error, context) => Promise \| void` | — | Called when a loader hook throws/errors. | + +### Client Plugin Overrides + +Configure the comments plugin behavior from your layout: + +#### `CommentsPluginOverrides` + +| Field | Type | Description | +|-------|------|-------------| +| `localization` | `Partial` | Override any UI string in the plugin. Import `COMMENTS_LOCALIZATION` from `@btst/stack/plugins/comments/client` to see all available keys. | +| `apiBaseURL` | `string` | Base URL for API requests | +| `apiBasePath` | `string` | Path prefix for the API | +| `headers` | `Record` | Optional headers for authenticated plugin API calls. | +| `showAttribution` | `boolean` | Show/hide the "Powered by BTST" attribution on plugin pages (defaults to `true`). | +| `currentUserId` | `string \| (() => string \| undefined \| Promise)` | Authenticated user's ID — used by the User Comments page. Supports async functions for session-based resolution. | +| `loginHref` | `string` | Login route used by comment UIs when user is unauthenticated. | +| `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. | +| `allowPosting` | `boolean` | Hide/show comment form and reply actions globally in `CommentThread` instances (defaults to `true`). | +| `allowEditing` | `boolean` | Hide/show edit affordances globally in `CommentThread` instances (defaults to `true`). | +| `resourceLinks` | `Record string>` | Per-resource-type URL builders for linking comments back to their resource on the User Comments page (e.g. `{ "blog-post": (slug) => "/pages/blog/" + slug }`). The plugin appends `#comments` automatically so the page scrolls to the thread. | +| `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. | +| `onBeforeUserCommentsPageRendered` | hook | Called before rendering the User Comments page. Throw to deny access (e.g. when no session exists). | +| `onRouteRender` | `(routeName, context) => void \| Promise` | Called when a comments route renders. | +| `onRouteError` | `(routeName, error, context) => void \| Promise` | Called when a comments route hits an error. | + +### 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/package.json b/e2e/package.json index ec29abe8..3ec78b7a 100644 --- a/e2e/package.json +++ b/e2e/package.json @@ -6,6 +6,9 @@ "scripts": { "e2e:install": "playwright install --with-deps", "e2e:smoke": "playwright test", + "e2e:smoke:nextjs": "BTST_FRAMEWORK=nextjs playwright test", + "e2e:smoke:tanstack": "BTST_FRAMEWORK=tanstack playwright test", + "e2e:smoke:react-router": "BTST_FRAMEWORK=react-router playwright test", "e2e:ui": "playwright test --ui" }, "devDependencies": { diff --git a/e2e/playwright.config.ts b/e2e/playwright.config.ts index d5d4ba67..15523229 100644 --- a/e2e/playwright.config.ts +++ b/e2e/playwright.config.ts @@ -14,33 +14,21 @@ const reactRouterEnv = config({ path: resolve(__dirname, "../examples/react-router/.env") }) .parsed || {}; -export default defineConfig({ - testDir: "./tests", - timeout: 90_000, - forbidOnly: !!process.env.CI, - outputDir: "../test-results", - reporter: [["list"], ["html", { open: "never" }]], - expect: { - timeout: 10_000, - }, - retries: process.env["CI"] ? 2 : 0, - use: { - trace: "retain-on-failure", - video: "retain-on-failure", - screenshot: "only-on-failure", - actionTimeout: 15_000, - navigationTimeout: 30_000, - baseURL: "http://localhost:3000", - }, - webServer: [ - // Next.js with memory provider and custom plugin - { +// When BTST_FRAMEWORK is set, only the matching webServer and project are +// started — useful for running a single framework locally or in a matrix CI job. +type Framework = "nextjs" | "tanstack" | "react-router"; +const framework = process.env.BTST_FRAMEWORK as Framework | undefined; + +const allWebServers = [ + { + framework: "nextjs" as Framework, + config: { command: "pnpm -F examples/nextjs run start:e2e", port: 3003, reuseExistingServer: !process.env["CI"], timeout: 300_000, - stdout: "pipe", - stderr: "pipe", + stdout: "pipe" as const, + stderr: "pipe" as const, env: { ...process.env, ...nextjsEnv, @@ -50,13 +38,16 @@ export default defineConfig({ NEXT_PUBLIC_BASE_URL: "http://localhost:3003", }, }, - { + }, + { + framework: "tanstack" as Framework, + config: { command: "pnpm -F examples/tanstack run start:e2e", port: 3004, reuseExistingServer: !process.env["CI"], timeout: 300_000, - stdout: "pipe", - stderr: "pipe", + stdout: "pipe" as const, + stderr: "pipe" as const, env: { ...process.env, ...tanstackEnv, @@ -65,13 +56,16 @@ export default defineConfig({ BASE_URL: "http://localhost:3004", }, }, - { + }, + { + framework: "react-router" as Framework, + config: { command: "pnpm -F examples/react-router run start:e2e", port: 3005, reuseExistingServer: !process.env["CI"], timeout: 300_000, - stdout: "pipe", - stderr: "pipe", + stdout: "pipe" as const, + stderr: "pipe" as const, env: { ...process.env, ...reactRouterEnv, @@ -80,9 +74,13 @@ export default defineConfig({ BASE_URL: "http://localhost:3005", }, }, - ], - projects: [ - { + }, +]; + +const allProjects = [ + { + framework: "nextjs" as Framework, + config: { name: "nextjs:memory", fullyParallel: false, workers: 1, @@ -98,12 +96,16 @@ 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", ], }, - { + }, + { + framework: "tanstack" as Framework, + config: { name: "tanstack:memory", fullyParallel: false, workers: 1, @@ -114,10 +116,14 @@ export default defineConfig({ "**/*.cms.spec.ts", "**/*.relations-cms.spec.ts", "**/*.form-builder.spec.ts", + "**/*.comments.spec.ts", "**/*.page-context.spec.ts", ], }, - { + }, + { + framework: "react-router" as Framework, + config: { name: "react-router:memory", fullyParallel: false, workers: 1, @@ -128,8 +134,39 @@ export default defineConfig({ "**/*.cms.spec.ts", "**/*.relations-cms.spec.ts", "**/*.form-builder.spec.ts", + "**/*.comments.spec.ts", "**/*.page-context.spec.ts", ], }, - ], + }, +]; + +const webServers = framework + ? allWebServers.filter((s) => s.framework === framework).map((s) => s.config) + : allWebServers.map((s) => s.config); + +const projects = framework + ? allProjects.filter((p) => p.framework === framework).map((p) => p.config) + : allProjects.map((p) => p.config); + +export default defineConfig({ + testDir: "./tests", + timeout: 90_000, + forbidOnly: !!process.env.CI, + outputDir: "../test-results", + reporter: [["list"], ["html", { open: "never" }]], + expect: { + timeout: 10_000, + }, + retries: process.env["CI"] ? 2 : 0, + use: { + trace: "retain-on-failure", + video: "retain-on-failure", + screenshot: "only-on-failure", + actionTimeout: 15_000, + navigationTimeout: 30_000, + baseURL: "http://localhost:3000", + }, + webServer: webServers, + projects, }); diff --git a/e2e/tests/smoke.comments.spec.ts b/e2e/tests/smoke.comments.spec.ts new file mode 100644 index 00000000..d783401c --- /dev/null +++ b/e2e/tests/smoke.comments.spec.ts @@ -0,0 +1,914 @@ +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("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 + // x-user-id header authenticates the session — own pending comments must be included. + const response = await request.get( + `/api/data/comments?resourceId=${encodeURIComponent(resourceId)}&resourceType=${encodeURIComponent(resourceType)}¤tUserId=${CURRENT_USER_ID}`, + { headers: { "x-user-id": 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 authenticated user should not see this pending comment. + // The query param is spoofed as the author's ID, but the server resolves + // currentUserId from the authenticated header (x-user-id). + const response = await request.get( + `/api/data/comments?resourceId=${encodeURIComponent(resourceId)}&resourceType=${encodeURIComponent(resourceType)}¤tUserId=${CURRENT_USER_ID}`, + { headers: { "x-user-id": "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 (x-user-id header authenticates the session) + const withUserResponse = await request.get( + `/api/data/comments?resourceId=${encodeURIComponent(resourceId)}&resourceType=${encodeURIComponent(resourceType)}&parentId=null¤tUserId=${CURRENT_USER_ID}`, + { headers: { "x-user-id": 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: + // x-user-id header authenticates the session — own pending reply must be included + const response = await request.get( + `/api/data/comments?resourceId=${encodeURIComponent(resourceId)}&resourceType=${encodeURIComponent(resourceType)}&parentId=${encodeURIComponent(parent.id)}¤tUserId=${CURRENT_USER_ID}`, + { headers: { "x-user-id": 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(); + }); +}); + +// ─── 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", { + 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", { + 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", { + 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..c264a844 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,19 @@ 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 +282,18 @@ 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 +309,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 + }, + onBeforeUserCommentsPageRendered: (context) => { + console.log(`[${context.isSSR ? 'SSR' : 'CSR'}] onBeforeUserCommentsPageRendered`); + 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..21047ea7 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,72 @@ 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) + }, + onBeforeEdit: async (commentId, update, ctx) => { + // In production: verify the caller owns the comment they are editing, e.g.: + // 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") + console.log("onBeforeEdit: comment", commentId) + }, + 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 + // Demo only: read from x-user-id header so E2E tests can simulate + // authenticated vs unauthenticated requests independently. + return ctx?.headers?.get?.("x-user-id") ?? null + }, + }), // Kanban plugin for project management boards kanban: kanbanBackendPlugin({ onBeforeListBoards: async (filter, context) => { diff --git a/examples/nextjs/next.config.ts b/examples/nextjs/next.config.ts index 56299e46..041a470a 100644 --- a/examples/nextjs/next.config.ts +++ b/examples/nextjs/next.config.ts @@ -2,6 +2,10 @@ import type { NextConfig } from "next"; const nextConfig: NextConfig = { reactCompiler: false, + experimental: { + turbopackFileSystemCacheForDev: true, + turbopackFileSystemCacheForBuild: true, + }, images: { remotePatterns: [ { diff --git a/examples/nextjs/package.json b/examples/nextjs/package.json index a5cab594..9a4dda95 100644 --- a/examples/nextjs/package.json +++ b/examples/nextjs/package.json @@ -36,7 +36,7 @@ "kysely": "^0.28.0", "lucide-react": "^0.545.0", "mongodb": "^6.0.0", - "next": "16.0.10", + "next": "16.1.6", "next-themes": "^0.4.6", "react": "19.2.0", "react-dom": "19.2.0", @@ -47,13 +47,13 @@ "devDependencies": { "@eslint/eslintrc": "^3", "@tailwindcss/postcss": "^4", + "@tailwindcss/typography": "^0.5.19", "@types/node": "^20", "@types/react": "^19", "@types/react-dom": "^19", "eslint": "^9", "eslint-config-next": "15.5.5", "tailwindcss": "^4", - "@tailwindcss/typography": "^0.5.19", "tw-animate-css": "^1.4.0", "typescript": "catalog:" } 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..17965fb6 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" @@ -16,25 +17,25 @@ import { ProductSchema, TestimonialSchema, CategorySchema, ResourceSchema, Comme const blogHooks: BlogBackendHooks = { onBeforeCreatePost: async (data) => { console.log("onBeforeCreatePost hook called", data.title); - return true; // Allow for now + // throw new Error("Unauthorized") to deny }, onBeforeUpdatePost: async (postId) => { // Example: Check if user owns the post or is admin console.log("onBeforeUpdatePost hook called for post:", postId); - return true; // Allow for now + // throw new Error("Unauthorized") to deny }, onBeforeDeletePost: async (postId) => { // Example: Check if user can delete this post console.log("onBeforeDeletePost hook called for post:", postId); - return true; // Allow for now + // throw new Error("Unauthorized") to deny }, onBeforeListPosts: async (filter) => { // Example: Allow public posts, require auth for drafts if (filter.published === false) { // Check authentication for drafts console.log("onBeforeListPosts: checking auth for drafts"); + // throw new Error("Authentication required") to deny } - return true; // Allow for now }, // Lifecycle hooks - perform actions after operations @@ -148,12 +149,67 @@ const { handler, dbSchema } = stack({ kanban: kanbanBackendPlugin({ onBeforeListBoards: async (filter, context) => { console.log("onBeforeListBoards hook called", filter); - return true; }, onBoardCreated: async (board, context) => { 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 } + }, + onBeforeEdit: async (commentId, update, ctx) => { + // In production: verify the caller owns the comment they are editing, e.g.: + // 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") + console.log("onBeforeEdit: comment", commentId) + }, + 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 + // Demo only: read from x-user-id header so E2E tests can simulate + // authenticated vs unauthenticated requests independently. + return ctx?.headers?.get?.("x-user-id") ?? null + }, + }), }, 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..dc7ccb6a 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,19 @@ 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 +218,40 @@ 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 + }, + onBeforeUserCommentsPageRendered: (context) => { + console.log(`[${context.isSSR ? 'SSR' : 'CSR'}] onBeforeUserCommentsPageRendered`); + 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..a0d84b65 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" @@ -15,25 +16,25 @@ import { ProductSchema, TestimonialSchema, CategorySchema, ResourceSchema, Comme const blogHooks: BlogBackendHooks = { onBeforeCreatePost: async (data) => { console.log("onBeforeCreatePost hook called", data.title); - return true; // Allow for now + // throw new Error("Unauthorized") to deny }, onBeforeUpdatePost: async (postId) => { // Example: Check if user owns the post or is admin console.log("onBeforeUpdatePost hook called for post:", postId); - return true; // Allow for now + // throw new Error("Unauthorized") to deny }, onBeforeDeletePost: async (postId) => { // Example: Check if user can delete this post console.log("onBeforeDeletePost hook called for post:", postId); - return true; // Allow for now + // throw new Error("Unauthorized") to deny }, onBeforeListPosts: async (filter) => { // Example: Allow public posts, require auth for drafts if (filter.published === false) { // Check authentication for drafts console.log("onBeforeListPosts: checking auth for drafts"); + // throw new Error("Authentication required") to deny } - return true; // Allow for now }, // Lifecycle hooks - perform actions after operations @@ -147,12 +148,67 @@ const { handler, dbSchema } = stack({ kanban: kanbanBackendPlugin({ onBeforeListBoards: async (filter, context) => { console.log("onBeforeListBoards hook called", filter); - return true; }, onBoardCreated: async (board, context) => { 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 } + }, + onBeforeEdit: async (commentId, update, ctx) => { + // In production: verify the caller owns the comment they are editing, e.g.: + // 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") + console.log("onBeforeEdit: comment", commentId) + }, + 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 + // Demo only: read from x-user-id header so E2E tests can simulate + // authenticated vs unauthenticated requests independently. + return ctx?.headers?.get?.("x-user-id") ?? null + }, + }), }, 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..f45412f5 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,19 @@ 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 +227,40 @@ 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 + }, + onBeforeUserCommentsPageRendered: (context) => { + console.log(`[${context.isSSR ? 'SSR' : 'CSR'}] onBeforeUserCommentsPageRendered`); + 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/package.json b/package.json index 16ec9a89..57b84652 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,9 @@ "bump": "bumpp", "test": "turbo --filter \"./packages/*\" test", "e2e:smoke": "turbo --filter \"./e2e\" e2e:smoke", + "e2e:smoke:nextjs": "turbo --filter \"./e2e\" e2e:smoke:nextjs", + "e2e:smoke:tanstack": "turbo --filter \"./e2e\" e2e:smoke:tanstack", + "e2e:smoke:react-router": "turbo --filter \"./e2e\" e2e:smoke:react-router", "e2e:integration": "turbo --filter \"./e2e/*\" e2e:integration", "typecheck": "turbo --filter \"./packages/*\" typecheck", "knip": "turbo --filter \"./packages/*\" knip" 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..5e394512 --- /dev/null +++ b/packages/stack/registry/btst-comments.json @@ -0,0 +1,164 @@ +{ + "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\n/**\n * Schema for GET /comments query parameters.\n *\n * `currentUserId` is intentionally absent — it is never accepted from the client.\n * The server always resolves the caller's identity via the `resolveCurrentUserId`\n * hook and injects it internally. Accepting it from the client would allow any\n * anonymous caller to supply an arbitrary user ID and read that user's pending\n * (pre-moderation) comments.\n */\nexport const CommentListQuerySchema = z.object({\n\tresourceId: z.string().optional(),\n\tresourceType: z.string().optional(),\n\tparentId: z.string().optional().nullable(),\n\tstatus: CommentStatusSchema.optional(),\n\tauthorId: z.string().optional(),\n\tsort: z.enum([\"asc\", \"desc\"]).optional(),\n\tlimit: z.coerce.number().int().min(1).max(100).optional(),\n\toffset: z.coerce.number().int().min(0).optional(),\n});\n\n/**\n * Internal params schema used by `listComments()` and the `api` factory.\n * Extends the HTTP query schema with `currentUserId`, which is always injected\n * server-side (either by the HTTP handler via `resolveCurrentUserId`, or by a\n * trusted server-side caller such as a Server Component or cron job).\n */\nexport const CommentListParamsSchema = CommentListQuerySchema.extend({\n\tcurrentUserId: z.string().optional(),\n});\n\nexport const CommentCountQuerySchema = z.object({\n\tresourceId: z.string().min(1),\n\tresourceType: z.string().min(1),\n\tstatus: CommentStatusSchema.optional(),\n});\n", + "target": "src/components/btst/comments/schemas.ts" + }, + { + "path": "btst/comments/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 { useEffect, useState, type ComponentType } from \"react\";\nimport { WhenVisible } from \"@/components/ui/when-visible\";\nimport {\n\tAvatar,\n\tAvatarFallback,\n\tAvatarImage,\n} from \"@/components/ui/avatar\";\nimport { Badge } from \"@/components/ui/badge\";\nimport { Button } from \"@/components/ui/button\";\nimport { Separator } from \"@/components/ui/separator\";\nimport {\n\tHeart,\n\tMessageSquare,\n\tPencil,\n\tX,\n\tLogIn,\n\tChevronDown,\n\tChevronUp,\n} from \"lucide-react\";\nimport { formatDistanceToNow } from \"date-fns\";\nimport type { SerializedComment } from \"../../types\";\nimport { getInitials } from \"../utils\";\nimport { CommentForm } from \"./comment-form\";\nimport {\n\tuseComments,\n\tuseInfiniteComments,\n\tusePostComment,\n\tuseUpdateComment,\n\tuseDeleteComment,\n\tuseToggleLike,\n} from \"@btst/stack/plugins/comments/client/hooks\";\nimport {\n\tCOMMENTS_LOCALIZATION,\n\ttype CommentsLocalization,\n} from \"../localization\";\nimport { usePluginOverrides } from \"@btst/stack/context\";\nimport type { CommentsPluginOverrides } from \"../overrides\";\n\n/** Custom input component props */\nexport interface CommentInputProps {\n\tvalue: string;\n\tonChange: (value: string) => void;\n\tdisabled?: boolean;\n\tplaceholder?: string;\n}\n\n/** Custom renderer component props */\nexport interface CommentRendererProps {\n\tbody: string;\n}\n\n/** Override slot for custom input + renderer */\nexport interface CommentComponents {\n\tInput?: ComponentType;\n\tRenderer?: ComponentType;\n}\n\nexport interface CommentThreadProps {\n\t/** The resource this thread is attached to (e.g. post slug, task ID) */\n\tresourceId: string;\n\t/** Discriminates resources across plugins (e.g. \"blog-post\", \"kanban-task\") */\n\tresourceType: string;\n\t/** Base URL for API calls */\n\tapiBaseURL: string;\n\t/** Path where the API is mounted */\n\tapiBasePath: string;\n\t/** Currently authenticated user ID. Omit for read-only / unauthenticated. */\n\tcurrentUserId?: string;\n\t/**\n\t * URL to redirect unauthenticated users to.\n\t * When provided and currentUserId is absent, shows a \"Please login to comment\" prompt.\n\t */\n\tloginHref?: string;\n\t/** Optional HTTP headers for API calls (e.g. forwarding cookies) */\n\theaders?: HeadersInit;\n\t/** Swap in custom Input / Renderer components */\n\tcomponents?: CommentComponents;\n\t/** Optional className applied to the root wrapper */\n\tclassName?: string;\n\t/** Localization strings — defaults to English */\n\tlocalization?: Partial;\n\t/**\n\t * Number of top-level comments to load per page.\n\t * Clicking \"Load more\" fetches the next page. Default: 10.\n\t */\n\tpageSize?: number;\n\t/**\n\t * When false, the comment form and reply buttons are hidden.\n\t * Overrides the global `allowPosting` from `CommentsPluginOverrides`.\n\t * Defaults to true.\n\t */\n\tallowPosting?: boolean;\n\t/**\n\t * When false, the edit button is hidden on comment cards.\n\t * Overrides the global `allowEditing` from `CommentsPluginOverrides`.\n\t * Defaults to true.\n\t */\n\tallowEditing?: boolean;\n}\n\nconst DEFAULT_RENDERER: ComponentType = ({ body }) => (\n\t{body}\n);\n\n// ─── Comment Card ─────────────────────────────────────────────────────────────\n\nfunction CommentCard({\n\tcomment,\n\tcurrentUserId,\n\tapiBaseURL,\n\tapiBasePath,\n\tresourceId,\n\tresourceType,\n\theaders,\n\tcomponents,\n\tloc,\n\tinfiniteKey,\n\tonReplyClick,\n\tallowPosting,\n\tallowEditing,\n}: {\n\tcomment: SerializedComment;\n\tcurrentUserId?: string;\n\tapiBaseURL: string;\n\tapiBasePath: string;\n\tresourceId: string;\n\tresourceType: string;\n\theaders?: HeadersInit;\n\tcomponents?: CommentComponents;\n\tloc: CommentsLocalization;\n\t/** Infinite thread query key — pass for top-level comments so like optimistic\n\t * updates target the correct InfiniteData cache entry. */\n\tinfiniteKey?: readonly unknown[];\n\tonReplyClick: (parentId: string) => void;\n\tallowPosting: boolean;\n\tallowEditing: boolean;\n}) {\n\tconst [isEditing, setIsEditing] = useState(false);\n\tconst Renderer = components?.Renderer ?? DEFAULT_RENDERER;\n\n\tconst config = { apiBaseURL, apiBasePath, headers };\n\n\tconst updateMutation = useUpdateComment(config);\n\tconst deleteMutation = useDeleteComment(config);\n\tconst toggleLikeMutation = useToggleLike(config, {\n\t\tresourceId,\n\t\tresourceType,\n\t\tparentId: comment.parentId,\n\t\tcurrentUserId,\n\t\tinfiniteKey,\n\t});\n\n\tconst isOwn = currentUserId && comment.authorId === currentUserId;\n\tconst isPending = comment.status === \"pending\";\n\tconst isApproved = comment.status === \"approved\";\n\n\tconst handleEdit = async (body: string) => {\n\t\tawait updateMutation.mutateAsync({ id: comment.id, body });\n\t\tsetIsEditing(false);\n\t};\n\n\tconst handleDelete = async () => {\n\t\tif (!window.confirm(loc.COMMENTS_DELETE_CONFIRM)) return;\n\t\tawait deleteMutation.mutateAsync(comment.id);\n\t};\n\n\tconst handleLike = () => {\n\t\tif (!currentUserId) return;\n\t\ttoggleLikeMutation.mutate({\n\t\t\tcommentId: comment.id,\n\t\t\tauthorId: currentUserId,\n\t\t});\n\t};\n\n\treturn (\n\t\t\n\t\t\t\n\t\t\t\t{comment.resolvedAvatarUrl && (\n\t\t\t\t\t\n\t\t\t\t)}\n\t\t\t\t\n\t\t\t\t\t{getInitials(comment.resolvedAuthorName)}\n\t\t\t\t\n\t\t\t\n\n\t\t\t\n\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\t{comment.resolvedAuthorName}\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\t{formatDistanceToNow(new Date(comment.createdAt), {\n\t\t\t\t\t\t\taddSuffix: true,\n\t\t\t\t\t\t})}\n\t\t\t\t\t\n\t\t\t\t\t{comment.editedAt && (\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t{loc.COMMENTS_EDITED_BADGE}\n\t\t\t\t\t\t\n\t\t\t\t\t)}\n\t\t\t\t\t{isPending && isOwn && (\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t{loc.COMMENTS_PENDING_BADGE}\n\t\t\t\t\t\t\n\t\t\t\t\t)}\n\t\t\t\t\n\n\t\t\t\t{isEditing ? (\n\t\t\t\t\t setIsEditing(false)}\n\t\t\t\t\t/>\n\t\t\t\t) : (\n\t\t\t\t\t\n\t\t\t\t)}\n\n\t\t\t\t{!isEditing && (\n\t\t\t\t\t\n\t\t\t\t\t\t{currentUserId && isApproved && (\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t{comment.likes > 0 && (\n\t\t\t\t\t\t\t\t\t{comment.likes}\n\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t)}\n\n\t\t\t\t\t\t{allowPosting &&\n\t\t\t\t\t\t\tcurrentUserId &&\n\t\t\t\t\t\t\t!comment.parentId &&\n\t\t\t\t\t\t\tisApproved && (\n\t\t\t\t\t\t\t\t onReplyClick(comment.id)}\n\t\t\t\t\t\t\t\t\tdata-testid=\"reply-button\"\n\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t{loc.COMMENTS_REPLY_BUTTON}\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t)}\n\n\t\t\t\t\t\t{isOwn && (\n\t\t\t\t\t\t\t<>\n\t\t\t\t\t\t\t\t{allowEditing && isApproved && (\n\t\t\t\t\t\t\t\t\t setIsEditing(true)}\n\t\t\t\t\t\t\t\t\t\tdata-testid=\"edit-button\"\n\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t{loc.COMMENTS_EDIT_BUTTON}\n\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t{loc.COMMENTS_DELETE_BUTTON}\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t)}\n\t\t\t\t\t\n\t\t\t\t)}\n\t\t\t\n\t\t\n\t);\n}\n\n// ─── Thread Inner (handles data) ──────────────────────────────────────────────\n\nconst DEFAULT_PAGE_SIZE = 100;\nconst REPLIES_PAGE_SIZE = 20;\nconst OPTIMISTIC_ID_PREFIX = \"optimistic-\";\n\nfunction CommentThreadInner({\n\tresourceId,\n\tresourceType,\n\tapiBaseURL,\n\tapiBasePath,\n\tcurrentUserId,\n\tloginHref,\n\theaders,\n\tcomponents,\n\tlocalization: localizationProp,\n\tpageSize: pageSizeProp,\n\tallowPosting: allowPostingProp,\n\tallowEditing: allowEditingProp,\n}: CommentThreadProps) {\n\tconst overrides = usePluginOverrides<\n\t\tCommentsPluginOverrides,\n\t\tPartial\n\t>(\"comments\", {});\n\tconst pageSize =\n\t\tpageSizeProp ?? overrides.defaultCommentPageSize ?? DEFAULT_PAGE_SIZE;\n\tconst allowPosting = allowPostingProp ?? overrides.allowPosting ?? true;\n\tconst allowEditing = allowEditingProp ?? overrides.allowEditing ?? true;\n\tconst loc = { ...COMMENTS_LOCALIZATION, ...localizationProp };\n\tconst [replyingTo, setReplyingTo] = useState(null);\n\tconst [expandedReplies, setExpandedReplies] = useState>(\n\t\tnew Set(),\n\t);\n\tconst [replyOffsets, setReplyOffsets] = useState>({});\n\n\tconst config = { apiBaseURL, apiBasePath, headers };\n\n\tconst {\n\t\tcomments,\n\t\ttotal,\n\t\tisLoading,\n\t\tloadMore,\n\t\thasMore,\n\t\tisLoadingMore,\n\t\tqueryKey: threadQueryKey,\n\t} = useInfiniteComments(config, {\n\t\tresourceId,\n\t\tresourceType,\n\t\tstatus: \"approved\",\n\t\tparentId: null,\n\t\tcurrentUserId,\n\t\tpageSize,\n\t});\n\n\tconst postMutation = usePostComment(config, {\n\t\tresourceId,\n\t\tresourceType,\n\t\tcurrentUserId,\n\t\tinfiniteKey: threadQueryKey,\n\t\tpageSize,\n\t});\n\n\tconst handlePost = async (body: string) => {\n\t\tif (!currentUserId) return;\n\t\tawait postMutation.mutateAsync({\n\t\t\tbody,\n\t\t\tparentId: null,\n\t\t});\n\t};\n\n\tconst handleReply = async (body: string, parentId: string) => {\n\t\tif (!currentUserId) return;\n\t\tawait postMutation.mutateAsync({\n\t\t\tbody,\n\t\t\tparentId,\n\t\t\tlimit: REPLIES_PAGE_SIZE,\n\t\t\toffset: replyOffsets[parentId] ?? 0,\n\t\t});\n\t\tsetReplyingTo(null);\n\t\tsetExpandedReplies((prev) => new Set(prev).add(parentId));\n\t};\n\n\treturn (\n\t\t\n\t\t\t\n\t\t\t\t\n\t\t\t\t\n\t\t\t\t\t{total === 0 ? loc.COMMENTS_TITLE : `${total} ${loc.COMMENTS_TITLE}`}\n\t\t\t\t\n\t\t\t\n\n\t\t\t{isLoading && (\n\t\t\t\t\n\t\t\t\t\t{[1, 2].map((i) => (\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t))}\n\t\t\t\t\n\t\t\t)}\n\n\t\t\t{!isLoading && comments.length > 0 && (\n\t\t\t\t\n\t\t\t\t\t{comments.map((comment) => (\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t {\n\t\t\t\t\t\t\t\t\tsetReplyingTo(replyingTo === parentId ? null : parentId);\n\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\tallowPosting={allowPosting}\n\t\t\t\t\t\t\t\tallowEditing={allowEditing}\n\t\t\t\t\t\t\t/>\n\n\t\t\t\t\t\t\t{/* Replies */}\n\t\t\t\t\t\t\t {\n\t\t\t\t\t\t\t\t\tconst isExpanded = expandedReplies.has(comment.id);\n\t\t\t\t\t\t\t\t\tif (!isExpanded) {\n\t\t\t\t\t\t\t\t\t\tsetReplyOffsets((prev) => {\n\t\t\t\t\t\t\t\t\t\t\tif ((prev[comment.id] ?? 0) === 0) return prev;\n\t\t\t\t\t\t\t\t\t\t\treturn { ...prev, [comment.id]: 0 };\n\t\t\t\t\t\t\t\t\t\t});\n\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\tsetExpandedReplies((prev) => {\n\t\t\t\t\t\t\t\t\t\tconst next = new Set(prev);\n\t\t\t\t\t\t\t\t\t\tnext.has(comment.id)\n\t\t\t\t\t\t\t\t\t\t\t? next.delete(comment.id)\n\t\t\t\t\t\t\t\t\t\t\t: next.add(comment.id);\n\t\t\t\t\t\t\t\t\t\treturn next;\n\t\t\t\t\t\t\t\t\t});\n\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\tonOffsetChange={(offset) => {\n\t\t\t\t\t\t\t\t\tsetReplyOffsets((prev) => {\n\t\t\t\t\t\t\t\t\t\tif (prev[comment.id] === offset) return prev;\n\t\t\t\t\t\t\t\t\t\treturn { ...prev, [comment.id]: offset };\n\t\t\t\t\t\t\t\t\t});\n\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\tallowEditing={allowEditing}\n\t\t\t\t\t\t\t/>\n\n\t\t\t\t\t\t\t{allowPosting && replyingTo === comment.id && currentUserId && (\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t handleReply(body, comment.id)}\n\t\t\t\t\t\t\t\t\t\tonCancel={() => setReplyingTo(null)}\n\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\n\t\t\t\t\t))}\n\t\t\t\t\n\t\t\t)}\n\n\t\t\t{!isLoading && comments.length === 0 && (\n\t\t\t\t\n\t\t\t\t\t{loc.COMMENTS_EMPTY}\n\t\t\t\t\n\t\t\t)}\n\n\t\t\t{hasMore && (\n\t\t\t\t\n\t\t\t\t\t loadMore()}\n\t\t\t\t\t\tdisabled={isLoadingMore}\n\t\t\t\t\t\tdata-testid=\"load-more-comments\"\n\t\t\t\t\t>\n\t\t\t\t\t\t{isLoadingMore ? loc.COMMENTS_LOADING_MORE : loc.COMMENTS_LOAD_MORE}\n\t\t\t\t\t\n\t\t\t\t\n\t\t\t)}\n\n\t\t\t{allowPosting && (\n\t\t\t\t<>\n\t\t\t\t\t\n\n\t\t\t\t\t{currentUserId ? (\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t) : (\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t{loc.COMMENTS_LOGIN_PROMPT}\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t{loginHref && (\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t{loc.COMMENTS_LOGIN_LINK}\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\n\t\t\t\t\t)}\n\t\t\t\t>\n\t\t\t)}\n\t\t\n\t);\n}\n\n// ─── Replies Section ───────────────────────────────────────────────────────────\n\nfunction RepliesSection({\n\tparentId,\n\tresourceId,\n\tresourceType,\n\tapiBaseURL,\n\tapiBasePath,\n\tcurrentUserId,\n\theaders,\n\tcomponents,\n\tloc,\n\texpanded,\n\treplyCount,\n\tonToggle,\n\tonOffsetChange,\n\tallowEditing,\n}: {\n\tparentId: string;\n\tresourceId: string;\n\tresourceType: string;\n\tapiBaseURL: string;\n\tapiBasePath: string;\n\tcurrentUserId?: string;\n\theaders?: HeadersInit;\n\tcomponents?: CommentComponents;\n\tloc: CommentsLocalization;\n\texpanded: boolean;\n\t/** Pre-computed from the parent comment — avoids an extra fetch on mount. */\n\treplyCount: number;\n\tonToggle: () => void;\n\tonOffsetChange: (offset: number) => void;\n\tallowEditing: boolean;\n}) {\n\tconst config = { apiBaseURL, apiBasePath, headers };\n\tconst [replyOffset, setReplyOffset] = useState(0);\n\tconst [loadedReplies, setLoadedReplies] = useState([]);\n\t// Only fetch reply bodies once the section is expanded.\n\tconst {\n\t\tcomments: repliesPage,\n\t\ttotal: repliesTotal,\n\t\tisFetching: isFetchingReplies,\n\t} = useComments(\n\t\tconfig,\n\t\t{\n\t\t\tresourceId,\n\t\t\tresourceType,\n\t\t\tparentId,\n\t\t\tstatus: \"approved\",\n\t\t\tcurrentUserId,\n\t\t\tlimit: REPLIES_PAGE_SIZE,\n\t\t\toffset: replyOffset,\n\t\t},\n\t\t{ enabled: expanded },\n\t);\n\n\tuseEffect(() => {\n\t\tif (expanded) {\n\t\t\tsetReplyOffset(0);\n\t\t\tsetLoadedReplies([]);\n\t\t}\n\t}, [expanded, parentId]);\n\n\tuseEffect(() => {\n\t\tonOffsetChange(replyOffset);\n\t}, [onOffsetChange, replyOffset]);\n\n\tuseEffect(() => {\n\t\tif (!expanded) return;\n\t\tsetLoadedReplies((prev) => {\n\t\t\tconst byId = new Map(prev.map((item) => [item.id, item]));\n\t\t\tfor (const reply of repliesPage) {\n\t\t\t\tbyId.set(reply.id, reply);\n\t\t\t}\n\n\t\t\t// Reconcile optimistic replies once the real server reply arrives with\n\t\t\t// a different id. Without this, both entries can persist in local state\n\t\t\t// until the section is collapsed and re-opened.\n\t\t\tconst currentPageIds = new Set(repliesPage.map((reply) => reply.id));\n\t\t\tconst currentPageRealReplies = repliesPage.filter(\n\t\t\t\t(reply) => !reply.id.startsWith(OPTIMISTIC_ID_PREFIX),\n\t\t\t);\n\n\t\t\treturn Array.from(byId.values()).filter((reply) => {\n\t\t\t\tif (!reply.id.startsWith(OPTIMISTIC_ID_PREFIX)) return true;\n\t\t\t\t// Keep optimistic items still present in the current cache page.\n\t\t\t\tif (currentPageIds.has(reply.id)) return true;\n\t\t\t\t// Drop stale optimistic rows that have been replaced by a real reply.\n\t\t\t\treturn !currentPageRealReplies.some(\n\t\t\t\t\t(realReply) =>\n\t\t\t\t\t\trealReply.parentId === reply.parentId &&\n\t\t\t\t\t\trealReply.authorId === reply.authorId &&\n\t\t\t\t\t\trealReply.body === reply.body,\n\t\t\t\t);\n\t\t\t});\n\t\t});\n\t}, [expanded, repliesPage]);\n\n\t// Hide when there are no known replies — but keep rendered when already\n\t// expanded so a freshly-posted first reply (which increments replyCount\n\t// only after the server responds) stays visible in the same session.\n\tif (replyCount === 0 && !expanded) return null;\n\n\t// Prefer the fetched count (accurate after optimistic inserts); fall back to\n\t// the server-provided replyCount before the fetch completes.\n\tconst displayCount = expanded\n\t\t? loadedReplies.length || replyCount\n\t\t: replyCount;\n\tconst effectiveReplyTotal = repliesTotal || replyCount;\n\tconst hasMoreReplies = loadedReplies.length < effectiveReplyTotal;\n\n\treturn (\n\t\t\n\t\t\t{/* Toggle button — always at the top so collapse is reachable without scrolling */}\n\t\t\t\n\t\t\t\t{expanded ? (\n\t\t\t\t\t\n\t\t\t\t) : (\n\t\t\t\t\t\n\t\t\t\t)}\n\t\t\t\t{expanded\n\t\t\t\t\t? loc.COMMENTS_HIDE_REPLIES\n\t\t\t\t\t: `${displayCount} ${displayCount === 1 ? loc.COMMENTS_REPLIES_SINGULAR : loc.COMMENTS_REPLIES_PLURAL}`}\n\t\t\t\n\t\t\t{expanded && (\n\t\t\t\t\n\t\t\t\t\t{loadedReplies.map((reply) => (\n\t\t\t\t\t\t {}} // No nested replies in v1\n\t\t\t\t\t\t\tallowPosting={false}\n\t\t\t\t\t\t\tallowEditing={allowEditing}\n\t\t\t\t\t\t/>\n\t\t\t\t\t))}\n\t\t\t\t\t{hasMoreReplies && (\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\tsetReplyOffset((prev) => prev + REPLIES_PAGE_SIZE)\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\tdisabled={isFetchingReplies}\n\t\t\t\t\t\t\t\tdata-testid=\"load-more-replies\"\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t{isFetchingReplies\n\t\t\t\t\t\t\t\t\t? loc.COMMENTS_LOADING_MORE\n\t\t\t\t\t\t\t\t\t: loc.COMMENTS_LOAD_MORE}\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t)}\n\t\t\t\t\n\t\t\t)}\n\t\t\n\t);\n}\n\n// ─── Public export: lazy-mounts on scroll into view ───────────────────────────\n\n/**\n * Embeddable threaded comment section.\n *\n * Lazy-mounts when the component scrolls into the viewport (via WhenVisible).\n * Requires `currentUserId` to allow posting; shows a \"Please login\" prompt otherwise.\n *\n * @example\n * ```tsx\n * \n * ```\n */\nfunction CommentThreadSkeleton() {\n\treturn (\n\t\t\n\t\t\t{/* Header */}\n\t\t\t\n\t\t\t\t\n\t\t\t\t\n\t\t\t\n\n\t\t\t{/* Comment rows */}\n\t\t\t{[1, 2, 3].map((i) => (\n\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\n\t\t\t))}\n\n\t\t\t{/* Separator */}\n\t\t\t\n\n\t\t\t{/* Textarea placeholder */}\n\t\t\t\n\t\t\t\t\n\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\n\t\t\t\n\t\t\n\t);\n}\n\nexport function CommentThread(props: CommentThreadProps) {\n\treturn (\n\t\t\n\t\t\t} rootMargin=\"300px\">\n\t\t\t\t\n\t\t\t\n\t\t\n\t);\n}\n", + "target": "src/components/btst/comments/client/components/comment-thread.tsx" + }, + { + "path": "btst/comments/client/components/pages/moderation-page.internal.tsx", + "type": "registry:component", + "content": "\"use client\";\n\nimport { useState } from \"react\";\nimport {\n\tTable,\n\tTableBody,\n\tTableCell,\n\tTableHead,\n\tTableHeader,\n\tTableRow,\n} from \"@/components/ui/table\";\nimport {\n\tDialog,\n\tDialogContent,\n\tDialogHeader,\n\tDialogTitle,\n} from \"@/components/ui/dialog\";\nimport {\n\tAlertDialog,\n\tAlertDialogAction,\n\tAlertDialogCancel,\n\tAlertDialogContent,\n\tAlertDialogDescription,\n\tAlertDialogFooter,\n\tAlertDialogHeader,\n\tAlertDialogTitle,\n} from \"@/components/ui/alert-dialog\";\nimport { Button } from \"@/components/ui/button\";\nimport { Badge } from \"@/components/ui/badge\";\nimport { Tabs, TabsList, TabsTrigger } from \"@/components/ui/tabs\";\nimport { Checkbox } from \"@/components/ui/checkbox\";\nimport {\n\tAvatar,\n\tAvatarFallback,\n\tAvatarImage,\n} from \"@/components/ui/avatar\";\nimport { CheckCircle, ShieldOff, Trash2, Eye } from \"lucide-react\";\nimport { toast } from \"sonner\";\nimport { formatDistanceToNow } from \"date-fns\";\nimport { useRegisterPageAIContext } from \"@btst/stack/plugins/ai-chat/client/context\";\nimport type { SerializedComment, CommentStatus } from \"../../../types\";\nimport {\n\tuseSuspenseModerationComments,\n\tuseUpdateCommentStatus,\n\tuseDeleteComment,\n} from \"@btst/stack/plugins/comments/client/hooks\";\nimport {\n\tCOMMENTS_LOCALIZATION,\n\ttype CommentsLocalization,\n} from \"../../localization\";\nimport { getInitials } from \"../../utils\";\nimport { Pagination } from \"../shared/pagination\";\n\ninterface ModerationPageProps {\n\tapiBaseURL: string;\n\tapiBasePath: string;\n\theaders?: HeadersInit;\n\tlocalization?: CommentsLocalization;\n}\n\nfunction StatusBadge({ status }: { status: CommentStatus }) {\n\tconst variants: Record<\n\t\tCommentStatus,\n\t\t\"secondary\" | \"default\" | \"destructive\"\n\t> = {\n\t\tpending: \"secondary\",\n\t\tapproved: \"default\",\n\t\tspam: \"destructive\",\n\t};\n\treturn {status};\n}\n\nexport function ModerationPage({\n\tapiBaseURL,\n\tapiBasePath,\n\theaders,\n\tlocalization: localizationProp,\n}: ModerationPageProps) {\n\tconst loc = { ...COMMENTS_LOCALIZATION, ...localizationProp };\n\tconst [activeTab, setActiveTab] = useState(\"pending\");\n\tconst [currentPage, setCurrentPage] = useState(1);\n\tconst [selected, setSelected] = useState>(new Set());\n\tconst [viewComment, setViewComment] = useState(\n\t\tnull,\n\t);\n\tconst [deleteIds, setDeleteIds] = useState([]);\n\n\tconst config = { apiBaseURL, apiBasePath, headers };\n\n\tconst { comments, total, limit, offset, totalPages, refetch } =\n\t\tuseSuspenseModerationComments(config, {\n\t\t\tstatus: activeTab,\n\t\t\tpage: currentPage,\n\t\t});\n\n\tconst updateStatus = useUpdateCommentStatus(config);\n\tconst deleteMutation = useDeleteComment(config);\n\n\t// Register AI context with pending comment previews\n\tuseRegisterPageAIContext({\n\t\trouteName: \"comments-moderation\",\n\t\tpageDescription: `${total} ${activeTab} comments in the moderation queue.\\n\\nTop ${activeTab} comments:\\n${comments\n\t\t\t.slice(0, 5)\n\t\t\t.map(\n\t\t\t\t(c) =>\n\t\t\t\t\t`- \"${c.body.slice(0, 80)}${c.body.length > 80 ? \"…\" : \"\"}\" by ${c.resolvedAuthorName} on ${c.resourceType}/${c.resourceId}`,\n\t\t\t)\n\t\t\t.join(\"\\n\")}`,\n\t\tsuggestions: [\n\t\t\t\"Approve all safe-looking comments\",\n\t\t\t\"Flag spam comments\",\n\t\t\t\"Summarize today's discussion\",\n\t\t],\n\t});\n\n\tconst toggleSelect = (id: string) => {\n\t\tsetSelected((prev) => {\n\t\t\tconst next = new Set(prev);\n\t\t\tnext.has(id) ? next.delete(id) : next.add(id);\n\t\t\treturn next;\n\t\t});\n\t};\n\n\tconst toggleSelectAll = () => {\n\t\tif (selected.size === comments.length) {\n\t\t\tsetSelected(new Set());\n\t\t} else {\n\t\t\tsetSelected(new Set(comments.map((c) => c.id)));\n\t\t}\n\t};\n\n\tconst handleApprove = async (id: string) => {\n\t\ttry {\n\t\t\tawait updateStatus.mutateAsync({ id, status: \"approved\" });\n\t\t\ttoast.success(loc.COMMENTS_MODERATION_TOAST_APPROVED);\n\t\t\tawait refetch();\n\t\t} catch {\n\t\t\ttoast.error(loc.COMMENTS_MODERATION_TOAST_APPROVE_ERROR);\n\t\t}\n\t};\n\n\tconst handleSpam = async (id: string) => {\n\t\ttry {\n\t\t\tawait updateStatus.mutateAsync({ id, status: \"spam\" });\n\t\t\ttoast.success(loc.COMMENTS_MODERATION_TOAST_SPAM);\n\t\t\tawait refetch();\n\t\t} catch {\n\t\t\ttoast.error(loc.COMMENTS_MODERATION_TOAST_SPAM_ERROR);\n\t\t}\n\t};\n\n\tconst handleDelete = async (ids: string[]) => {\n\t\ttry {\n\t\t\tawait Promise.all(ids.map((id) => deleteMutation.mutateAsync(id)));\n\t\t\ttoast.success(\n\t\t\t\tids.length === 1\n\t\t\t\t\t? loc.COMMENTS_MODERATION_TOAST_DELETED\n\t\t\t\t\t: loc.COMMENTS_MODERATION_TOAST_DELETED_PLURAL.replace(\n\t\t\t\t\t\t\t\"{n}\",\n\t\t\t\t\t\t\tString(ids.length),\n\t\t\t\t\t\t),\n\t\t\t);\n\t\t\tsetSelected(new Set());\n\t\t\tsetDeleteIds([]);\n\t\t\tawait refetch();\n\t\t} catch {\n\t\t\ttoast.error(loc.COMMENTS_MODERATION_TOAST_DELETE_ERROR);\n\t\t}\n\t};\n\n\tconst handleBulkApprove = async () => {\n\t\tconst ids = [...selected];\n\t\ttry {\n\t\t\tawait Promise.all(\n\t\t\t\tids.map((id) => updateStatus.mutateAsync({ id, status: \"approved\" })),\n\t\t\t);\n\t\t\ttoast.success(\n\t\t\t\tloc.COMMENTS_MODERATION_TOAST_BULK_APPROVED.replace(\n\t\t\t\t\t\"{n}\",\n\t\t\t\t\tString(ids.length),\n\t\t\t\t),\n\t\t\t);\n\t\t\tsetSelected(new Set());\n\t\t\tawait refetch();\n\t\t} catch {\n\t\t\ttoast.error(loc.COMMENTS_MODERATION_TOAST_BULK_APPROVE_ERROR);\n\t\t}\n\t};\n\n\treturn (\n\t\t\n\t\t\t\n\t\t\t\t{loc.COMMENTS_MODERATION_TITLE}\n\t\t\t\t\n\t\t\t\t\t{loc.COMMENTS_MODERATION_DESCRIPTION}\n\t\t\t\t\n\t\t\t\n\n\t\t\t {\n\t\t\t\t\tsetActiveTab(v as CommentStatus);\n\t\t\t\t\tsetCurrentPage(1);\n\t\t\t\t\tsetSelected(new Set());\n\t\t\t\t}}\n\t\t\t>\n\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\t{loc.COMMENTS_MODERATION_TAB_PENDING}\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\t{loc.COMMENTS_MODERATION_TAB_APPROVED}\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\t{loc.COMMENTS_MODERATION_TAB_SPAM}\n\t\t\t\t\t\n\t\t\t\t\n\t\t\t\n\n\t\t\t{/* Bulk actions toolbar */}\n\t\t\t{selected.size > 0 && (\n\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\t{loc.COMMENTS_MODERATION_SELECTED.replace(\n\t\t\t\t\t\t\t\"{n}\",\n\t\t\t\t\t\t\tString(selected.size),\n\t\t\t\t\t\t)}\n\t\t\t\t\t\n\t\t\t\t\t{activeTab !== \"approved\" && (\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t{loc.COMMENTS_MODERATION_APPROVE_SELECTED}\n\t\t\t\t\t\t\n\t\t\t\t\t)}\n\t\t\t\t\t setDeleteIds([...selected])}\n\t\t\t\t\t>\n\t\t\t\t\t\t\n\t\t\t\t\t\t{loc.COMMENTS_MODERATION_DELETE_SELECTED}\n\t\t\t\t\t\n\t\t\t\t\n\t\t\t)}\n\n\t\t\t{comments.length === 0 ? (\n\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\t{loc.COMMENTS_MODERATION_EMPTY.replace(\"{status}\", activeTab)}\n\t\t\t\t\t\n\t\t\t\t\n\t\t\t) : (\n\t\t\t\t<>\n\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t 0\n\t\t\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\t\t\tonCheckedChange={toggleSelectAll}\n\t\t\t\t\t\t\t\t\t\t\taria-label={loc.COMMENTS_MODERATION_SELECT_ALL}\n\t\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t{loc.COMMENTS_MODERATION_COL_AUTHOR}\n\t\t\t\t\t\t\t\t\t{loc.COMMENTS_MODERATION_COL_COMMENT}\n\t\t\t\t\t\t\t\t\t{loc.COMMENTS_MODERATION_COL_RESOURCE}\n\t\t\t\t\t\t\t\t\t{loc.COMMENTS_MODERATION_COL_DATE}\n\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t{loc.COMMENTS_MODERATION_COL_ACTIONS}\n\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t{comments.map((comment) => (\n\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t toggleSelect(comment.id)}\n\t\t\t\t\t\t\t\t\t\t\t\taria-label={loc.COMMENTS_MODERATION_SELECT_ONE}\n\t\t\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t\t\t{comment.resolvedAvatarUrl && (\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t{getInitials(comment.resolvedAuthorName)}\n\t\t\t\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t\t\t{comment.resolvedAuthorName}\n\t\t\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t\t{comment.body}\n\t\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t\t{comment.resourceType}/{comment.resourceId}\n\t\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t{formatDistanceToNow(new Date(comment.createdAt), {\n\t\t\t\t\t\t\t\t\t\t\t\taddSuffix: true,\n\t\t\t\t\t\t\t\t\t\t\t})}\n\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t\t setViewComment(comment)}\n\t\t\t\t\t\t\t\t\t\t\t\t\tdata-testid=\"view-button\"\n\t\t\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t\t{activeTab !== \"approved\" && (\n\t\t\t\t\t\t\t\t\t\t\t\t\t handleApprove(comment.id)}\n\t\t\t\t\t\t\t\t\t\t\t\t\t\tdisabled={updateStatus.isPending}\n\t\t\t\t\t\t\t\t\t\t\t\t\t\tdata-testid=\"approve-button\"\n\t\t\t\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t\t\t\t\t{activeTab !== \"spam\" && (\n\t\t\t\t\t\t\t\t\t\t\t\t\t handleSpam(comment.id)}\n\t\t\t\t\t\t\t\t\t\t\t\t\t\tdisabled={updateStatus.isPending}\n\t\t\t\t\t\t\t\t\t\t\t\t\t\tdata-testid=\"spam-button\"\n\t\t\t\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t\t\t\t\t setDeleteIds([comment.id])}\n\t\t\t\t\t\t\t\t\t\t\t\t\tdata-testid=\"delete-button\"\n\t\t\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t))}\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t>\n\t\t\t)}\n\n\t\t\t{/* View comment dialog */}\n\t\t\t setViewComment(null)}>\n\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\t{loc.COMMENTS_MODERATION_DIALOG_TITLE}\n\t\t\t\t\t\n\t\t\t\t\t{viewComment && (\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t{viewComment.resolvedAvatarUrl && (\n\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t{getInitials(viewComment.resolvedAuthorName)}\n\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t{viewComment.resolvedAuthorName}\n\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t{new Date(viewComment.createdAt).toLocaleString()}\n\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t{loc.COMMENTS_MODERATION_DIALOG_RESOURCE}\n\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t{viewComment.resourceType}/{viewComment.resourceId}\n\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t{loc.COMMENTS_MODERATION_DIALOG_LIKES}\n\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t{viewComment.likes}\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t{viewComment.parentId && (\n\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t{loc.COMMENTS_MODERATION_DIALOG_REPLY_TO}\n\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t{viewComment.parentId}\n\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t{viewComment.editedAt && (\n\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t{loc.COMMENTS_MODERATION_DIALOG_EDITED}\n\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t{new Date(viewComment.editedAt).toLocaleString()}\n\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\n\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t{loc.COMMENTS_MODERATION_DIALOG_BODY}\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t{viewComment.body}\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t{viewComment.status !== \"approved\" && (\n\t\t\t\t\t\t\t\t\t {\n\t\t\t\t\t\t\t\t\t\t\tawait handleApprove(viewComment.id);\n\t\t\t\t\t\t\t\t\t\t\tsetViewComment(null);\n\t\t\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t\t\tdisabled={updateStatus.isPending}\n\t\t\t\t\t\t\t\t\t\tdata-testid=\"dialog-approve-button\"\n\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t{loc.COMMENTS_MODERATION_DIALOG_APPROVE}\n\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t{viewComment.status !== \"spam\" && (\n\t\t\t\t\t\t\t\t\t {\n\t\t\t\t\t\t\t\t\t\t\tawait handleSpam(viewComment.id);\n\t\t\t\t\t\t\t\t\t\t\tsetViewComment(null);\n\t\t\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t\t\tdisabled={updateStatus.isPending}\n\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t{loc.COMMENTS_MODERATION_DIALOG_MARK_SPAM}\n\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t {\n\t\t\t\t\t\t\t\t\t\tsetDeleteIds([viewComment.id]);\n\t\t\t\t\t\t\t\t\t\tsetViewComment(null);\n\t\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t{loc.COMMENTS_MODERATION_DIALOG_DELETE}\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t)}\n\t\t\t\t\n\t\t\t\n\n\t\t\t{/* Delete confirmation dialog */}\n\t\t\t 0}\n\t\t\t\tonOpenChange={(open) => !open && setDeleteIds([])}\n\t\t\t>\n\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t{deleteIds.length === 1\n\t\t\t\t\t\t\t\t? loc.COMMENTS_MODERATION_DELETE_TITLE_SINGULAR\n\t\t\t\t\t\t\t\t: loc.COMMENTS_MODERATION_DELETE_TITLE_PLURAL.replace(\n\t\t\t\t\t\t\t\t\t\t\"{n}\",\n\t\t\t\t\t\t\t\t\t\tString(deleteIds.length),\n\t\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t{deleteIds.length === 1\n\t\t\t\t\t\t\t\t? loc.COMMENTS_MODERATION_DELETE_DESCRIPTION_SINGULAR\n\t\t\t\t\t\t\t\t: loc.COMMENTS_MODERATION_DELETE_DESCRIPTION_PLURAL}\n\t\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t{loc.COMMENTS_MODERATION_DELETE_CANCEL}\n\t\t\t\t\t\t\n\t\t\t\t\t\t handleDelete(deleteIds)}\n\t\t\t\t\t\t\tdata-testid=\"confirm-delete-button\"\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t{deleteMutation.isPending\n\t\t\t\t\t\t\t\t? loc.COMMENTS_MODERATION_DELETE_DELETING\n\t\t\t\t\t\t\t\t: loc.COMMENTS_MODERATION_DELETE_CONFIRM}\n\t\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\n\t\t\t\n\t\t\n\t);\n}\n", + "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 } from \"react\";\nimport {\n\tTable,\n\tTableBody,\n\tTableCell,\n\tTableHead,\n\tTableHeader,\n\tTableRow,\n} from \"@/components/ui/table\";\nimport {\n\tAlertDialog,\n\tAlertDialogAction,\n\tAlertDialogCancel,\n\tAlertDialogContent,\n\tAlertDialogDescription,\n\tAlertDialogFooter,\n\tAlertDialogHeader,\n\tAlertDialogTitle,\n} from \"@/components/ui/alert-dialog\";\nimport { Button } from \"@/components/ui/button\";\nimport { Badge } from \"@/components/ui/badge\";\nimport {\n\tAvatar,\n\tAvatarFallback,\n\tAvatarImage,\n} from \"@/components/ui/avatar\";\nimport { Trash2, ExternalLink, LogIn, MessageSquareOff } from \"lucide-react\";\nimport { toast } from \"sonner\";\nimport { formatDistanceToNow } from \"date-fns\";\nimport type { CommentsPluginOverrides } from \"../../overrides\";\nimport { PaginationControls } from \"@/components/ui/pagination-controls\";\nimport type { SerializedComment, CommentStatus } from \"../../../types\";\nimport {\n\tuseSuspenseComments,\n\tuseDeleteComment,\n} from \"@btst/stack/plugins/comments/client/hooks\";\nimport {\n\tCOMMENTS_LOCALIZATION,\n\ttype CommentsLocalization,\n} from \"../../localization\";\nimport { getInitials, useResolvedCurrentUserId } from \"../../utils\";\n\nconst PAGE_LIMIT = 20;\n\ninterface UserCommentsPageProps {\n\tapiBaseURL: string;\n\tapiBasePath: string;\n\theaders?: HeadersInit;\n\tcurrentUserId?: CommentsPluginOverrides[\"currentUserId\"];\n\tresourceLinks?: CommentsPluginOverrides[\"resourceLinks\"];\n\tlocalization?: CommentsLocalization;\n}\n\nfunction StatusBadge({\n\tstatus,\n\tloc,\n}: {\n\tstatus: CommentStatus;\n\tloc: CommentsLocalization;\n}) {\n\tif (status === \"approved\") {\n\t\treturn (\n\t\t\t\n\t\t\t\t{loc.COMMENTS_MY_STATUS_APPROVED}\n\t\t\t\n\t\t);\n\t}\n\tif (status === \"pending\") {\n\t\treturn (\n\t\t\t\n\t\t\t\t{loc.COMMENTS_MY_STATUS_PENDING}\n\t\t\t\n\t\t);\n\t}\n\treturn (\n\t\t\n\t\t\t{loc.COMMENTS_MY_STATUS_SPAM}\n\t\t\n\t);\n}\n\n// ─── Main export ──────────────────────────────────────────────────────────────\n\nexport function UserCommentsPage({\n\tapiBaseURL,\n\tapiBasePath,\n\theaders,\n\tcurrentUserId: currentUserIdProp,\n\tresourceLinks,\n\tlocalization: localizationProp,\n}: UserCommentsPageProps) {\n\tconst loc = { ...COMMENTS_LOCALIZATION, ...localizationProp };\n\tconst resolvedUserId = useResolvedCurrentUserId(currentUserIdProp);\n\n\tif (!resolvedUserId) {\n\t\treturn (\n\t\t\t\n\t\t\t\t\n\t\t\t\t{loc.COMMENTS_MY_LOGIN_TITLE}\n\t\t\t\t\n\t\t\t\t\t{loc.COMMENTS_MY_LOGIN_DESCRIPTION}\n\t\t\t\t\n\t\t\t\n\t\t);\n\t}\n\n\treturn (\n\t\t\n\t);\n}\n\n// ─── List (suspense boundary is in ComposedRoute) ─────────────────────────────\n\nfunction UserCommentsList({\n\tapiBaseURL,\n\tapiBasePath,\n\theaders,\n\tcurrentUserId,\n\tresourceLinks,\n\tloc,\n}: {\n\tapiBaseURL: string;\n\tapiBasePath: string;\n\theaders?: HeadersInit;\n\tcurrentUserId: string;\n\tresourceLinks?: CommentsPluginOverrides[\"resourceLinks\"];\n\tloc: CommentsLocalization;\n}) {\n\tconst [page, setPage] = useState(1);\n\tconst [deleteId, setDeleteId] = useState(null);\n\n\tconst config = { apiBaseURL, apiBasePath, headers };\n\tconst offset = (page - 1) * PAGE_LIMIT;\n\n\tconst { comments, total, refetch } = useSuspenseComments(config, {\n\t\tauthorId: currentUserId,\n\t\tsort: \"desc\",\n\t\tlimit: PAGE_LIMIT,\n\t\toffset,\n\t});\n\n\tconst deleteMutation = useDeleteComment(config);\n\n\tconst totalPages = Math.max(1, Math.ceil(total / PAGE_LIMIT));\n\n\tconst handleDelete = async () => {\n\t\tif (!deleteId) return;\n\t\ttry {\n\t\t\tawait deleteMutation.mutateAsync(deleteId);\n\t\t\ttoast.success(loc.COMMENTS_MY_TOAST_DELETED);\n\t\t\trefetch();\n\t\t} catch {\n\t\t\ttoast.error(loc.COMMENTS_MY_TOAST_DELETE_ERROR);\n\t\t} finally {\n\t\t\tsetDeleteId(null);\n\t\t}\n\t};\n\n\tif (comments.length === 0 && page === 1) {\n\t\treturn (\n\t\t\t\n\t\t\t\t\n\t\t\t\t{loc.COMMENTS_MY_EMPTY_TITLE}\n\t\t\t\t\n\t\t\t\t\t{loc.COMMENTS_MY_EMPTY_DESCRIPTION}\n\t\t\t\t\n\t\t\t\n\t\t);\n\t}\n\n\treturn (\n\t\t\n\t\t\t\n\t\t\t\t\n\t\t\t\t\t{loc.COMMENTS_MY_PAGE_TITLE}\n\t\t\t\t\n\t\t\t\t\n\t\t\t\t\t{total} {loc.COMMENTS_MY_COL_COMMENT.toLowerCase()}\n\t\t\t\t\t{total !== 1 ? \"s\" : \"\"}\n\t\t\t\t\n\t\t\t\n\n\t\t\t\n\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t{loc.COMMENTS_MY_COL_COMMENT}\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t{loc.COMMENTS_MY_COL_RESOURCE}\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t{loc.COMMENTS_MY_COL_STATUS}\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t{loc.COMMENTS_MY_COL_DATE}\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\t{comments.map((comment) => (\n\t\t\t\t\t\t\t setDeleteId(comment.id)}\n\t\t\t\t\t\t\t\tisDeleting={deleteMutation.isPending && deleteId === comment.id}\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t))}\n\t\t\t\t\t\n\t\t\t\t\n\n\t\t\t\t {\n\t\t\t\t\t\tsetPage(p);\n\t\t\t\t\t\twindow.scrollTo({ top: 0, behavior: \"smooth\" });\n\t\t\t\t\t}}\n\t\t\t\t/>\n\t\t\t\n\n\t\t\t !open && setDeleteId(null)}\n\t\t\t>\n\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\t{loc.COMMENTS_MY_DELETE_TITLE}\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t{loc.COMMENTS_MY_DELETE_DESCRIPTION}\n\t\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t{loc.COMMENTS_MY_DELETE_CANCEL}\n\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t{loc.COMMENTS_MY_DELETE_CONFIRM}\n\t\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\n\t\t\t\n\t\t\n\t);\n}\n\n// ─── Row ──────────────────────────────────────────────────────────────────────\n\nfunction CommentRow({\n\tcomment,\n\tresourceLinks,\n\tloc,\n\tonDelete,\n\tisDeleting,\n}: {\n\tcomment: SerializedComment;\n\tresourceLinks?: CommentsPluginOverrides[\"resourceLinks\"];\n\tloc: CommentsLocalization;\n\tonDelete: () => void;\n\tisDeleting: boolean;\n}) {\n\tconst resourceUrlBase = resourceLinks?.[comment.resourceType]?.(\n\t\tcomment.resourceId,\n\t);\n\tconst resourceUrl = resourceUrlBase\n\t\t? `${resourceUrlBase}#comments`\n\t\t: undefined;\n\n\treturn (\n\t\t\n\t\t\t\n\t\t\t\t\n\t\t\t\t\t{comment.resolvedAvatarUrl && (\n\t\t\t\t\t\t\n\t\t\t\t\t)}\n\t\t\t\t\t\n\t\t\t\t\t\t{getInitials(comment.resolvedAuthorName)}\n\t\t\t\t\t\n\t\t\t\t\n\t\t\t\n\n\t\t\t\n\t\t\t\t{comment.body}\n\t\t\t\t{comment.parentId && (\n\t\t\t\t\t\n\t\t\t\t\t\t{loc.COMMENTS_MY_REPLY_INDICATOR}\n\t\t\t\t\t\n\t\t\t\t)}\n\t\t\t\n\n\t\t\t\n\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\t{comment.resourceType.replace(/-/g, \" \")}\n\t\t\t\t\t\n\t\t\t\t\t{resourceUrl ? (\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t{loc.COMMENTS_MY_VIEW_LINK}\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t) : (\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t{comment.resourceId}\n\t\t\t\t\t\t\n\t\t\t\t\t)}\n\t\t\t\t\n\t\t\t\n\n\t\t\t\n\t\t\t\t\n\t\t\t\n\n\t\t\t\n\t\t\t\t{formatDistanceToNow(new Date(comment.createdAt), { addSuffix: true })}\n\t\t\t\n\n\t\t\t\n\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t{loc.COMMENTS_MY_DELETE_BUTTON_SR}\n\t\t\t\t\n\t\t\t\n\t\t\n\t);\n}\n", + "target": "src/components/btst/comments/client/components/pages/my-comments-page.internal.tsx" + }, + { + "path": "btst/comments/client/components/pages/my-comments-page.tsx", + "type": "registry:page", + "content": "\"use client\";\n\nimport { lazy } from \"react\";\nimport { ComposedRoute } from \"@btst/stack/client/components\";\nimport { usePluginOverrides } from \"@btst/stack/context\";\nimport type { CommentsPluginOverrides } from \"../../overrides\";\nimport { COMMENTS_LOCALIZATION } from \"../../localization\";\nimport { useRouteLifecycle } from \"@/hooks/use-route-lifecycle\";\nimport { PageWrapper } from \"../shared/page-wrapper\";\n\nconst UserCommentsPageInternal = lazy(() =>\n\timport(\"./my-comments-page.internal\").then((m) => ({\n\t\tdefault: m.UserCommentsPage,\n\t})),\n);\n\nfunction UserCommentsPageSkeleton() {\n\treturn (\n\t\t\n\t\t\t\n\t\t\t\n\t\t\t\n\t\t\n\t);\n}\n\nexport function UserCommentsPageComponent() {\n\treturn (\n\t\t\n\t\t\t\tconsole.error(\"[btst/comments] User Comments error:\", error)\n\t\t\t}\n\t\t/>\n\t);\n}\n\nfunction UserCommentsPageWrapper() {\n\tconst overrides = usePluginOverrides(\"comments\");\n\tconst loc = { ...COMMENTS_LOCALIZATION, ...overrides.localization };\n\n\tuseRouteLifecycle({\n\t\trouteName: \"userComments\",\n\t\tcontext: {\n\t\t\tpath: \"/comments\",\n\t\t\tisSSR: typeof window === \"undefined\",\n\t\t},\n\t\toverrides,\n\t\tbeforeRenderHook: (o, context) => {\n\t\t\tif (o.onBeforeUserCommentsPageRendered) {\n\t\t\t\tconst result = o.onBeforeUserCommentsPageRendered(context);\n\t\t\t\treturn result === false ? false : true;\n\t\t\t}\n\t\t\treturn true;\n\t\t},\n\t});\n\n\treturn (\n\t\t\n\t\t\t\n\t\t\n\t);\n}\n", + "target": "src/components/btst/comments/client/components/pages/my-comments-page.tsx" + }, + { + "path": "btst/comments/client/components/pages/resource-comments-page.internal.tsx", + "type": "registry:component", + "content": "\"use client\";\n\nimport type { SerializedComment } from \"../../../types\";\nimport {\n\tuseSuspenseComments,\n\tuseUpdateCommentStatus,\n\tuseDeleteComment,\n} from \"@btst/stack/plugins/comments/client/hooks\";\nimport { CommentThread } from \"../comment-thread\";\nimport { Button } from \"@/components/ui/button\";\nimport { Badge } from \"@/components/ui/badge\";\nimport {\n\tAvatar,\n\tAvatarFallback,\n\tAvatarImage,\n} from \"@/components/ui/avatar\";\nimport { CheckCircle, ShieldOff, Trash2 } from \"lucide-react\";\nimport { formatDistanceToNow } from \"date-fns\";\nimport { toast } from \"sonner\";\nimport {\n\tCOMMENTS_LOCALIZATION,\n\ttype CommentsLocalization,\n} from \"../../localization\";\nimport { getInitials } from \"../../utils\";\n\ninterface ResourceCommentsPageProps {\n\tresourceId: string;\n\tresourceType: string;\n\tapiBaseURL: string;\n\tapiBasePath: string;\n\theaders?: HeadersInit;\n\tcurrentUserId?: string;\n\tloginHref?: string;\n\tlocalization?: CommentsLocalization;\n}\n\nexport function ResourceCommentsPage({\n\tresourceId,\n\tresourceType,\n\tapiBaseURL,\n\tapiBasePath,\n\theaders,\n\tcurrentUserId,\n\tloginHref,\n\tlocalization: localizationProp,\n}: ResourceCommentsPageProps) {\n\tconst loc = { ...COMMENTS_LOCALIZATION, ...localizationProp };\n\tconst config = { apiBaseURL, apiBasePath, headers };\n\n\tconst {\n\t\tcomments: pendingComments,\n\t\ttotal: pendingTotal,\n\t\trefetch,\n\t} = useSuspenseComments(config, {\n\t\tresourceId,\n\t\tresourceType,\n\t\tstatus: \"pending\",\n\t});\n\n\tconst updateStatus = useUpdateCommentStatus(config);\n\tconst deleteMutation = useDeleteComment(config);\n\n\tconst handleApprove = async (id: string) => {\n\t\ttry {\n\t\t\tawait updateStatus.mutateAsync({ id, status: \"approved\" });\n\t\t\ttoast.success(loc.COMMENTS_RESOURCE_TOAST_APPROVED);\n\t\t\trefetch();\n\t\t} catch {\n\t\t\ttoast.error(loc.COMMENTS_RESOURCE_TOAST_APPROVE_ERROR);\n\t\t}\n\t};\n\n\tconst handleSpam = async (id: string) => {\n\t\ttry {\n\t\t\tawait updateStatus.mutateAsync({ id, status: \"spam\" });\n\t\t\ttoast.success(loc.COMMENTS_RESOURCE_TOAST_SPAM);\n\t\t\trefetch();\n\t\t} catch {\n\t\t\ttoast.error(loc.COMMENTS_RESOURCE_TOAST_SPAM_ERROR);\n\t\t}\n\t};\n\n\tconst handleDelete = async (id: string) => {\n\t\tif (!window.confirm(loc.COMMENTS_RESOURCE_DELETE_CONFIRM)) return;\n\t\ttry {\n\t\t\tawait deleteMutation.mutateAsync(id);\n\t\t\ttoast.success(loc.COMMENTS_RESOURCE_TOAST_DELETED);\n\t\t\trefetch();\n\t\t} catch {\n\t\t\ttoast.error(loc.COMMENTS_RESOURCE_TOAST_DELETE_ERROR);\n\t\t}\n\t};\n\n\treturn (\n\t\t\n\t\t\t\n\t\t\t\t{loc.COMMENTS_RESOURCE_TITLE}\n\t\t\t\t\n\t\t\t\t\t{resourceType}/{resourceId}\n\t\t\t\t\n\t\t\t\n\n\t\t\t{pendingTotal > 0 && (\n\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\t{loc.COMMENTS_RESOURCE_PENDING_SECTION}\n\t\t\t\t\t\t{pendingTotal}\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\t{pendingComments.map((comment) => (\n\t\t\t\t\t\t\t handleApprove(comment.id)}\n\t\t\t\t\t\t\t\tonSpam={() => handleSpam(comment.id)}\n\t\t\t\t\t\t\t\tonDelete={() => handleDelete(comment.id)}\n\t\t\t\t\t\t\t\tisUpdating={updateStatus.isPending}\n\t\t\t\t\t\t\t\tisDeleting={deleteMutation.isPending}\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t))}\n\t\t\t\t\t\n\t\t\t\t\n\t\t\t)}\n\n\t\t\t\n\t\t\t\t\n\t\t\t\t\t{loc.COMMENTS_RESOURCE_THREAD_SECTION}\n\t\t\t\t\n\t\t\t\t\n\t\t\t\n\t\t\n\t);\n}\n\nfunction PendingCommentRow({\n\tcomment,\n\tloc,\n\tonApprove,\n\tonSpam,\n\tonDelete,\n\tisUpdating,\n\tisDeleting,\n}: {\n\tcomment: SerializedComment;\n\tloc: CommentsLocalization;\n\tonApprove: () => void;\n\tonSpam: () => void;\n\tonDelete: () => void;\n\tisUpdating: boolean;\n\tisDeleting: boolean;\n}) {\n\treturn (\n\t\t\n\t\t\t\n\t\t\t\t{comment.resolvedAvatarUrl && (\n\t\t\t\t\t\n\t\t\t\t)}\n\t\t\t\t\n\t\t\t\t\t{getInitials(comment.resolvedAuthorName)}\n\t\t\t\t\n\t\t\t\n\t\t\t\n\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\t{comment.resolvedAuthorName}\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\t{formatDistanceToNow(new Date(comment.createdAt), {\n\t\t\t\t\t\t\taddSuffix: true,\n\t\t\t\t\t\t})}\n\t\t\t\t\t\n\t\t\t\t\n\t\t\t\t\n\t\t\t\t\t{comment.body}\n\t\t\t\t\n\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t{loc.COMMENTS_RESOURCE_ACTION_APPROVE}\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t{loc.COMMENTS_RESOURCE_ACTION_SPAM}\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t{loc.COMMENTS_RESOURCE_ACTION_DELETE}\n\t\t\t\t\t\n\t\t\t\t\n\t\t\t\n\t\t\n\t);\n}\n", + "target": "src/components/btst/comments/client/components/pages/resource-comments-page.internal.tsx" + }, + { + "path": "btst/comments/client/components/pages/resource-comments-page.tsx", + "type": "registry:page", + "content": "\"use client\";\n\nimport { lazy } from \"react\";\nimport { ComposedRoute } from \"@btst/stack/client/components\";\nimport { usePluginOverrides } from \"@btst/stack/context\";\nimport type { CommentsPluginOverrides } from \"../../overrides\";\nimport { COMMENTS_LOCALIZATION } from \"../../localization\";\nimport { useRouteLifecycle } from \"@/hooks/use-route-lifecycle\";\nimport { PageWrapper } from \"../shared/page-wrapper\";\nimport { useResolvedCurrentUserId } from \"../../utils\";\n\nconst ResourceCommentsPageInternal = lazy(() =>\n\timport(\"./resource-comments-page.internal\").then((m) => ({\n\t\tdefault: m.ResourceCommentsPage,\n\t})),\n);\n\nfunction ResourceCommentsSkeleton() {\n\treturn (\n\t\t\n\t\t\t\n\t\t\t\n\t\t\t\n\t\t\n\t);\n}\n\nexport function ResourceCommentsPageComponent({\n\tresourceId,\n\tresourceType,\n}: {\n\tresourceId: string;\n\tresourceType: string;\n}) {\n\treturn (\n\t\t (\n\t\t\t\t\n\t\t\t)}\n\t\t\tLoadingComponent={ResourceCommentsSkeleton}\n\t\t\tonError={(error) =>\n\t\t\t\tconsole.error(\"[btst/comments] Resource comments error:\", error)\n\t\t\t}\n\t\t/>\n\t);\n}\n\nfunction ResourceCommentsPageWrapper({\n\tresourceId,\n\tresourceType,\n}: {\n\tresourceId: string;\n\tresourceType: string;\n}) {\n\tconst overrides = usePluginOverrides(\"comments\");\n\tconst loc = { ...COMMENTS_LOCALIZATION, ...overrides.localization };\n\tconst resolvedUserId = useResolvedCurrentUserId(overrides.currentUserId);\n\n\tuseRouteLifecycle({\n\t\trouteName: \"resourceComments\",\n\t\tcontext: {\n\t\t\tpath: `/comments/${resourceType}/${resourceId}`,\n\t\t\tparams: { resourceId, resourceType },\n\t\t\tisSSR: typeof window === \"undefined\",\n\t\t},\n\t\toverrides,\n\t\tbeforeRenderHook: (o, context) => {\n\t\t\tif (o.onBeforeResourceCommentsRendered) {\n\t\t\t\treturn o.onBeforeResourceCommentsRendered(\n\t\t\t\t\tresourceType,\n\t\t\t\t\tresourceId,\n\t\t\t\t\tcontext,\n\t\t\t\t);\n\t\t\t}\n\t\t\treturn true;\n\t\t},\n\t});\n\n\treturn (\n\t\t\n\t\t\t\n\t\t\n\t);\n}\n", + "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/components/shared/pagination.tsx", + "type": "registry:component", + "content": "\"use client\";\n\nimport { usePluginOverrides } from \"@btst/stack/context\";\nimport type { CommentsPluginOverrides } from \"../../overrides\";\nimport { COMMENTS_LOCALIZATION } from \"../../localization\";\nimport { PaginationControls } from \"@/components/ui/pagination-controls\";\n\ninterface PaginationProps {\n\tcurrentPage: number;\n\ttotalPages: number;\n\tonPageChange: (page: number) => void;\n\ttotal: number;\n\tlimit: number;\n\toffset: number;\n}\n\nexport function Pagination({\n\tcurrentPage,\n\ttotalPages,\n\tonPageChange,\n\ttotal,\n\tlimit,\n\toffset,\n}: PaginationProps) {\n\tconst { localization: customLocalization } =\n\t\tusePluginOverrides(\"comments\");\n\tconst localization = { ...COMMENTS_LOCALIZATION, ...customLocalization };\n\n\treturn (\n\t\t\n\t);\n}\n", + "target": "src/components/btst/comments/client/components/shared/pagination.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_MODERATION_PAGINATION_PREVIOUS: \"Previous\",\n\tCOMMENTS_MODERATION_PAGINATION_NEXT: \"Next\",\n\tCOMMENTS_MODERATION_PAGINATION_SHOWING: \"Showing {from}–{to} of {total}\",\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 User Comments page and the per-resource comments admin view to\n\t * scope the comment list to the current user and to enable posting.\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 both pages show a \"Please log in\" prompt.\n\t */\n\tcurrentUserId?:\n\t\t| string\n\t\t| (() => string | undefined | Promise);\n\n\t/**\n\t * URL to redirect unauthenticated users to when they try to post a comment.\n\t *\n\t * Forwarded to every embedded `CommentThread` (including the one on the\n\t * per-resource admin comments view). When absent no login link is shown.\n\t */\n\tloginHref?: string;\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 * When false, the comment form and reply buttons are hidden in all\n\t * `CommentThread` instances. Users can still read existing comments.\n\t * Defaults to true.\n\t *\n\t * Can be overridden per-instance via the `allowPosting` prop on `CommentThread`.\n\t */\n\tallowPosting?: boolean;\n\n\t/**\n\t * When false, the edit button is hidden on all comment cards in all\n\t * `CommentThread` instances.\n\t * Defaults to true.\n\t *\n\t * Can be overridden per-instance via the `allowEditing` prop on `CommentThread`.\n\t */\n\tallowEditing?: boolean;\n\n\t/**\n\t * Per-resource-type URL builders used to link each comment back to its\n\t * original resource on the User 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 User 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\tonBeforeUserCommentsPageRendered?: (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": "btst/comments/client/utils.ts", + "type": "registry:lib", + "content": "import { useState, useEffect } from \"react\";\nimport type { CommentsPluginOverrides } from \"./overrides\";\n\n/**\n * Resolves `currentUserId` from the plugin overrides, supporting both a static\n * string and a sync/async function. Returns `undefined` until resolution completes.\n */\nexport function useResolvedCurrentUserId(\n\traw: CommentsPluginOverrides[\"currentUserId\"],\n): string | undefined {\n\tconst [resolved, setResolved] = useState(\n\t\ttypeof raw === \"string\" ? raw : undefined,\n\t);\n\n\tuseEffect(() => {\n\t\tif (typeof raw === \"function\") {\n\t\t\tvoid Promise.resolve(raw())\n\t\t\t\t.then((id) => setResolved(id ?? undefined))\n\t\t\t\t.catch((err: unknown) => {\n\t\t\t\t\tconsole.error(\n\t\t\t\t\t\t\"[btst/comments] Failed to resolve currentUserId:\",\n\t\t\t\t\t\terr,\n\t\t\t\t\t);\n\t\t\t\t});\n\t\t} else {\n\t\t\tsetResolved(raw ?? undefined);\n\t\t}\n\t}, [raw]);\n\n\treturn resolved;\n}\n\n/**\n * Normalise any thrown value into an Error.\n *\n * Handles three shapes:\n * 1. Already an Error — returned as-is.\n * 2. A plain object — message is taken from `.message`, then `.error` (API\n * error-response shape), then JSON.stringify. All original properties are\n * copied onto the Error via Object.assign so callers can inspect them.\n * 3. Anything else — converted via String().\n */\nexport function toError(error: unknown): Error {\n\tif (error instanceof Error) return error;\n\tif (typeof error === \"object\" && error !== null) {\n\t\tconst obj = error as Record;\n\t\tconst message =\n\t\t\t(typeof obj.message === \"string\" ? obj.message : null) ||\n\t\t\t(typeof obj.error === \"string\" ? obj.error : null) ||\n\t\t\tJSON.stringify(error);\n\t\tconst err = new Error(message);\n\t\tObject.assign(err, error);\n\t\treturn err;\n\t}\n\treturn new Error(String(error));\n}\n\nexport function getInitials(name: string | null | undefined): string {\n\tif (!name) return \"?\";\n\treturn name\n\t\t.split(\" \")\n\t\t.filter(Boolean)\n\t\t.slice(0, 2)\n\t\t.map((n) => n[0])\n\t\t.join(\"\")\n\t\t.toUpperCase();\n}\n", + "target": "src/components/btst/comments/client/utils.ts" + }, + { + "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..18f06e5f 100644 --- a/packages/stack/registry/btst-kanban.json +++ b/packages/stack/registry/btst-kanban.json @@ -61,7 +61,7 @@ { "path": "btst/kanban/client/components/forms/task-form.tsx", "type": "registry:component", - "content": "\"use client\";\n\nimport { useState } from \"react\";\nimport { Trash2 } from \"lucide-react\";\nimport { Button } from \"@/components/ui/button\";\nimport { Input } from \"@/components/ui/input\";\nimport { Label } from \"@/components/ui/label\";\nimport {\n\tSelect,\n\tSelectContent,\n\tSelectItem,\n\tSelectTrigger,\n\tSelectValue,\n} from \"@/components/ui/select\";\nimport { MinimalTiptapEditor } from \"@/components/ui/minimal-tiptap\";\nimport SearchSelect from \"@/components/ui/search-select\";\nimport { useTaskMutations, useSearchUsers } from \"@btst/stack/plugins/kanban/client/hooks\";\nimport { PRIORITY_OPTIONS } from \"../../../utils\";\nimport type {\n\tSerializedColumn,\n\tSerializedTask,\n\tPriority,\n} from \"../../../types\";\n\ninterface TaskFormProps {\n\tcolumnId: string;\n\tboardId: string;\n\ttaskId?: string;\n\ttask?: SerializedTask;\n\tcolumns: SerializedColumn[];\n\tonClose: () => void;\n\tonSuccess: () => void;\n\tonDelete?: () => void;\n}\n\nexport function TaskForm({\n\tcolumnId,\n\tboardId,\n\ttaskId,\n\ttask,\n\tcolumns,\n\tonClose,\n\tonSuccess,\n\tonDelete,\n}: TaskFormProps) {\n\tconst isEditing = !!taskId;\n\tconst {\n\t\tcreateTask,\n\t\tupdateTask,\n\t\tmoveTask,\n\t\tisCreating,\n\t\tisUpdating,\n\t\tisDeleting,\n\t\tisMoving,\n\t} = useTaskMutations();\n\n\tconst [title, setTitle] = useState(task?.title || \"\");\n\tconst [description, setDescription] = useState(task?.description || \"\");\n\tconst [priority, setPriority] = useState(\n\t\ttask?.priority || \"MEDIUM\",\n\t);\n\tconst [selectedColumnId, setSelectedColumnId] = useState(\n\t\ttask?.columnId || columnId,\n\t);\n\tconst [assigneeId, setAssigneeId] = useState(task?.assigneeId || \"\");\n\tconst [error, setError] = useState(null);\n\n\t// Fetch available users for assignment\n\tconst { data: users = [] } = useSearchUsers(\"\", boardId);\n\tconst userOptions = [\n\t\t{ value: \"\", label: \"Unassigned\" },\n\t\t...users.map((user) => ({ value: user.id, label: user.name })),\n\t];\n\n\tconst isPending = isCreating || isUpdating || isDeleting || isMoving;\n\n\tconst handleSubmit = async (e: React.FormEvent) => {\n\t\te.preventDefault();\n\t\tsetError(null);\n\n\t\tif (!title.trim()) {\n\t\t\tsetError(\"Title is required\");\n\t\t\treturn;\n\t\t}\n\n\t\ttry {\n\t\t\tif (isEditing && taskId) {\n\t\t\t\tconst isColumnChanging =\n\t\t\t\t\ttask?.columnId && selectedColumnId !== task.columnId;\n\n\t\t\t\tif (isColumnChanging) {\n\t\t\t\t\t// When changing columns, we need two operations:\n\t\t\t\t\t// 1. Update task properties (title, description, priority, assigneeId)\n\t\t\t\t\t// 2. Move task to new column with proper order calculation\n\t\t\t\t\t//\n\t\t\t\t\t// To avoid partial failure confusion, we attempt both operations\n\t\t\t\t\t// but provide clear messaging if one succeeds and the other fails.\n\n\t\t\t\t\t// First update the task properties (title, description, priority, assigneeId)\n\t\t\t\t\t// If this fails, nothing is saved and the outer catch handles it\n\t\t\t\t\tawait updateTask(taskId, {\n\t\t\t\t\t\ttitle,\n\t\t\t\t\t\tdescription,\n\t\t\t\t\t\tpriority,\n\t\t\t\t\t\tassigneeId: assigneeId || null,\n\t\t\t\t\t});\n\n\t\t\t\t\t// Then move the task to the new column with calculated order\n\t\t\t\t\t// Place at the end of the destination column\n\t\t\t\t\ttry {\n\t\t\t\t\t\tconst targetColumn = columns.find((c) => c.id === selectedColumnId);\n\t\t\t\t\t\tconst targetTasks = targetColumn?.tasks || [];\n\t\t\t\t\t\tconst targetOrder =\n\t\t\t\t\t\t\ttargetTasks.length > 0\n\t\t\t\t\t\t\t\t? Math.max(...targetTasks.map((t) => t.order)) + 1\n\t\t\t\t\t\t\t\t: 0;\n\n\t\t\t\t\t\tawait moveTask(taskId, selectedColumnId, targetOrder);\n\t\t\t\t\t} catch (moveErr) {\n\t\t\t\t\t\t// Properties were saved but column move failed\n\t\t\t\t\t\t// Provide specific error message about partial success\n\t\t\t\t\t\tconst moveErrorMsg =\n\t\t\t\t\t\t\tmoveErr instanceof Error ? moveErr.message : \"Unknown error\";\n\t\t\t\t\t\tsetError(\n\t\t\t\t\t\t\t`Task properties were saved, but moving to the new column failed: ${moveErrorMsg}. ` +\n\t\t\t\t\t\t\t\t`You can try dragging the task to the desired column.`,\n\t\t\t\t\t\t);\n\t\t\t\t\t\t// Don't call onSuccess since the operation wasn't fully completed\n\t\t\t\t\t\t// but also don't throw - we want to show the specific error\n\t\t\t\t\t\treturn;\n\t\t\t\t\t}\n\t\t\t\t} else {\n\t\t\t\t\t// Same column - just update the task properties\n\t\t\t\t\tawait updateTask(taskId, {\n\t\t\t\t\t\ttitle,\n\t\t\t\t\t\tdescription,\n\t\t\t\t\t\tpriority,\n\t\t\t\t\t\tcolumnId: selectedColumnId,\n\t\t\t\t\t\tassigneeId: assigneeId || null,\n\t\t\t\t\t});\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\tawait createTask({\n\t\t\t\t\ttitle,\n\t\t\t\t\tdescription,\n\t\t\t\t\tpriority,\n\t\t\t\t\tcolumnId: selectedColumnId,\n\t\t\t\t\tassigneeId: assigneeId || undefined,\n\t\t\t\t});\n\t\t\t}\n\t\t\tonSuccess();\n\t\t} catch (err) {\n\t\t\tsetError(err instanceof Error ? err.message : \"An error occurred\");\n\t\t}\n\t};\n\n\treturn (\n\t\t\n\t\t\t\n\t\t\t\tTitle *\n\t\t\t\t) =>\n\t\t\t\t\t\tsetTitle(e.target.value)\n\t\t\t\t\t}\n\t\t\t\t\tplaceholder=\"e.g., Fix login bug\"\n\t\t\t\t\tdisabled={isPending}\n\t\t\t\t/>\n\t\t\t\n\n\t\t\t\n\t\t\t\t\n\t\t\t\t\tPriority\n\t\t\t\t\t setPriority(v as Priority)}\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\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t{PRIORITY_OPTIONS.map((option) => (\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t{option.label}\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\n\t\t\t\t\n\t\t\t\t\tColumn\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\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t{columns.map((col) => (\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t{col.title}\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\n\t\t\t\tAssignee\n\t\t\t\t\n\t\t\t\n\n\t\t\t\n\t\t\t\tDescription\n\t\t\t\t\n\t\t\t\t\t\tsetDescription(typeof value === \"string\" ? value : \"\")\n\t\t\t\t\t}\n\t\t\t\t\toutput=\"markdown\"\n\t\t\t\t\tplaceholder=\"Describe the task...\"\n\t\t\t\t\teditable={!isPending}\n\t\t\t\t\tclassName=\"min-h-[150px]\"\n\t\t\t\t/>\n\t\t\t\n\n\t\t\t{error && (\n\t\t\t\t\n\t\t\t\t\t{error}\n\t\t\t\t\n\t\t\t)}\n\n\t\t\t\n\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\t{isPending\n\t\t\t\t\t\t\t? isEditing\n\t\t\t\t\t\t\t\t? \"Updating...\"\n\t\t\t\t\t\t\t\t: \"Creating...\"\n\t\t\t\t\t\t\t: isEditing\n\t\t\t\t\t\t\t\t? \"Update Task\"\n\t\t\t\t\t\t\t\t: \"Create Task\"}\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\n\t\t\t\t\n\t\t\t\t{isEditing && onDelete && (\n\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\tDelete\n\t\t\t\t\t\n\t\t\t\t)}\n\t\t\t\n\t\t\n\t);\n}\n", + "content": "\"use client\";\n\nimport { useState } from \"react\";\nimport { Trash2 } from \"lucide-react\";\nimport { Button } from \"@/components/ui/button\";\nimport { Input } from \"@/components/ui/input\";\nimport { Label } from \"@/components/ui/label\";\nimport {\n\tSelect,\n\tSelectContent,\n\tSelectItem,\n\tSelectTrigger,\n\tSelectValue,\n} from \"@/components/ui/select\";\nimport { MinimalTiptapEditor } from \"@/components/ui/minimal-tiptap\";\nimport SearchSelect from \"@/components/ui/search-select\";\nimport { useTaskMutations, useSearchUsers } from \"@btst/stack/plugins/kanban/client/hooks\";\nimport { PRIORITY_OPTIONS } from \"../../../utils\";\nimport type {\n\tSerializedColumn,\n\tSerializedTask,\n\tPriority,\n} from \"../../../types\";\n\ninterface TaskFormProps {\n\tcolumnId: string;\n\tboardId: string;\n\ttaskId?: string;\n\ttask?: SerializedTask;\n\tcolumns: SerializedColumn[];\n\tonClose: () => void;\n\tonSuccess: () => void;\n\tonDelete?: () => void;\n}\n\nexport function TaskForm({\n\tcolumnId,\n\tboardId,\n\ttaskId,\n\ttask,\n\tcolumns,\n\tonClose,\n\tonSuccess,\n\tonDelete,\n}: TaskFormProps) {\n\tconst isEditing = !!taskId;\n\tconst {\n\t\tcreateTask,\n\t\tupdateTask,\n\t\tmoveTask,\n\t\tisCreating,\n\t\tisUpdating,\n\t\tisDeleting,\n\t\tisMoving,\n\t} = useTaskMutations();\n\n\tconst [title, setTitle] = useState(task?.title || \"\");\n\tconst [description, setDescription] = useState(task?.description || \"\");\n\tconst [priority, setPriority] = useState(\n\t\ttask?.priority || \"MEDIUM\",\n\t);\n\tconst [selectedColumnId, setSelectedColumnId] = useState(\n\t\ttask?.columnId || columnId,\n\t);\n\tconst [assigneeId, setAssigneeId] = useState(task?.assigneeId || \"\");\n\tconst [error, setError] = useState(null);\n\n\t// Fetch available users for assignment\n\tconst { data: users = [] } = useSearchUsers(\"\", boardId);\n\tconst userOptions = [\n\t\t{ value: \"\", label: \"Unassigned\" },\n\t\t...users.map((user) => ({ value: user.id, label: user.name })),\n\t];\n\n\tconst isPending = isCreating || isUpdating || isDeleting || isMoving;\n\n\tconst handleSubmit = async (e: React.FormEvent) => {\n\t\te.preventDefault();\n\t\tsetError(null);\n\n\t\tif (!title.trim()) {\n\t\t\tsetError(\"Title is required\");\n\t\t\treturn;\n\t\t}\n\n\t\ttry {\n\t\t\tif (isEditing && taskId) {\n\t\t\t\tconst isColumnChanging =\n\t\t\t\t\ttask?.columnId && selectedColumnId !== task.columnId;\n\n\t\t\t\tif (isColumnChanging) {\n\t\t\t\t\t// When changing columns, we need two operations:\n\t\t\t\t\t// 1. Update task properties (title, description, priority, assigneeId)\n\t\t\t\t\t// 2. Move task to new column with proper order calculation\n\t\t\t\t\t//\n\t\t\t\t\t// To avoid partial failure confusion, we attempt both operations\n\t\t\t\t\t// but provide clear messaging if one succeeds and the other fails.\n\n\t\t\t\t\t// First update the task properties (title, description, priority, assigneeId)\n\t\t\t\t\t// If this fails, nothing is saved and the outer catch handles it\n\t\t\t\t\tawait updateTask(taskId, {\n\t\t\t\t\t\ttitle,\n\t\t\t\t\t\tdescription,\n\t\t\t\t\t\tpriority,\n\t\t\t\t\t\tassigneeId: assigneeId || null,\n\t\t\t\t\t});\n\n\t\t\t\t\t// Then move the task to the new column with calculated order\n\t\t\t\t\t// Place at the end of the destination column\n\t\t\t\t\ttry {\n\t\t\t\t\t\tconst targetColumn = columns.find((c) => c.id === selectedColumnId);\n\t\t\t\t\t\tconst targetTasks = targetColumn?.tasks || [];\n\t\t\t\t\t\tconst targetOrder =\n\t\t\t\t\t\t\ttargetTasks.length > 0\n\t\t\t\t\t\t\t\t? Math.max(...targetTasks.map((t) => t.order)) + 1\n\t\t\t\t\t\t\t\t: 0;\n\n\t\t\t\t\t\tawait moveTask(taskId, selectedColumnId, targetOrder);\n\t\t\t\t\t} catch (moveErr) {\n\t\t\t\t\t\t// Properties were saved but column move failed\n\t\t\t\t\t\t// Provide specific error message about partial success\n\t\t\t\t\t\tconst moveErrorMsg =\n\t\t\t\t\t\t\tmoveErr instanceof Error ? moveErr.message : \"Unknown error\";\n\t\t\t\t\t\tsetError(\n\t\t\t\t\t\t\t`Task properties were saved, but moving to the new column failed: ${moveErrorMsg}. ` +\n\t\t\t\t\t\t\t\t`You can try dragging the task to the desired column.`,\n\t\t\t\t\t\t);\n\t\t\t\t\t\t// Don't call onSuccess since the operation wasn't fully completed\n\t\t\t\t\t\t// but also don't throw - we want to show the specific error\n\t\t\t\t\t\treturn;\n\t\t\t\t\t}\n\t\t\t\t} else {\n\t\t\t\t\t// Same column - just update the task properties\n\t\t\t\t\tawait updateTask(taskId, {\n\t\t\t\t\t\ttitle,\n\t\t\t\t\t\tdescription,\n\t\t\t\t\t\tpriority,\n\t\t\t\t\t\tcolumnId: selectedColumnId,\n\t\t\t\t\t\tassigneeId: assigneeId || null,\n\t\t\t\t\t});\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\tawait createTask({\n\t\t\t\t\ttitle,\n\t\t\t\t\tdescription,\n\t\t\t\t\tpriority,\n\t\t\t\t\tcolumnId: selectedColumnId,\n\t\t\t\t\tassigneeId: assigneeId || undefined,\n\t\t\t\t});\n\t\t\t}\n\t\t\tonSuccess();\n\t\t} catch (err) {\n\t\t\tsetError(err instanceof Error ? err.message : \"An error occurred\");\n\t\t}\n\t};\n\n\treturn (\n\t\t\n\t\t\t\n\t\t\t\tTitle *\n\t\t\t\t) =>\n\t\t\t\t\t\tsetTitle(e.target.value)\n\t\t\t\t\t}\n\t\t\t\t\tplaceholder=\"e.g., Fix login bug\"\n\t\t\t\t\tdisabled={isPending}\n\t\t\t\t/>\n\t\t\t\n\n\t\t\t\n\t\t\t\t\n\t\t\t\t\tPriority\n\t\t\t\t\t setPriority(v as Priority)}\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\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t{PRIORITY_OPTIONS.map((option) => (\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t{option.label}\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\n\t\t\t\t\n\t\t\t\t\tColumn\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\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t{columns.map((col) => (\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t{col.title}\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\n\t\t\t\tAssignee\n\t\t\t\t\n\t\t\t\n\n\t\t\t\n\t\t\t\tDescription\n\t\t\t\t\n\t\t\t\t\t\tsetDescription(typeof value === \"string\" ? value : \"\")\n\t\t\t\t\t}\n\t\t\t\t\toutput=\"markdown\"\n\t\t\t\t\tplaceholder=\"Describe the task...\"\n\t\t\t\t\tclassName=\"min-h-[150px]\"\n\t\t\t\t/>\n\t\t\t\n\n\t\t\t{error && (\n\t\t\t\t\n\t\t\t\t\t{error}\n\t\t\t\t\n\t\t\t)}\n\n\t\t\t\n\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\t{isPending\n\t\t\t\t\t\t\t? isEditing\n\t\t\t\t\t\t\t\t? \"Updating...\"\n\t\t\t\t\t\t\t\t: \"Creating...\"\n\t\t\t\t\t\t\t: isEditing\n\t\t\t\t\t\t\t\t? \"Update Task\"\n\t\t\t\t\t\t\t\t: \"Create Task\"}\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\n\t\t\t\t\n\t\t\t\t{isEditing && onDelete && (\n\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\tDelete\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/forms/task-form.tsx" }, { @@ -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..474ac406 100755 --- a/packages/stack/scripts/test-registry.sh +++ b/packages/stack/scripts/test-registry.sh @@ -47,7 +47,7 @@ SERVER_PORT=8766 SERVER_PID="" TEST_PASSED=false -PLUGIN_NAMES=("blog" "ai-chat" "cms" "form-builder" "kanban" "ui-builder") +PLUGIN_NAMES=("blog" "ai-chat" "cms" "form-builder" "kanban" "comments" "ui-builder") # --------------------------------------------------------------------------- # Cleanup @@ -71,6 +71,15 @@ cleanup() { } trap cleanup EXIT +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- +pause() { + local seconds="${1:-20}" + echo "Waiting ${seconds}s…" + sleep "$seconds" +} + # --------------------------------------------------------------------------- # Main # --------------------------------------------------------------------------- @@ -112,7 +121,7 @@ main() { npx --yes http-server "$REGISTRY_DIR" -p $SERVER_PORT -c-1 --silent & SERVER_PID=$! - # Wait for server to be ready (up to 15s) + # Wait for server to be ready (up to 15s), then an extra 20s for stability for i in $(seq 1 15); do if curl -sf "http://localhost:$SERVER_PORT/btst-blog.json" > /dev/null 2>&1; then break @@ -124,6 +133,7 @@ main() { fi done success "HTTP server running (PID: $SERVER_PID)" + pause 20 # ------------------------------------------------------------------ step "4 — Packing @btst/stack with npm pack" @@ -219,7 +229,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 +240,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" @@ -246,7 +256,48 @@ console.log('tsconfig.json patched'); fi # ------------------------------------------------------------------ - step "7b — Patching external registry files with known type errors" + step "7b — Pinning tiptap packages to 3.20.1" + # ------------------------------------------------------------------ + # Must run AFTER all `shadcn add` calls so that tiptap packages are already + # present as direct dependencies — setting npm overrides for packages that + # are not yet direct deps and then having shadcn add them afterwards causes + # EOVERRIDE, which silently aborts the shadcn install and leaves plugin + # files (boards-list-page, page-list-page, …) unwritten. + node -e " +const fs = require('fs'); +const pkg = JSON.parse(fs.readFileSync('./package.json', 'utf8')); +const V = '3.20.1'; +const pkgs = [ + '@tiptap/core','@tiptap/react','@tiptap/pm','@tiptap/starter-kit', + '@tiptap/extensions','@tiptap/markdown', + '@tiptap/extension-blockquote','@tiptap/extension-bold', + '@tiptap/extension-bubble-menu','@tiptap/extension-bullet-list', + '@tiptap/extension-code','@tiptap/extension-code-block', + '@tiptap/extension-code-block-lowlight','@tiptap/extension-color', + '@tiptap/extension-document','@tiptap/extension-dropcursor', + '@tiptap/extension-floating-menu','@tiptap/extension-gapcursor', + '@tiptap/extension-hard-break','@tiptap/extension-heading', + '@tiptap/extension-horizontal-rule','@tiptap/extension-image', + '@tiptap/extension-italic','@tiptap/extension-link', + '@tiptap/extension-list','@tiptap/extension-list-item', + '@tiptap/extension-list-keymap','@tiptap/extension-ordered-list', + '@tiptap/extension-paragraph','@tiptap/extension-strike', + '@tiptap/extension-table','@tiptap/extension-text', + '@tiptap/extension-text-style','@tiptap/extension-typography', + '@tiptap/extension-underline' +]; +pkg.overrides = pkg.overrides || {}; +for (const p of pkgs) { + if (pkg.dependencies?.[p]) pkg.dependencies[p] = V; + pkg.overrides[p] = V; +} +fs.writeFileSync('./package.json', JSON.stringify(pkg, null, 2)); +console.log('package.json updated with tiptap overrides'); +" + success "Tiptap overrides written (npm install runs in step 8)" + + # ------------------------------------------------------------------ + step "7c — Patching external registry files with known type errors" # ------------------------------------------------------------------ # Some files installed from external registries (e.g. the ui-builder component) # have TypeScript issues we cannot fix in their source. Add @ts-nocheck to @@ -262,7 +313,7 @@ console.log('tsconfig.json patched'); add_ts_nocheck "src/components/ui/minimal-tiptap/components/image/image-edit-block.tsx" # ------------------------------------------------------------------ - step "7c — Creating smoke-import page to force TypeScript to compile all plugin files" + step "7d — Creating smoke-import page to force TypeScript to compile all plugin files" # ------------------------------------------------------------------ # Without this page, `next build` only type-checks files reachable from # existing pages. Installed plugin components are never imported, so missing @@ -280,11 +331,12 @@ import { ChatPageComponent } from "@/components/btst/ai-chat/client/components/p import { DashboardPageComponent } from "@/components/btst/cms/client/components/pages/dashboard-page"; import { FormListPageComponent } from "@/components/btst/form-builder/client/components/pages/form-list-page"; import { BoardsListPageComponent } from "@/components/btst/kanban/client/components/pages/boards-list-page"; +import { ModerationPageComponent } from "@/components/btst/comments/client/components/pages/moderation-page"; import { PageListPage } from "@/components/btst/ui-builder/client/components/pages/page-list-page"; // Suppress unused-import warnings while still forcing TS to resolve everything. void [HomePageComponent, ChatPageComponent, DashboardPageComponent, - FormListPageComponent, BoardsListPageComponent, PageListPage]; + FormListPageComponent, BoardsListPageComponent, ModerationPageComponent, PageListPage]; export default function SmokeTestPage() { return Registry smoke test — all plugin imports resolved.; 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..1656ee2a --- /dev/null +++ b/packages/stack/src/plugins/comments/api/getters.ts @@ -0,0 +1,444 @@ +import type { DBAdapter as Adapter } from "@btst/db"; +import type { + Comment, + CommentLike, + CommentListResult, + SerializedComment, +} from "../types"; +import type { z } from "zod"; +import type { + CommentListParamsSchema, + 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" | "lt" | "gt"; +}; + +/** + * 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 the current user's own pending comments (always a small, bounded + // set — typically 0–5 per user per resource). Then paginate approved + // comments entirely at the DB level by computing each pending comment's + // exact position in the merged sorted list. + // + // Algorithm: + // For each pending p_i (sorted, 0-indexed): + // mergedPosition[i] = countApprovedBefore(p_i) + i + // where countApprovedBefore uses a `lt`/`gt` DB count on createdAt. + // This lets us derive the exact approvedOffset and approvedLimit for + // the requested page without loading the full approved set. + const [ownPendingAll, approvedCount] = await Promise.all([ + adapter.findMany({ + model: "comment", + where: [ + ...baseConditions, + { field: "status", value: "pending", operator: "eq" }, + { field: "authorId", value: params.currentUserId, operator: "eq" }, + ], + sortBy: { field: "createdAt", direction: sortDirection }, + }), + adapter.count({ + model: "comment", + where: [ + ...baseConditions, + { field: "status", value: "approved", operator: "eq" }, + ], + }), + ]); + + total = approvedCount + ownPendingAll.length; + + if (ownPendingAll.length === 0) { + // Fast path: no pending — paginate approved directly. + comments = await adapter.findMany({ + model: "comment", + limit, + offset, + where: [ + ...baseConditions, + { field: "status", value: "approved", operator: "eq" }, + ], + sortBy: { field: "createdAt", direction: sortDirection }, + }); + } else { + // For each pending comment, count how many approved records precede + // it in the merged sort order. The adapter supports `lt`/`gt` on + // date fields, so this is a single count query per pending comment + // (N_pending is tiny, so O(N_pending) queries is acceptable). + const dateOp = sortDirection === "asc" ? "lt" : "gt"; + const pendingWithPositions = await Promise.all( + ownPendingAll.map(async (p, i) => { + const approvedBefore = await adapter.count({ + model: "comment", + where: [ + ...baseConditions, + { field: "status", value: "approved", operator: "eq" }, + { + field: "createdAt", + value: p.createdAt, + operator: dateOp, + }, + ], + }); + return { comment: p, mergedPosition: approvedBefore + i }; + }), + ); + + // Partition pending into those that fall within [offset, offset+limit). + const pendingInWindow = pendingWithPositions.filter( + ({ mergedPosition }) => + mergedPosition >= offset && mergedPosition < offset + limit, + ); + const countPendingBeforeWindow = pendingWithPositions.filter( + ({ mergedPosition }) => mergedPosition < offset, + ).length; + + const approvedOffset = Math.max(0, offset - countPendingBeforeWindow); + const approvedLimit = limit - pendingInWindow.length; + + const approvedPage = + approvedLimit > 0 + ? await adapter.findMany({ + model: "comment", + limit: approvedLimit, + offset: approvedOffset, + where: [ + ...baseConditions, + { field: "status", value: "approved", operator: "eq" }, + ], + sortBy: { field: "createdAt", direction: sortDirection }, + }) + : []; + + // Merge the approved page with the pending slice and re-sort. + const merged = [ + ...approvedPage, + ...pendingInWindow.map(({ comment }) => comment), + ]; + merged.sort((a, b) => { + const diff = a.createdAt.getTime() - b.createdAt.getTime(); + return sortDirection === "desc" ? -diff : diff; + }); + comments = merged; + } + } 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..32feca66 --- /dev/null +++ b/packages/stack/src/plugins/comments/api/mutations.ts @@ -0,0 +1,206 @@ +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, cascading to any child replies. + * + * Replies reference the parent via `parentId`. Because the schema declares no + * DB-level cascade on `comment.parentId`, orphaned replies must be removed here + * in the application layer. `commentLike` rows are covered by the FK cascade + * on `commentLike.commentId` (declared in `db.ts`). + * + * Comments are only one level deep (the UI prevents replying to replies), so a + * single-level cascade is sufficient — no recursive walk is needed. + * + * @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.transaction(async (tx) => { + // Remove child replies first so they don't become orphans. + // Their commentLike rows are cleaned up by the FK cascade on commentLike.commentId. + await tx.delete({ + model: "comment", + where: [{ field: "parentId", value: id, operator: "eq" }], + }); + + // Remove the comment itself (its commentLike rows cascade via FK). + await tx.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..4f826941 --- /dev/null +++ b/packages/stack/src/plugins/comments/api/plugin.ts @@ -0,0 +1,628 @@ +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, + CommentListParamsSchema, + 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; +} + +/** Shared hook and config fields that are always present regardless of allowPosting. */ +interface CommentsBackendOptionsBase { + /** + * When true, new comments are automatically approved (status: "approved"). + * Default: false — all comments start as "pending" until a moderator approves. + */ + autoApprove?: boolean; + + /** + * When false, the `PATCH /comments/:id` endpoint is not registered and + * comment bodies cannot be edited. + * Default: true. + */ + allowEditing?: 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 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. + * 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. + */ + onBeforeListByAuthor?: ( + authorId: string, + query: z.infer, + context: CommentsApiContext, + ) => Promise | void; +} + +/** + * Configuration options for the comments backend plugin. + * + * TypeScript enforces the security-critical hooks based on `allowPosting`: + * - When `allowPosting` is absent or `true`, `onBeforePost` and + * `resolveCurrentUserId` are **required**. + * - When `allowPosting` is `false`, both become optional (the POST endpoint + * is not registered so neither hook is ever called). + */ +export type CommentsBackendOptions = CommentsBackendOptionsBase & + ( + | { + /** + * Posting is enabled (default). `onBeforePost` and `resolveCurrentUserId` + * are required to prevent anonymous authorship and impersonation. + */ + allowPosting?: true; + + /** + * 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. + */ + onBeforePost: ( + input: z.infer, + context: CommentsApiContext, + ) => Promise<{ authorId: string }> | { authorId: string }; + + /** + * Resolve the current authenticated user's ID from the request context + * (e.g. session cookie or JWT). Used to include the user's own pending + * comments alongside approved ones in `GET /comments` responses so they + * remain visible immediately after posting. + * + * Return `null` or `undefined` for unauthenticated requests. + * + * ```ts + * resolveCurrentUserId: async (ctx) => { + * const session = await getSession(ctx.headers) + * return session?.user?.id ?? null + * } + * ``` + */ + resolveCurrentUserId: ( + context: CommentsApiContext, + ) => Promise | string | null | undefined; + } + | { + /** + * When `false`, the `POST /comments` endpoint is not registered. + * No new comments or replies can be submitted — users can only read + * existing comments. `onBeforePost` and `resolveCurrentUserId` become + * optional because they are never called. + */ + allowPosting: false; + onBeforePost?: ( + input: z.infer, + context: CommentsApiContext, + ) => Promise<{ authorId: string }> | { authorId: string }; + resolveCurrentUserId?: ( + context: CommentsApiContext, + ) => Promise | string | null | undefined; + } + ); + +export const commentsBackendPlugin = (options: CommentsBackendOptions) => { + const postingEnabled = options.allowPosting !== false; + const editingEnabled = options.allowEditing !== false; + + // Narrow once so closures below see fully-typed (non-optional) hooks. + // TypeScript resolves onBeforePost / resolveCurrentUserId as required in + // the allowPosting?: true branch, so these will be Hook | undefined — but + // we only call them when postingEnabled is true. + const onBeforePost = + options.allowPosting !== false ? options.onBeforePost : undefined; + const resolveCurrentUserId = + options.allowPosting !== false ? options.resolveCurrentUserId : undefined; + + 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, + request: ctx.request, + headers: ctx.headers, + }; + + 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", + ); + } + + 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) { + await runHookWithShim( + () => options.onBeforeList!(ctx.query, context), + ctx.error, + "Forbidden: Cannot list comments", + ); + } + + let resolvedCurrentUserId: string | undefined; + if (resolveCurrentUserId) { + try { + const result = await resolveCurrentUserId(context); + resolvedCurrentUserId = result ?? undefined; + } catch { + resolvedCurrentUserId = undefined; + } + } + + return await listComments( + adapter, + { ...ctx.query, currentUserId: resolvedCurrentUserId }, + options?.resolveUser, + ); + }, + ); + + // POST /comments + const createCommentEndpoint = createEndpoint( + "/comments", + { + method: "POST", + body: createCommentSchema, + }, + async (ctx) => { + if (!postingEnabled) { + throw ctx.error(403, { message: "Posting comments is disabled" }); + } + + const context: CommentsApiContext = { + body: ctx.body, + headers: ctx.headers, + }; + + const { authorId } = await runHookWithShim( + () => 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); + } + + const serialized = await getCommentById( + adapter, + comment.id, + options?.resolveUser, + ); + if (!serialized) { + throw ctx.error(500, { + message: "Failed to retrieve created comment", + }); + } + return serialized; + }, + ); + + // PATCH /comments/:id (edit body) + const updateCommentEndpoint = createEndpoint( + "/comments/:id", + { + method: "PATCH", + body: updateCommentSchema, + }, + async (ctx) => { + if (!editingEnabled) { + throw ctx.error(403, { message: "Editing comments is disabled" }); + } + + const { id } = ctx.params; + const context: CommentsApiContext = { + params: ctx.params, + body: ctx.body, + headers: ctx.headers, + }; + + 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); + } + + const serialized = await getCommentById( + adapter, + updated.id, + options?.resolveUser, + ); + if (!serialized) { + throw ctx.error(500, { + message: "Failed to retrieve updated comment", + }); + } + return serialized; + }, + ); + + // GET /comments/count + const getCommentCountEndpoint = createEndpoint( + "/comments/count", + { + method: "GET", + query: CommentCountQuerySchema, + }, + async (ctx) => { + const context: CommentsApiContext = { + query: ctx.query, + headers: ctx.headers, + }; + + 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 }; + }, + ); + + // 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, + }; + + 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; + }, + ); + + // 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, + }; + + 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); + } + + const serialized = await getCommentById( + adapter, + updated.id, + options?.resolveUser, + ); + if (!serialized) { + throw ctx.error(500, { + message: "Failed to retrieve updated comment", + }); + } + return serialized; + }, + ); + + // 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, + }; + + 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 }; + }, + ); + + return { + listComments: listCommentsEndpoint, + ...(postingEnabled && { createComment: createCommentEndpoint }), + ...(editingEnabled && { 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..56df86c6 --- /dev/null +++ b/packages/stack/src/plugins/comments/client/components/comment-form.tsx @@ -0,0 +1,112 @@ +"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..ddca9433 --- /dev/null +++ b/packages/stack/src/plugins/comments/client/components/comment-thread.tsx @@ -0,0 +1,799 @@ +"use client"; + +import { useEffect, 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, + X, + LogIn, + ChevronDown, + ChevronUp, +} from "lucide-react"; +import { formatDistanceToNow } from "date-fns"; +import type { SerializedComment } from "../../types"; +import { getInitials } from "../utils"; +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; + /** + * When false, the comment form and reply buttons are hidden. + * Overrides the global `allowPosting` from `CommentsPluginOverrides`. + * Defaults to true. + */ + allowPosting?: boolean; + /** + * When false, the edit button is hidden on comment cards. + * Overrides the global `allowEditing` from `CommentsPluginOverrides`. + * Defaults to true. + */ + allowEditing?: boolean; +} + +const DEFAULT_RENDERER: ComponentType = ({ body }) => ( + {body} +); + +// ─── Comment Card ───────────────────────────────────────────────────────────── + +function CommentCard({ + comment, + currentUserId, + apiBaseURL, + apiBasePath, + resourceId, + resourceType, + headers, + components, + loc, + infiniteKey, + onReplyClick, + allowPosting, + allowEditing, +}: { + 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; + allowPosting: boolean; + allowEditing: boolean; +}) { + 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} + )} + + )} + + {allowPosting && + currentUserId && + !comment.parentId && + isApproved && ( + onReplyClick(comment.id)} + data-testid="reply-button" + > + + {loc.COMMENTS_REPLY_BUTTON} + + )} + + {isOwn && ( + <> + {allowEditing && isApproved && ( + setIsEditing(true)} + data-testid="edit-button" + > + + {loc.COMMENTS_EDIT_BUTTON} + + )} + + + {loc.COMMENTS_DELETE_BUTTON} + + > + )} + + )} + + + ); +} + +// ─── Thread Inner (handles data) ────────────────────────────────────────────── + +const DEFAULT_PAGE_SIZE = 100; +const REPLIES_PAGE_SIZE = 20; +const OPTIMISTIC_ID_PREFIX = "optimistic-"; + +function CommentThreadInner({ + resourceId, + resourceType, + apiBaseURL, + apiBasePath, + currentUserId, + loginHref, + headers, + components, + localization: localizationProp, + pageSize: pageSizeProp, + allowPosting: allowPostingProp, + allowEditing: allowEditingProp, +}: CommentThreadProps) { + const overrides = usePluginOverrides< + CommentsPluginOverrides, + Partial + >("comments", {}); + const pageSize = + pageSizeProp ?? overrides.defaultCommentPageSize ?? DEFAULT_PAGE_SIZE; + const allowPosting = allowPostingProp ?? overrides.allowPosting ?? true; + const allowEditing = allowEditingProp ?? overrides.allowEditing ?? true; + const loc = { ...COMMENTS_LOCALIZATION, ...localizationProp }; + const [replyingTo, setReplyingTo] = useState(null); + const [expandedReplies, setExpandedReplies] = useState>( + new Set(), + ); + const [replyOffsets, setReplyOffsets] = useState>({}); + + 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, + pageSize, + }); + + 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, + limit: REPLIES_PAGE_SIZE, + offset: replyOffsets[parentId] ?? 0, + }); + 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); + }} + allowPosting={allowPosting} + allowEditing={allowEditing} + /> + + {/* Replies */} + { + const isExpanded = expandedReplies.has(comment.id); + if (!isExpanded) { + setReplyOffsets((prev) => { + if ((prev[comment.id] ?? 0) === 0) return prev; + return { ...prev, [comment.id]: 0 }; + }); + } + setExpandedReplies((prev) => { + const next = new Set(prev); + next.has(comment.id) + ? next.delete(comment.id) + : next.add(comment.id); + return next; + }); + }} + onOffsetChange={(offset) => { + setReplyOffsets((prev) => { + if (prev[comment.id] === offset) return prev; + return { ...prev, [comment.id]: offset }; + }); + }} + allowEditing={allowEditing} + /> + + {allowPosting && 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} + + + )} + + {allowPosting && ( + <> + + + {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, + onOffsetChange, + allowEditing, +}: { + 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; + onOffsetChange: (offset: number) => void; + allowEditing: boolean; +}) { + const config = { apiBaseURL, apiBasePath, headers }; + const [replyOffset, setReplyOffset] = useState(0); + const [loadedReplies, setLoadedReplies] = useState([]); + // Only fetch reply bodies once the section is expanded. + const { + comments: repliesPage, + total: repliesTotal, + isFetching: isFetchingReplies, + } = useComments( + config, + { + resourceId, + resourceType, + parentId, + status: "approved", + currentUserId, + limit: REPLIES_PAGE_SIZE, + offset: replyOffset, + }, + { enabled: expanded }, + ); + + useEffect(() => { + if (expanded) { + setReplyOffset(0); + setLoadedReplies([]); + } + }, [expanded, parentId]); + + useEffect(() => { + onOffsetChange(replyOffset); + }, [onOffsetChange, replyOffset]); + + useEffect(() => { + if (!expanded) return; + setLoadedReplies((prev) => { + const byId = new Map(prev.map((item) => [item.id, item])); + for (const reply of repliesPage) { + byId.set(reply.id, reply); + } + + // Reconcile optimistic replies once the real server reply arrives with + // a different id. Without this, both entries can persist in local state + // until the section is collapsed and re-opened. + const currentPageIds = new Set(repliesPage.map((reply) => reply.id)); + const currentPageRealReplies = repliesPage.filter( + (reply) => !reply.id.startsWith(OPTIMISTIC_ID_PREFIX), + ); + + return Array.from(byId.values()).filter((reply) => { + if (!reply.id.startsWith(OPTIMISTIC_ID_PREFIX)) return true; + // Keep optimistic items still present in the current cache page. + if (currentPageIds.has(reply.id)) return true; + // Drop stale optimistic rows that have been replaced by a real reply. + return !currentPageRealReplies.some( + (realReply) => + realReply.parentId === reply.parentId && + realReply.authorId === reply.authorId && + realReply.body === reply.body, + ); + }); + }); + }, [expanded, repliesPage]); + + // 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 + ? loadedReplies.length || replyCount + : replyCount; + const effectiveReplyTotal = repliesTotal || replyCount; + const hasMoreReplies = loadedReplies.length < effectiveReplyTotal; + + return ( + + {/* Toggle button — always at the top so collapse is reachable without scrolling */} + + {expanded ? ( + + ) : ( + + )} + {expanded + ? loc.COMMENTS_HIDE_REPLIES + : `${displayCount} ${displayCount === 1 ? loc.COMMENTS_REPLIES_SINGULAR : loc.COMMENTS_REPLIES_PLURAL}`} + + {expanded && ( + + {loadedReplies.map((reply) => ( + {}} // No nested replies in v1 + allowPosting={false} + allowEditing={allowEditing} + /> + ))} + {hasMoreReplies && ( + + + setReplyOffset((prev) => prev + REPLIES_PAGE_SIZE) + } + disabled={isFetchingReplies} + data-testid="load-more-replies" + > + {isFetchingReplies + ? loc.COMMENTS_LOADING_MORE + : loc.COMMENTS_LOAD_MORE} + + + )} + + )} + + ); +} + +// ─── 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"> + + + + ); +} 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..9ddf021e --- /dev/null +++ b/packages/stack/src/plugins/comments/client/components/pages/moderation-page.internal.tsx @@ -0,0 +1,550 @@ +"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 { + useSuspenseModerationComments, + useUpdateCommentStatus, + useDeleteComment, +} from "../../hooks/use-comments"; +import { + COMMENTS_LOCALIZATION, + type CommentsLocalization, +} from "../../localization"; +import { getInitials } from "../../utils"; +import { Pagination } from "../shared/pagination"; + +interface ModerationPageProps { + apiBaseURL: string; + apiBasePath: string; + headers?: HeadersInit; + localization?: CommentsLocalization; +} + +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 [currentPage, setCurrentPage] = useState(1); + const [selected, setSelected] = useState>(new Set()); + const [viewComment, setViewComment] = useState( + null, + ); + const [deleteIds, setDeleteIds] = useState([]); + + const config = { apiBaseURL, apiBasePath, headers }; + + const { comments, total, limit, offset, totalPages, refetch } = + useSuspenseModerationComments(config, { + status: activeTab, + page: currentPage, + }); + + 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); + setCurrentPage(1); + 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..5cf09cc7 --- /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..cdebbdbd --- /dev/null +++ b/packages/stack/src/plugins/comments/client/components/pages/my-comments-page.internal.tsx @@ -0,0 +1,367 @@ +"use client"; + +import { useState } 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"; +import { getInitials, useResolvedCurrentUserId } from "../../utils"; + +const PAGE_LIMIT = 20; + +interface UserCommentsPageProps { + apiBaseURL: string; + apiBasePath: string; + headers?: HeadersInit; + currentUserId?: CommentsPluginOverrides["currentUserId"]; + resourceLinks?: CommentsPluginOverrides["resourceLinks"]; + localization?: CommentsLocalization; +} + +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} + + ); +} + +// ─── Main export ────────────────────────────────────────────────────────────── + +export function UserCommentsPage({ + apiBaseURL, + apiBasePath, + headers, + currentUserId: currentUserIdProp, + resourceLinks, + localization: localizationProp, +}: UserCommentsPageProps) { + 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 UserCommentsList({ + 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 resourceUrlBase = resourceLinks?.[comment.resourceType]?.( + comment.resourceId, + ); + const resourceUrl = resourceUrlBase + ? `${resourceUrlBase}#comments` + : undefined; + + 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..b3049d65 --- /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 UserCommentsPageInternal = lazy(() => + import("./my-comments-page.internal").then((m) => ({ + default: m.UserCommentsPage, + })), +); + +function UserCommentsPageSkeleton() { + return ( + + + + + + ); +} + +export function UserCommentsPageComponent() { + return ( + + console.error("[btst/comments] User Comments error:", error) + } + /> + ); +} + +function UserCommentsPageWrapper() { + const overrides = usePluginOverrides("comments"); + const loc = { ...COMMENTS_LOCALIZATION, ...overrides.localization }; + + useRouteLifecycle({ + routeName: "userComments", + context: { + path: "/comments", + isSSR: typeof window === "undefined", + }, + overrides, + beforeRenderHook: (o, context) => { + if (o.onBeforeUserCommentsPageRendered) { + const result = o.onBeforeUserCommentsPageRendered(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..f9db8e28 --- /dev/null +++ b/packages/stack/src/plugins/comments/client/components/pages/resource-comments-page.internal.tsx @@ -0,0 +1,225 @@ +"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"; +import { getInitials } from "../../utils"; + +interface ResourceCommentsPageProps { + resourceId: string; + resourceType: string; + apiBaseURL: string; + apiBasePath: string; + headers?: HeadersInit; + currentUserId?: string; + loginHref?: string; + localization?: CommentsLocalization; +} + +export function ResourceCommentsPage({ + resourceId, + resourceType, + apiBaseURL, + apiBasePath, + headers, + currentUserId, + loginHref, + 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..69ecc627 --- /dev/null +++ b/packages/stack/src/plugins/comments/client/components/pages/resource-comments-page.tsx @@ -0,0 +1,97 @@ +"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"; +import { useResolvedCurrentUserId } from "../../utils"; + +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 }; + const resolvedUserId = useResolvedCurrentUserId(overrides.currentUserId); + + 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/components/shared/pagination.tsx b/packages/stack/src/plugins/comments/client/components/shared/pagination.tsx new file mode 100644 index 00000000..5d1b7e50 --- /dev/null +++ b/packages/stack/src/plugins/comments/client/components/shared/pagination.tsx @@ -0,0 +1,44 @@ +"use client"; + +import { usePluginOverrides } from "@btst/stack/context"; +import type { CommentsPluginOverrides } from "../../overrides"; +import { COMMENTS_LOCALIZATION } from "../../localization"; +import { PaginationControls } from "@workspace/ui/components/pagination-controls"; + +interface PaginationProps { + currentPage: number; + totalPages: number; + onPageChange: (page: number) => void; + total: number; + limit: number; + offset: number; +} + +export function Pagination({ + currentPage, + totalPages, + onPageChange, + total, + limit, + offset, +}: PaginationProps) { + const { localization: customLocalization } = + usePluginOverrides("comments"); + const localization = { ...COMMENTS_LOCALIZATION, ...customLocalization }; + + return ( + + ); +} 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..5309c263 --- /dev/null +++ b/packages/stack/src/plugins/comments/client/hooks/index.tsx @@ -0,0 +1,13 @@ +export { + useComments, + useSuspenseComments, + useSuspenseModerationComments, + 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..a2779ee0 --- /dev/null +++ b/packages/stack/src/plugins/comments/client/hooks/use-comments.tsx @@ -0,0 +1,717 @@ +"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"; +import { toError } from "../utils"; + +interface CommentsClientConfig { + apiBaseURL: string; + apiBasePath: string; + headers?: HeadersInit; +} + +function getClient(config: CommentsClientConfig) { + return createApiClient({ + baseURL: config.apiBaseURL, + basePath: config.apiBasePath, + }); +} + +/** + * 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, + }; +} + +/** + * Page-based variant for the moderation dashboard. + * Uses useSuspenseQuery with explicit offset so the table always shows exactly + * one page of results and navigation is handled by Prev / Next controls. + */ +export function useSuspenseModerationComments( + config: CommentsClientConfig, + params: { + status?: "pending" | "approved" | "spam"; + limit?: number; + page?: number; + }, +) { + const limit = params.limit ?? 20; + const page = params.page ?? 1; + const offset = (page - 1) * limit; + + const client = getClient(config); + const queries = createCommentsQueryKeys(client, config.headers); + + const { data, refetch, error, isFetching } = useSuspenseQuery({ + ...queries.comments.list({ status: params.status, limit, offset }), + staleTime: 30_000, + retry: false, + }); + + if (error && !isFetching) { + throw error; + } + + const comments = data?.items ?? []; + const total = data?.total ?? 0; + const totalPages = Math.max(1, Math.ceil(total / limit)); + + return { + comments, + total, + limit, + offset, + totalPages, + 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[]; + /** + * Page size used by the corresponding `useInfiniteComments` call. + * Used only when the infinite-query cache is empty at the time of the + * optimistic update — ensures `getNextPageParam` computes the correct + * `nextOffset` from `lastPage.limit` instead of a hardcoded fallback. + */ + pageSize?: number; + }, +) { + 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, + offset?: number, + limit?: number, + ) => { + // 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, + limit, + offset, + }).queryKey; + }; + + const isInfinitePost = (parentId: string | null | undefined) => + !!params.infiniteKey && (parentId ?? null) === null; + + return useMutation({ + mutationFn: async (input: { + body: string; + parentId?: string | null; + limit?: number; + offset?: number; + }) => { + 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, input.offset, input.limit); + 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: params.pageSize ?? 10, + offset: 0, + }, + ], + pageParams: [0], + }; + } + const lastIdx = old.pages.length - 1; + return { + ...old, + // Increment `total` on every page so the header count (which reads + // pages[0].total) stays in sync even after multiple pages are loaded. + pages: old.pages.map((page, idx) => + idx === lastIdx + ? { + ...page, + items: [...page.items, optimistic], + total: page.total + 1, + } + : { ...page, total: page.total + 1 }, + ), + }; + }, + ); + + 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. + // + // For replies (non-infinite path): do NOT call invalidateQueries here. + // The setQueryData below already puts the authoritative server response + // (including the pending reply) in the cache. Invalidating would trigger + // a background refetch that goes to the server without auth context and + // returns only approved replies — overwriting the cache and making the + // pending reply disappear. + if (context.isInfinite) { + queryClient.setQueryData>( + context.listKey, + (old) => { + if (!old) { + // Cache was cleared between onMutate and onSuccess (rare). + // Seed with the real server response so the thread keeps the new comment. + return { + pages: [ + { + items: [data], + total: 1, + limit: _input.limit ?? params.pageSize ?? 10, + offset: _input.offset ?? 0, + }, + ], + pageParams: [_input.offset ?? 0], + }; + } + 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) { + // Cache was cleared between onMutate and onSuccess (rare). + // Seed it with the real server response so the reply stays visible. + return { + items: [data], + total: 1, + limit: _input.limit ?? params.pageSize ?? 20, + offset: _input.offset ?? 0, + }; + } + return { + ...old, + items: old.items.map((item) => + item.id === context.optimisticId ? data : item, + ), + }; + }); + } + }, + onError: (_err, _input, context) => { + if (!context) return; + queryClient.setQueryData(context.listKey, context.previous); + }, + }); +} + +/** + * 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, + }); + // Also invalidate the infinite thread cache so status changes are reflected there. + queryClient.invalidateQueries({ queryKey: ["commentsThread"] }); + }, + }); +} + +/** + * 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, + }); + // Also invalidate the infinite thread cache so status changes are reflected there. + queryClient.invalidateQueries({ queryKey: ["commentsThread"] }); + }, + }); +} + +/** + * 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..c294992d --- /dev/null +++ b/packages/stack/src/plugins/comments/client/localization/comments-moderation.ts @@ -0,0 +1,75 @@ +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_MODERATION_PAGINATION_PREVIOUS: "Previous", + COMMENTS_MODERATION_PAGINATION_NEXT: "Next", + COMMENTS_MODERATION_PAGINATION_SHOWING: "Showing {from}–{to} of {total}", + + 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..dbd82e3e --- /dev/null +++ b/packages/stack/src/plugins/comments/client/overrides.ts @@ -0,0 +1,164 @@ +/** + * 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 User Comments page and the per-resource comments admin view to + * scope the comment list to the current user and to enable posting. + * 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 both pages show a "Please log in" prompt. + */ + currentUserId?: + | string + | (() => string | undefined | Promise); + + /** + * URL to redirect unauthenticated users to when they try to post a comment. + * + * Forwarded to every embedded `CommentThread` (including the one on the + * per-resource admin comments view). When absent no login link is shown. + */ + loginHref?: string; + + /** + * 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; + + /** + * When false, the comment form and reply buttons are hidden in all + * `CommentThread` instances. Users can still read existing comments. + * Defaults to true. + * + * Can be overridden per-instance via the `allowPosting` prop on `CommentThread`. + */ + allowPosting?: boolean; + + /** + * When false, the edit button is hidden on all comment cards in all + * `CommentThread` instances. + * Defaults to true. + * + * Can be overridden per-instance via the `allowEditing` prop on `CommentThread`. + */ + allowEditing?: boolean; + + /** + * Per-resource-type URL builders used to link each comment back to its + * original resource on the User 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 User Comments page is rendered. + * Throw to block rendering (e.g., when the user is not authenticated). + * @param context - Route context + */ + onBeforeUserCommentsPageRendered?: (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..af3f4de9 --- /dev/null +++ b/packages/stack/src/plugins/comments/client/plugin.tsx @@ -0,0 +1,195 @@ +// 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 UserCommentsPageComponent = lazy(() => + import("./components/pages/my-comments-page").then((m) => ({ + default: m.UserCommentsPageComponent, + })), +); + +/** + * 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 User Comments page. Throw to cancel. + */ + beforeLoadUserComments?: (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 createUserCommentsLoader(config: CommentsClientConfig) { + return async () => { + if (typeof window === "undefined") { + const { apiBasePath, apiBaseURL, headers, hooks } = config; + const context: LoaderContext = { + path: "/comments", + isSSR: true, + apiBaseURL, + apiBasePath, + headers, + }; + try { + if (hooks?.beforeLoadUserComments) { + await hooks.beforeLoadUserComments(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 createCommentsRouteMeta( + config: CommentsClientConfig, + path: "/comments/moderation" | "/comments", + title: string, + description: string, +) { + return () => { + const fullUrl = `${config.siteBaseURL}${config.siteBasePath}${path}`; + return [ + { title }, + { name: "title", content: title }, + { name: "description", content: description }, + { name: "robots", content: "noindex, nofollow" }, + { property: "og:title", content: title }, + { property: "og:description", content: description }, + { property: "og:url", content: fullUrl }, + { name: "twitter:card", content: "summary" }, + { name: "twitter:title", content: title }, + { name: "twitter:description", content: description }, + ]; + }; +} + +/** + * 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: createCommentsRouteMeta( + config, + "/comments/moderation", + "Comment Moderation", + "Review and manage comments across all resources.", + ), + })), + userComments: createRoute("/comments", () => ({ + PageComponent: UserCommentsPageComponent, + loader: createUserCommentsLoader(config), + meta: createCommentsRouteMeta( + config, + "/comments", + "User Comments", + "View and manage your comments across resources.", + ), + })), + }), + }); diff --git a/packages/stack/src/plugins/comments/client/utils.ts b/packages/stack/src/plugins/comments/client/utils.ts new file mode 100644 index 00000000..898c73ac --- /dev/null +++ b/packages/stack/src/plugins/comments/client/utils.ts @@ -0,0 +1,67 @@ +import { useState, useEffect } from "react"; +import type { CommentsPluginOverrides } from "./overrides"; + +/** + * Resolves `currentUserId` from the plugin overrides, supporting both a static + * string and a sync/async function. Returns `undefined` until resolution completes. + */ +export 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)) + .catch((err: unknown) => { + console.error( + "[btst/comments] Failed to resolve currentUserId:", + err, + ); + }); + } else { + setResolved(raw ?? undefined); + } + }, [raw]); + + return resolved; +} + +/** + * Normalise any thrown value into an Error. + * + * Handles three shapes: + * 1. Already an Error — returned as-is. + * 2. A plain object — message is taken from `.message`, then `.error` (API + * error-response shape), then JSON.stringify. All original properties are + * copied onto the Error via Object.assign so callers can inspect them. + * 3. Anything else — converted via String(). + */ +export 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 getInitials(name: string | null | undefined): string { + if (!name) return "?"; + return name + .split(" ") + .filter(Boolean) + .slice(0, 2) + .map((n) => n[0]) + .join("") + .toUpperCase(); +} 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..8cb1c111 --- /dev/null +++ b/packages/stack/src/plugins/comments/query-keys.ts @@ -0,0 +1,189 @@ +import { + mergeQueryKeys, + createQueryKeys, +} from "@lukemorales/query-key-factory"; +import type { CommentsApiRouter } from "./api"; +import { createApiClient } from "@btst/stack/plugins/client"; +import type { CommentListResult } from "./types"; +import { + commentsListDiscriminator, + commentCountDiscriminator, + commentsThreadDiscriminator, +} from "./api/query-key-defs"; +import { toError } from "./client/utils"; + +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 + ); +} + +export function createCommentsQueryKeys( + client: ReturnType>, + headers?: HeadersInit, +) { + return mergeQueryKeys( + createCommentsQueries(client, headers), + createCommentCountQueries(client, headers), + createCommentsThreadQueries(client, headers), + ); +} + +function createCommentsQueries( + client: ReturnType
\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\t\t{loc.COMMENTS_LOGIN_PROMPT}\n\t\t\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\t{comment.body}\n\t\t\t\t\t\t\t\t\t\t\t
\n\t\t\t\t\t\t\t\t\t\t{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} +