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..6b73f7d2 --- /dev/null +++ b/docs/content/docs/plugins/comments.mdx @@ -0,0 +1,536 @@ +--- +title: Comments Plugin +description: Threaded comments with moderation, likes, replies, and embeddable CommentThread component +--- + +import { Tabs, Tab } from "fumadocs-ui/components/tabs"; +import { Callout } from "fumadocs-ui/components/callout"; + +The Comments plugin adds threaded commenting to any resource in your application — blog posts, Kanban tasks, CMS content, or your own custom pages. Comments are displayed with the embeddable `CommentThread` component and managed via a built-in moderation dashboard. + +**Key Features:** +- **Threaded replies** — Top-level comments and nested replies +- **Like system** — One like per user, optimistic UI updates, denormalized counter +- **Edit support** — Authors can edit their own comments; an "edited" timestamp is shown +- **Moderation dashboard** — Tabbed view (Pending / Approved / Spam) with bulk actions +- **Server-side user resolution** — `resolveUser` hook to embed author name and avatar in API responses +- **Optimistic updates** — New comments appear instantly with a "Pending approval" badge when `autoApprove: false` +- **Scroll-into-view lazy loading** — `CommentThread` is mounted only when it scrolls into the viewport + +## Installation + + +Ensure you followed the general [framework installation guide](/installation) first. + + +### 1. Add Plugin to Backend API + +Register the comments backend plugin in your `stack.ts` file: + +```ts title="lib/stack.ts" +import { stack } from "@btst/stack" +import { commentsBackendPlugin } from "@btst/stack/plugins/comments/api" + +const { handler, dbSchema } = stack({ + basePath: "/api/data", + plugins: { + comments: commentsBackendPlugin({ + // Automatically approve comments (default: false — requires moderation) + autoApprove: false, + + // Resolve author display name and avatar from your auth system + resolveUser: async (authorId) => { + const user = await db.users.findById(authorId) + return user + ? { name: user.displayName, avatarUrl: user.avatarUrl } + : null + }, + + // Lifecycle hooks — see Security section below for required configuration + onBeforeList: async (query, ctx) => { + // Restrict non-approved status filters (pending/spam) to admin sessions only + if (query.status && query.status !== "approved") { + const session = await getSession(ctx.headers) + if (!session?.user?.isAdmin) throw new Error("Admin access required") + } + }, + onBeforePost: async (comment, ctx) => { + // Required: resolve the authorId from the authenticated session + // Never use any ID supplied by the client + const session = await getSession(ctx.headers) + if (!session?.user) throw new Error("Authentication required") + return { authorId: session.user.id } + }, + onAfterPost: async (comment, ctx) => { + console.log("New comment posted:", comment.id) + }, + onBeforeEdit: async (commentId, update, ctx) => { + // Required: verify the caller owns the comment they are editing. + // Without this hook all edit requests return 403 by default. + const session = await getSession(ctx.headers) + if (!session?.user) throw new Error("Authentication required") + const comment = await db.comments.findById(commentId) + if (comment?.authorId !== session.user.id && !session.user.isAdmin) + throw new Error("Forbidden") + }, + onBeforeLike: async (commentId, authorId, ctx) => { + // Verify authorId matches the authenticated session + const session = await getSession(ctx.headers) + if (!session?.user) throw new Error("Authentication required") + if (authorId !== session.user.id) throw new Error("Forbidden") + }, + onBeforeStatusChange: async (commentId, status, ctx) => { + // Require admin/moderator role for the moderation endpoint + const session = await getSession(ctx.headers) + if (!session?.user?.isAdmin) throw new Error("Admin access required") + }, + onAfterApprove: async (comment, ctx) => { + // Send notification to comment author + await sendApprovalEmail(comment.authorId) + }, + onBeforeDelete: async (commentId, ctx) => { + // Require admin/moderator role — the Delete button is client-side only + const session = await getSession(ctx.headers) + if (!session?.user?.isAdmin) throw new Error("Admin access required") + }, + + // Required to show authors their own pending comments after posting. + // Without this hook the feature is disabled — client-supplied + // currentUserId is ignored server-side to prevent impersonation. + resolveCurrentUserId: async (ctx) => { + const session = await getSession(ctx.headers) + return session?.user?.id ?? null + }, + }) + }, + adapter: (db) => createMemoryAdapter(db)({}) +}) + +export { handler, dbSchema } +``` + +### 2. Add Plugin to Client + +Register the comments client plugin in your `stack-client.tsx` file: + +```tsx title="lib/stack-client.tsx" +import { createStackClient } from "@btst/stack/client" +import { commentsClientPlugin } from "@btst/stack/plugins/comments/client" +import { QueryClient } from "@tanstack/react-query" + +const getBaseURL = () => + process.env.BASE_URL || "http://localhost:3000" + +export const getStackClient = (queryClient: QueryClient) => { + const baseURL = getBaseURL() + return createStackClient({ + plugins: { + comments: commentsClientPlugin({ + queryClient, + siteBaseURL: baseURL, + siteBasePath: "/pages", + }), + }, + queryClient, + }) +} +``` + +### 3. Add CSS Import + + + +```css title="app/globals.css" +@import "@btst/stack/plugins/comments/css"; +``` + + +```css title="app/app.css" +@import "@btst/stack/plugins/comments/css"; +``` + + +```css title="src/styles/globals.css" +@import "@btst/stack/plugins/comments/css"; +``` + + + +### 4. Configure Overrides + +Add comments overrides to your layout file. You must also register the `CommentsPluginOverrides` type: + + + +```tsx title="app/pages/layout.tsx" +import type { CommentsPluginOverrides } from "@btst/stack/plugins/comments/client" + +type PluginOverrides = { + // ... existing plugins + comments: CommentsPluginOverrides +} + +// Inside your StackProvider overrides: +overrides={{ + comments: { + apiBaseURL: baseURL, + apiBasePath: "/api/data", + + // Access control for admin routes + onBeforeModerationPageRendered: async (context) => { + const session = await getSession() + if (!session?.user?.isAdmin) throw new Error("Admin access required") + }, + } +}} +``` + + +```tsx title="app/routes/pages/_layout.tsx" +import type { CommentsPluginOverrides } from "@btst/stack/plugins/comments/client" + +type PluginOverrides = { + // ... existing plugins + comments: CommentsPluginOverrides +} + +// Inside your StackProvider overrides: +overrides={{ + comments: { + apiBaseURL: baseURL, + apiBasePath: "/api/data", + onBeforeModerationPageRendered: async (context) => { + const session = await getSession() + if (!session?.user?.isAdmin) throw new Error("Admin access required") + }, + } +}} +``` + + +```tsx title="src/routes/pages/route.tsx" +import type { CommentsPluginOverrides } from "@btst/stack/plugins/comments/client" + +type PluginOverrides = { + // ... existing plugins + comments: CommentsPluginOverrides +} + +// Inside your StackProvider overrides: +overrides={{ + comments: { + apiBaseURL: baseURL, + apiBasePath: "/api/data", + onBeforeModerationPageRendered: async (context) => { + const session = await getSession() + if (!session?.user?.isAdmin) throw new Error("Admin access required") + }, + } +}} +``` + + + +## Embedding Comments + +The `CommentThread` component can be embedded anywhere — below a blog post, inside a Kanban task dialog, or on a custom page. + +```tsx +import { CommentThread } from "@btst/stack/plugins/comments/client/components" + + +``` + +### Props + +| Prop | Type | Required | Description | +|------|------|----------|-------------| +| `resourceId` | `string` | ✓ | Identifier for the resource (e.g. post slug, task ID) | +| `resourceType` | `string` | ✓ | Type of resource (`"blog-post"`, `"kanban-task"`, etc.) | +| `apiBaseURL` | `string` | ✓ | Base URL for API requests | +| `apiBasePath` | `string` | ✓ | Path prefix where the API is mounted | +| `currentUserId` | `string` | — | Authenticated user ID — enables edit/delete/pending badge | +| `loginHref` | `string` | — | Login page URL shown to unauthenticated users | +| `pageSize` | `number` | — | Comments per page. Falls back to `defaultCommentPageSize` from overrides, then 100. A "Load more" button appears when there are additional pages. | +| `components.Input` | `ComponentType` | — | Custom input component (default: ``) | +| `components.Renderer` | `ComponentType` | — | Custom renderer for comment body (default: ``) | + +### Blog Post Integration + +The blog plugin exposes a `postBottomSlot` override that renders below every post: + +```tsx +import { CommentThread } from "@btst/stack/plugins/comments/client/components" + +overrides={{ + blog: { + postBottomSlot: (post) => ( + + ), + } +}} +``` + +### Kanban Task Integration + +The Kanban plugin exposes a `taskDetailBottomSlot` override that renders at the bottom of the task detail dialog: + +```tsx +import { CommentThread } from "@btst/stack/plugins/comments/client/components" + +overrides={{ + kanban: { + taskDetailBottomSlot: (task) => ( + + ), + } +}} +``` + +### Comment Count Badge + +Use `CommentCount` to show the number of approved comments anywhere (e.g., in a post listing): + +```tsx +import { CommentCount } from "@btst/stack/plugins/comments/client/components" + + +``` + +## Moderation Dashboard + +The comments plugin adds a `/comments/moderation` admin route with: + +- **Tabbed views** — Pending, Approved, Spam +- **Bulk actions** — Approve, Mark as spam, Delete +- **Comment detail dialog** — View full body and metadata +- **Per-row actions** — Approve, spam, delete from the table row + +Access is controlled by the `onBeforeModerationPageRendered` hook in `CommentsPluginOverrides`. + +## Backend Configuration + +### `commentsBackendPlugin` Options + +| Option | Type | Default | Description | +|--------|------|---------|-------------| +| `autoApprove` | `boolean` | `false` | Automatically approve new comments | +| `resolveUser` | `(authorId: string) => Promise<{ name: string; avatarUrl?: string } \| null>` | — | Map author IDs to display info; returns `null` → shows `"[deleted]"` | +| `onBeforeList` | hook | — | Called before the comment list or count is returned. Throw to reject. When absent, any `status` filter other than `"approved"` is automatically rejected with 403 on both `GET /comments` and `GET /comments/count` — preventing anonymous access to, or probing of, the moderation queues. | +| `onBeforePost` | hook | **required** | Called before a comment is saved. Must return `{ authorId: string }` derived from the authenticated session. Throw to reject. Plugin throws at startup if absent. | +| `onAfterPost` | hook | — | Called after a comment is saved. | +| `onBeforeEdit` | hook | — | Called before a comment body is updated. Throw to reject. When absent, **all edit requests return 403** — preventing any unauthenticated caller from tampering with comment bodies. Configure to verify the caller owns the comment. | +| `onAfterEdit` | hook | — | Called after a comment body is updated. | +| `onBeforeLike` | hook | — | Called before a like is toggled. Throw to reject. When absent, **all like/unlike requests return 403** — preventing unauthenticated callers from toggling likes on behalf of arbitrary user IDs. Configure to verify `authorId` matches the authenticated session. | +| `onBeforeStatusChange` | hook | — | Called before moderation status is changed. Throw to reject. When absent, **all status-change requests return 403** — preventing unauthenticated callers from moderating comments. Configure to verify the caller has admin/moderator privileges. | +| `onAfterApprove` | hook | — | Called after a comment is approved. | +| `onBeforeDelete` | hook | — | Called before a comment is deleted. Throw to reject. When absent, **all delete requests return 403** — preventing unauthenticated callers from deleting comments. Configure to enforce admin-only access. | +| `onAfterDelete` | hook | — | Called after a comment is deleted. | +| `onBeforeListByAuthor` | hook | — | Called before returning comments filtered by `authorId`. Throw to reject. When absent, **any request with `authorId` returns 403** — preventing anonymous callers from reading any user's comment history. Use to verify `authorId` matches the authenticated session. | +| `resolveCurrentUserId` | hook | **required** | Resolve the current authenticated user's ID from the session. Used to safely include the user's own pending comments alongside approved ones in `GET /comments`. The client-supplied `currentUserId` query parameter is never trusted — identity is resolved exclusively via this hook. Return `null`/`undefined` for unauthenticated requests. Plugin throws at startup if absent. | + + +**`onBeforePost` and `resolveCurrentUserId` are both required.** `commentsBackendPlugin` throws at startup if either is absent. + +- `onBeforePost` must return `{ authorId: string }` derived from the session — `authorId` is intentionally absent from the POST body so clients can never forge authorship. +- `resolveCurrentUserId` must return the session-verified user ID (or `null` when unauthenticated) — the `?currentUserId=…` query parameter sent by the client is completely discarded. + + +### Server-Side API (`stack.api.comments`) + +Direct database access without HTTP, useful in Server Components, cron jobs, or AI tools: + +```ts +const items = await myStack.api.comments.listComments({ + resourceId: "my-post", + resourceType: "blog-post", + status: "approved", +}) + +const count = await myStack.api.comments.getCommentCount({ + resourceId: "my-post", + resourceType: "blog-post", +}) +``` + + +`stack().api.*` calls bypass authorization hooks. Callers are responsible for access control. + + +## React Hooks + +Import hooks from `@btst/stack/plugins/comments/client/hooks`: + +```tsx +import { + useComments, + useCommentCount, + usePostComment, + useUpdateComment, + useDeleteComment, + useToggleLike, + useUpdateCommentStatus, +} from "@btst/stack/plugins/comments/client/hooks" + +// Fetch approved comments for a resource +const { data, isLoading } = useComments({ + resourceId: "my-post", + resourceType: "blog-post", + status: "approved", +}) + +// Post a new comment (includes optimistic update) +const { mutate: postComment } = usePostComment() +postComment({ + resourceId: "my-post", + resourceType: "blog-post", + authorId: "user-123", + body: "Great post!", +}) + +// Toggle like (one per user; optimistic update) +const { mutate: toggleLike } = useToggleLike() +toggleLike({ commentId: "comment-id", authorId: "user-123" }) + +// Moderate a comment +const { mutate: updateStatus } = useUpdateCommentStatus() +updateStatus({ id: "comment-id", status: "approved" }) +``` + +## My Comments Page + +The comments plugin registers a `/comments/my-comments` route that shows the current user's full comment history — all statuses (approved, pending, spam) in a single paginated table, newest first. + +**Features:** +- All comment statuses visible to the owner in one list, each with an inline status badge +- Prev / Next pagination (20 per page) +- Resource link column — click through to the original resource when `resourceLinks` is configured (links automatically include `#comments` so the page scrolls to the comment thread) +- Delete button with confirmation dialog — calls `DELETE /comments/:id` (governed by `onBeforeDelete`) +- Login prompt when `currentUserId` is not configured + +### Setup + +Configure the overrides in your layout and the security hook in your backend: + +```tsx title="app/pages/layout.tsx" +overrides={{ + comments: { + apiBaseURL: baseURL, + apiBasePath: "/api/data", + + // Provide the current user's ID so the page can scope the query + currentUserId: session?.user?.id, + + // Map resource types to URLs so comments link back to their resource + resourceLinks: { + "blog-post": (slug) => `/pages/blog/${slug}`, + "kanban-task": (id) => `/pages/kanban?task=${id}`, + }, + + onBeforeMyCommentsPageRendered: (context) => { + if (!session?.user) throw new Error("Authentication required") + }, + } +}} +``` + +```ts title="lib/stack.ts" +commentsBackendPlugin({ + // ... + onBeforeListByAuthor: async (authorId, _query, ctx) => { + const session = await getSession(ctx.headers) + if (!session?.user) throw new Error("Authentication required") + if (authorId !== session.user.id && !session.user.isAdmin) + throw new Error("Forbidden") + }, +}) +``` + + +**`onBeforeListByAuthor` is 403 by default.** Any `GET /comments?authorId=...` request returns 403 unless `onBeforeListByAuthor` is configured. This prevents anonymous callers from reading any user's comment history. Always validate that `authorId` matches the authenticated session. + + +## API Reference + +### Client Plugin Overrides + +Configure the comments plugin behavior from your layout: + +#### `CommentsPluginOverrides` + +| Field | Type | Description | +|-------|------|-------------| +| `apiBaseURL` | `string` | Base URL for API requests | +| `apiBasePath` | `string` | Path prefix for the API | +| `currentUserId` | `string \| (() => string \| undefined \| Promise)` | Authenticated user's ID — used by the My Comments page. Supports async functions for session-based resolution. | +| `defaultCommentPageSize` | `number` | Default number of top-level comments per page for all `CommentThread` instances. Overridden per-instance by the `pageSize` prop. Defaults to `100` when not set. | +| `resourceLinks` | `Record string>` | Per-resource-type URL builders for linking comments back to their resource on the My Comments page (e.g. `{ "blog-post": (slug) => "/pages/blog/" + slug }`). The plugin appends `#comments` automatically so the page scrolls to the thread. | +| `localization` | `Partial` | Override any UI string in the plugin. Import `COMMENTS_LOCALIZATION` from `@btst/stack/plugins/comments/client` to see all available keys. | +| `onBeforeModerationPageRendered` | hook | Called before rendering the moderation dashboard. Throw to deny access. | +| `onBeforeResourceCommentsRendered` | hook | Called before rendering the per-resource comments admin view. Throw to deny access. | +| `onBeforeMyCommentsPageRendered` | hook | Called before rendering the My Comments page. Throw to deny access (e.g. when no session exists). | + +### HTTP Endpoints + +| Method | Path | Description | +|--------|------|-------------| +| `GET` | `/comments` | List comments for a resource | +| `POST` | `/comments` | Create a new comment | +| `PATCH` | `/comments/:id` | Edit a comment body | +| `GET` | `/comments/count` | Get approved comment count | +| `POST` | `/comments/:id/like` | Toggle like on a comment | +| `PATCH` | `/comments/:id/status` | Update moderation status | +| `DELETE` | `/comments/:id` | Delete a comment | + +### `SerializedComment` + +Comments returned by the API include resolved author information: + +| Field | Type | Description | +|-------|------|-------------| +| `id` | `string` | Comment ID | +| `resourceId` | `string` | Resource identifier | +| `resourceType` | `string` | Resource type | +| `parentId` | `string \| null` | Parent comment ID for replies | +| `authorId` | `string` | Author user ID | +| `resolvedAuthorName` | `string` | Display name from `resolveUser`, or `"[deleted]"` | +| `resolvedAvatarUrl` | `string \| null` | Avatar URL from `resolveUser` | +| `body` | `string` | Comment body | +| `status` | `"pending" \| "approved" \| "spam"` | Moderation status | +| `likes` | `number` | Denormalized like count | +| `isLikedByCurrentUser` | `boolean` | Whether the requesting user has liked this comment | +| `editedAt` | `string \| null` | ISO date string if the comment was edited | +| `createdAt` | `string` | ISO date string | +| `updatedAt` | `string` | ISO date string | diff --git a/docs/content/docs/plugins/kanban.mdx b/docs/content/docs/plugins/kanban.mdx index 715e031f..1149005f 100644 --- a/docs/content/docs/plugins/kanban.mdx +++ b/docs/content/docs/plugins/kanban.mdx @@ -696,6 +696,32 @@ overrides={{ | `resolveUser` | `(userId: string) => KanbanUser \| null` | Resolve user info from ID | | `searchUsers` | `(query: string, boardId?: string) => KanbanUser[]` | Search/list users for picker | +**Slot overrides:** + +| Override | Type | Description | +|----------|------|-------------| +| `taskDetailBottomSlot` | `(task: SerializedTask) => ReactNode` | Render additional content below task details — use to embed a `CommentThread` | + +```tsx +import { CommentThread } from "@btst/stack/plugins/comments/client/components" + +overrides={{ + kanban: { + // ... + taskDetailBottomSlot: (task) => ( + + ), + } +}} +``` + ## React Hooks Import hooks from `@btst/stack/plugins/kanban/client/hooks` to use in your components: diff --git a/e2e/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..39f2e836 --- /dev/null +++ b/e2e/tests/smoke.comments.spec.ts @@ -0,0 +1,911 @@ +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* userId should not see this pending comment + const response = await request.get( + `/api/data/comments?resourceId=${encodeURIComponent(resourceId)}&resourceType=${encodeURIComponent(resourceType)}¤tUserId=some-other-user`, + ); + expect(response.ok()).toBeTruthy(); + const body = await response.json(); + + const found = body.items.find((c: { id: string }) => c.id === comment.id); + expect( + found, + "Pending comment from another author must NOT appear for a different currentUserId", + ).toBeUndefined(); + }); + + test("replyCount on parent includes own pending reply when currentUserId is provided", async ({ + request, + }) => { + const resourceId = `e2e-replycount-${Date.now()}`; + const resourceType = "e2e-test"; + + // Create and approve parent so it appears in the top-level list + const parent = await createComment(request, { + resourceId, + resourceType, + body: "Parent comment for reply-count test.", + }); + await approveComment(request, parent.id); + + // Post a pending reply + await createComment(request, { + resourceId, + resourceType, + parentId: parent.id, + body: "My pending reply — should increment replyCount.", + }); + + // Fetch top-level comments WITH currentUserId (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/my-comments", { + waitUntil: "networkidle", + }); + + // Either the list or the empty-state element must be visible + const hasPage = await page + .locator('[data-testid="my-comments-page"]') + .isVisible() + .catch(() => false); + const hasEmpty = await page + .locator('[data-testid="my-comments-empty"]') + .isVisible() + .catch(() => false); + expect( + hasPage || hasEmpty, + "Expected my-comments-page or my-comments-empty to be visible", + ).toBe(true); + + expect(errors, `Console errors detected:\n${errors.join("\n")}`).toEqual( + [], + ); + }); + + test("populated state — comment created by current user appears in list", async ({ + page, + request, + }) => { + const errors: string[] = []; + page.on("console", (msg) => { + if (msg.type() === "error") errors.push(msg.text()); + }); + + // Create a comment — the example app's onBeforePost assigns authorId "olliethedev" + const uniqueBody = `My comment e2e ${Date.now()}`; + await createComment(request, { + resourceId: `e2e-mycomments-${Date.now()}`, + resourceType: "e2e-test", + body: uniqueBody, + }); + + await page.goto("/pages/comments/my-comments", { + waitUntil: "networkidle", + }); + + await expect( + page.locator('[data-testid="my-comments-list"]'), + ).toBeVisible(); + + // The comment body should appear somewhere in the list (possibly on page 1) + await expect(page.getByText(uniqueBody)).toBeVisible(); + + expect(errors, `Console errors detected:\n${errors.join("\n")}`).toEqual( + [], + ); + }); + + test("delete from list — comment disappears after confirmation", async ({ + page, + request, + }) => { + const errors: string[] = []; + page.on("console", (msg) => { + if (msg.type() === "error") errors.push(msg.text()); + }); + + const uniqueBody = `Delete me e2e ${Date.now()}`; + await createComment(request, { + resourceId: `e2e-delete-mycomments-${Date.now()}`, + resourceType: "e2e-test", + body: uniqueBody, + }); + + await page.goto("/pages/comments/my-comments", { + waitUntil: "networkidle", + }); + + // Find the row containing our comment + const row = page + .locator('[data-testid="my-comment-row"]') + .filter({ hasText: uniqueBody }); + await expect(row).toBeVisible(); + + // Click the delete button on that row + await row.locator('[data-testid="my-comment-delete-button"]').click(); + + // Confirm the AlertDialog + await page.locator("button", { hasText: "Delete" }).last().click(); + await page.waitForLoadState("networkidle"); + + // Row should no longer be visible + await expect(page.getByText(uniqueBody)).not.toBeVisible({ timeout: 5000 }); + + expect(errors, `Console errors detected:\n${errors.join("\n")}`).toEqual( + [], + ); + }); + + test("API security — GET /comments?authorId=unknown returns 403", async ({ + request, + }) => { + // The example app's onBeforeListByAuthor only allows "olliethedev" + const response = await request.get( + `/api/data/comments?authorId=unknown-user-12345`, + ); + expect( + response.status(), + "Expected 403 when onBeforeListByAuthor is absent or rejects", + ).toBe(403); + }); + + test("API — GET /comments?authorId=olliethedev returns comments", async ({ + request, + }) => { + // Seed a comment so we have at least one + await createComment(request, { + resourceId: `e2e-api-author-${Date.now()}`, + resourceType: "e2e-test", + body: "Author filter API test", + }); + + const response = await request.get( + `/api/data/comments?authorId=${encodeURIComponent(AUTHOR_ID)}`, + ); + expect(response.ok(), "Expected 200 for own-author query").toBeTruthy(); + const body = await response.json(); + expect(Array.isArray(body.items)).toBe(true); + // All returned comments must belong to the requested author + for (const item of body.items) { + expect(item.authorId).toBe(AUTHOR_ID); + } + }); +}); + +// ─── Load More ──────────────────────────────────────────────────────────────── +// +// These tests verify the comment thread pagination that powers the "Load more +// comments" button. They mirror the blog smoke tests for load-more: an API +// contract test validates server-side limit/offset, and a UI test exercises +// the full click-to-fetch cycle in the browser. +// +// The example app layouts set defaultCommentPageSize: 5 so that pagination +// triggers after 5 comments — mirroring the blog's 10-per-page default. + +test.describe("Comment thread — load more", () => { + test("API pagination contract: limit/offset return correct slices", async ({ + request, + }) => { + const resourceId = `e2e-pagination-${Date.now()}`; + const resourceType = "e2e-test"; + + // Create and approve 7 top-level comments + await createApprovedComments(request, resourceId, resourceType, 7); + + // First page: 5 items, total = 7 + const page1 = await request.get( + `/api/data/comments?resourceId=${encodeURIComponent(resourceId)}&resourceType=${encodeURIComponent(resourceType)}&parentId=null&status=approved&limit=5&offset=0`, + ); + expect(page1.ok()).toBeTruthy(); + const body1 = await page1.json(); + expect(body1.items).toHaveLength(5); + expect(body1.total).toBe(7); + expect(body1.limit).toBe(5); + expect(body1.offset).toBe(0); + + // Second page: 2 items, total still = 7 + const page2 = await request.get( + `/api/data/comments?resourceId=${encodeURIComponent(resourceId)}&resourceType=${encodeURIComponent(resourceType)}&parentId=null&status=approved&limit=5&offset=5`, + ); + expect(page2.ok()).toBeTruthy(); + const body2 = await page2.json(); + expect(body2.items).toHaveLength(2); + expect(body2.total).toBe(7); + expect(body2.limit).toBe(5); + expect(body2.offset).toBe(5); + + // Third page (beyond end): 0 items + const page3 = await request.get( + `/api/data/comments?resourceId=${encodeURIComponent(resourceId)}&resourceType=${encodeURIComponent(resourceType)}&parentId=null&status=approved&limit=5&offset=10`, + ); + expect(page3.ok()).toBeTruthy(); + const body3 = await page3.json(); + expect(body3.items).toHaveLength(0); + expect(body3.total).toBe(7); + }); + + test("load more button on blog post page", async ({ page, request }) => { + const errors: string[] = []; + page.on("console", (msg) => { + if (msg.type() === "error") errors.push(msg.text()); + }); + + const slug = `e2e-lm-comments-${Date.now()}`; + + // Create a published blog post to host the comment thread + await createBlogPost(request, { + title: "Load More Comments Test Post", + slug, + }); + + // Create 7 approved comments so two pages are needed (pageSize = 5) + await createApprovedComments(request, slug, "blog-post", 7); + + await testLoadMoreComments(page, slug, 7, { + pageSize: 5, + bodyPrefix: "Load More Comment", + }); + + expect(errors, `Console errors detected:\n${errors.join("\n")}`).toEqual( + [], + ); + }); +}); diff --git a/examples/nextjs/app/api/data/[[...all]]/route.ts b/examples/nextjs/app/api/data/[[...all]]/route.ts index 8f4d4e31..d60f8ef4 100644 --- a/examples/nextjs/app/api/data/[[...all]]/route.ts +++ b/examples/nextjs/app/api/data/[[...all]]/route.ts @@ -3,4 +3,5 @@ import { handler } from "@/lib/stack" export const GET = handler export const POST = handler export const PUT = handler +export const PATCH = handler export const DELETE = handler diff --git a/examples/nextjs/app/globals.css b/examples/nextjs/app/globals.css index d37c0845..580ce0b6 100644 --- a/examples/nextjs/app/globals.css +++ b/examples/nextjs/app/globals.css @@ -23,6 +23,7 @@ /* Import Kanban plugin styles */ @import "@btst/stack/plugins/kanban/css"; +@import "@btst/stack/plugins/comments/css"; @custom-variant dark (&:is(.dark *)); diff --git a/examples/nextjs/app/pages/layout.tsx b/examples/nextjs/app/pages/layout.tsx index 98117fbe..54d52ab3 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 + }, + onBeforeMyCommentsPageRendered: (context) => { + console.log(`[${context.isSSR ? 'SSR' : 'CSR'}] onBeforeMyCommentsPageRendered`); + return true; // In production: check authenticated session + }, } }} > diff --git a/examples/nextjs/lib/stack-client.tsx b/examples/nextjs/lib/stack-client.tsx index 02e2e282..5dcccd8c 100644 --- a/examples/nextjs/lib/stack-client.tsx +++ b/examples/nextjs/lib/stack-client.tsx @@ -7,6 +7,7 @@ import { formBuilderClientPlugin } from "@btst/stack/plugins/form-builder/client import { uiBuilderClientPlugin, defaultComponentRegistry } from "@btst/stack/plugins/ui-builder/client" import { routeDocsClientPlugin } from "@btst/stack/plugins/route-docs/client" import { kanbanClientPlugin } from "@btst/stack/plugins/kanban/client" +import { commentsClientPlugin } from "@btst/stack/plugins/comments/client" import { QueryClient } from "@tanstack/react-query" // Get base URL function - works on both server and client @@ -175,6 +176,15 @@ export const getStackClient = ( }, }, }), + // Comments plugin — registers the /comments/moderation admin route + comments: commentsClientPlugin({ + apiBaseURL: baseURL, + apiBasePath: "/api/data", + siteBaseURL: baseURL, + siteBasePath: "/pages", + queryClient: queryClient, + headers: options?.headers, + }), } }) } diff --git a/examples/nextjs/lib/stack.ts b/examples/nextjs/lib/stack.ts index 0af9829e..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..cd05869e 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 + }, + onBeforeMyCommentsPageRendered: (context) => { + console.log(`[${context.isSSR ? 'SSR' : 'CSR'}] onBeforeMyCommentsPageRendered`); + return true; // In production: check authenticated session + }, } }} > diff --git a/examples/tanstack/src/lib/stack-client.tsx b/examples/tanstack/src/lib/stack-client.tsx index 5cb52761..043ce077 100644 --- a/examples/tanstack/src/lib/stack-client.tsx +++ b/examples/tanstack/src/lib/stack-client.tsx @@ -6,6 +6,7 @@ import { formBuilderClientPlugin } from "@btst/stack/plugins/form-builder/client import { uiBuilderClientPlugin, defaultComponentRegistry } from "@btst/stack/plugins/ui-builder/client" import { routeDocsClientPlugin } from "@btst/stack/plugins/route-docs/client" import { kanbanClientPlugin } from "@btst/stack/plugins/kanban/client" +import { commentsClientPlugin } from "@btst/stack/plugins/comments/client" import { QueryClient } from "@tanstack/react-query" // Get base URL function - works on both server and client @@ -133,6 +134,14 @@ export const getStackClient = (queryClient: QueryClient) => { description: "Manage your projects with kanban boards", }, }), + // Comments plugin — registers the /comments/moderation admin route + comments: commentsClientPlugin({ + apiBaseURL: baseURL, + apiBasePath: "/api/data", + siteBaseURL: baseURL, + siteBasePath: "/pages", + queryClient: queryClient, + }), } }) } diff --git a/examples/tanstack/src/lib/stack.ts b/examples/tanstack/src/lib/stack.ts index ac4b0be1..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..0f5f80f6 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 + }, + onBeforeMyCommentsPageRendered: (context) => { + console.log(`[${context.isSSR ? 'SSR' : 'CSR'}] onBeforeMyCommentsPageRendered`); + return true; // In production: check authenticated session + }, } }} > diff --git a/examples/tanstack/src/styles/globals.css b/examples/tanstack/src/styles/globals.css index 57c5835a..59c07329 100644 --- a/examples/tanstack/src/styles/globals.css +++ b/examples/tanstack/src/styles/globals.css @@ -7,6 +7,7 @@ @import "@btst/stack/plugins/ai-chat/css"; @import "@btst/stack/plugins/ui-builder/css"; @import "@btst/stack/plugins/kanban/css"; +@import "@btst/stack/plugins/comments/css"; @custom-variant dark (&:is(.dark *)); diff --git a/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..e75813d6 --- /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\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 MyCommentsPageProps {\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 MyCommentsPage({\n\tapiBaseURL,\n\tapiBasePath,\n\theaders,\n\tcurrentUserId: currentUserIdProp,\n\tresourceLinks,\n\tlocalization: localizationProp,\n}: MyCommentsPageProps) {\n\tconst loc = { ...COMMENTS_LOCALIZATION, ...localizationProp };\n\tconst resolvedUserId = useResolvedCurrentUserId(currentUserIdProp);\n\n\tif (!resolvedUserId) {\n\t\treturn (\n\t\t\t\n\t\t\t\t\n\t\t\t\t{loc.COMMENTS_MY_LOGIN_TITLE}\n\t\t\t\t\n\t\t\t\t\t{loc.COMMENTS_MY_LOGIN_DESCRIPTION}\n\t\t\t\t\n\t\t\t\n\t\t);\n\t}\n\n\treturn (\n\t\t\n\t);\n}\n\n// ─── List (suspense boundary is in ComposedRoute) ─────────────────────────────\n\nfunction MyCommentsList({\n\tapiBaseURL,\n\tapiBasePath,\n\theaders,\n\tcurrentUserId,\n\tresourceLinks,\n\tloc,\n}: {\n\tapiBaseURL: string;\n\tapiBasePath: string;\n\theaders?: HeadersInit;\n\tcurrentUserId: string;\n\tresourceLinks?: CommentsPluginOverrides[\"resourceLinks\"];\n\tloc: CommentsLocalization;\n}) {\n\tconst [page, setPage] = useState(1);\n\tconst [deleteId, setDeleteId] = useState(null);\n\n\tconst config = { apiBaseURL, apiBasePath, headers };\n\tconst offset = (page - 1) * PAGE_LIMIT;\n\n\tconst { comments, total, refetch } = useSuspenseComments(config, {\n\t\tauthorId: currentUserId,\n\t\tsort: \"desc\",\n\t\tlimit: PAGE_LIMIT,\n\t\toffset,\n\t});\n\n\tconst deleteMutation = useDeleteComment(config);\n\n\tconst totalPages = Math.max(1, Math.ceil(total / PAGE_LIMIT));\n\n\tconst handleDelete = async () => {\n\t\tif (!deleteId) return;\n\t\ttry {\n\t\t\tawait deleteMutation.mutateAsync(deleteId);\n\t\t\ttoast.success(loc.COMMENTS_MY_TOAST_DELETED);\n\t\t\trefetch();\n\t\t} catch {\n\t\t\ttoast.error(loc.COMMENTS_MY_TOAST_DELETE_ERROR);\n\t\t} finally {\n\t\t\tsetDeleteId(null);\n\t\t}\n\t};\n\n\tif (comments.length === 0 && page === 1) {\n\t\treturn (\n\t\t\t\n\t\t\t\t\n\t\t\t\t{loc.COMMENTS_MY_EMPTY_TITLE}\n\t\t\t\t\n\t\t\t\t\t{loc.COMMENTS_MY_EMPTY_DESCRIPTION}\n\t\t\t\t\n\t\t\t\n\t\t);\n\t}\n\n\treturn (\n\t\t\n\t\t\t\n\t\t\t\t\n\t\t\t\t\t{loc.COMMENTS_MY_PAGE_TITLE}\n\t\t\t\t\n\t\t\t\t\n\t\t\t\t\t{total} {loc.COMMENTS_MY_COL_COMMENT.toLowerCase()}\n\t\t\t\t\t{total !== 1 ? \"s\" : \"\"}\n\t\t\t\t\n\t\t\t\n\n\t\t\t\n\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t{loc.COMMENTS_MY_COL_COMMENT}\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t{loc.COMMENTS_MY_COL_RESOURCE}\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t{loc.COMMENTS_MY_COL_STATUS}\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t{loc.COMMENTS_MY_COL_DATE}\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\t{comments.map((comment) => (\n\t\t\t\t\t\t\t setDeleteId(comment.id)}\n\t\t\t\t\t\t\t\tisDeleting={deleteMutation.isPending && deleteId === comment.id}\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t))}\n\t\t\t\t\t\n\t\t\t\t\n\n\t\t\t\t {\n\t\t\t\t\t\tsetPage(p);\n\t\t\t\t\t\twindow.scrollTo({ top: 0, behavior: \"smooth\" });\n\t\t\t\t\t}}\n\t\t\t\t/>\n\t\t\t\n\n\t\t\t !open && setDeleteId(null)}\n\t\t\t>\n\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\t{loc.COMMENTS_MY_DELETE_TITLE}\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t{loc.COMMENTS_MY_DELETE_DESCRIPTION}\n\t\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t{loc.COMMENTS_MY_DELETE_CANCEL}\n\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t{loc.COMMENTS_MY_DELETE_CONFIRM}\n\t\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\n\t\t\t\n\t\t\n\t);\n}\n\n// ─── Row ──────────────────────────────────────────────────────────────────────\n\nfunction CommentRow({\n\tcomment,\n\tresourceLinks,\n\tloc,\n\tonDelete,\n\tisDeleting,\n}: {\n\tcomment: SerializedComment;\n\tresourceLinks?: CommentsPluginOverrides[\"resourceLinks\"];\n\tloc: CommentsLocalization;\n\tonDelete: () => void;\n\tisDeleting: boolean;\n}) {\n\tconst 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 MyCommentsPageInternal = lazy(() =>\n\timport(\"./my-comments-page.internal\").then((m) => ({\n\t\tdefault: m.MyCommentsPage,\n\t})),\n);\n\nfunction MyCommentsPageSkeleton() {\n\treturn (\n\t\t\n\t\t\t\n\t\t\t\n\t\t\t\n\t\t\n\t);\n}\n\nexport function MyCommentsPageComponent() {\n\treturn (\n\t\t\n\t\t\t\tconsole.error(\"[btst/comments] My Comments error:\", error)\n\t\t\t}\n\t\t/>\n\t);\n}\n\nfunction MyCommentsPageWrapper() {\n\tconst overrides = usePluginOverrides(\"comments\");\n\tconst loc = { ...COMMENTS_LOCALIZATION, ...overrides.localization };\n\n\tuseRouteLifecycle({\n\t\trouteName: \"myComments\",\n\t\tcontext: {\n\t\t\tpath: \"/comments/my-comments\",\n\t\t\tisSSR: typeof window === \"undefined\",\n\t\t},\n\t\toverrides,\n\t\tbeforeRenderHook: (o, context) => {\n\t\t\tif (o.onBeforeMyCommentsPageRendered) {\n\t\t\t\tconst result = o.onBeforeMyCommentsPageRendered(context);\n\t\t\t\treturn result === false ? false : true;\n\t\t\t}\n\t\t\treturn true;\n\t\t},\n\t});\n\n\treturn (\n\t\t\n\t\t\t\n\t\t\n\t);\n}\n", + "target": "src/components/btst/comments/client/components/pages/my-comments-page.tsx" + }, + { + "path": "btst/comments/client/components/pages/resource-comments-page.internal.tsx", + "type": "registry:component", + "content": "\"use client\";\n\nimport type { SerializedComment } from \"../../../types\";\nimport {\n\tuseSuspenseComments,\n\tuseUpdateCommentStatus,\n\tuseDeleteComment,\n} from \"@btst/stack/plugins/comments/client/hooks\";\nimport { CommentThread } from \"../comment-thread\";\nimport { Button } from \"@/components/ui/button\";\nimport { Badge } from \"@/components/ui/badge\";\nimport {\n\tAvatar,\n\tAvatarFallback,\n\tAvatarImage,\n} from \"@/components/ui/avatar\";\nimport { CheckCircle, ShieldOff, Trash2 } from \"lucide-react\";\nimport { formatDistanceToNow } from \"date-fns\";\nimport { toast } from \"sonner\";\nimport {\n\tCOMMENTS_LOCALIZATION,\n\ttype CommentsLocalization,\n} from \"../../localization\";\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 My 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 My Comments page.\n\t *\n\t * @example\n\t * ```ts\n\t * resourceLinks: {\n\t * \"blog-post\": (slug) => `/pages/blog/${slug}`,\n\t * \"kanban-task\": (id) => `/pages/kanban?task=${id}`,\n\t * }\n\t * ```\n\t *\n\t * When a resource type has no entry the ID is shown as plain text.\n\t */\n\tresourceLinks?: Record string>;\n\n\t// ============ Access Control Hooks ============\n\n\t/**\n\t * Called before the moderation dashboard page is rendered.\n\t * Return false to block rendering (e.g., redirect to login or show 403).\n\t * @param context - Route context\n\t */\n\tonBeforeModerationPageRendered?: (context: RouteContext) => boolean;\n\n\t/**\n\t * Called before the per-resource comments page is rendered.\n\t * Return false to block rendering (e.g., for authorization).\n\t * @param resourceType - The type of resource (e.g., \"blog-post\")\n\t * @param resourceId - The ID of the resource\n\t * @param context - Route context\n\t */\n\tonBeforeResourceCommentsRendered?: (\n\t\tresourceType: string,\n\t\tresourceId: string,\n\t\tcontext: RouteContext,\n\t) => boolean;\n\n\t/**\n\t * Called before the My Comments page is rendered.\n\t * Throw to block rendering (e.g., when the user is not authenticated).\n\t * @param context - Route context\n\t */\n\tonBeforeMyCommentsPageRendered?: (context: RouteContext) => boolean | void;\n\n\t// ============ Lifecycle Hooks ============\n\n\t/**\n\t * Called when a route is rendered.\n\t * @param routeName - Name of the route (e.g., 'moderation', 'resourceComments')\n\t * @param context - Route context\n\t */\n\tonRouteRender?: (\n\t\trouteName: string,\n\t\tcontext: RouteContext,\n\t) => void | Promise;\n\n\t/**\n\t * Called when a route encounters an error.\n\t * @param routeName - Name of the route\n\t * @param error - The error that occurred\n\t * @param context - Route context\n\t */\n\tonRouteError?: (\n\t\trouteName: string,\n\t\terror: Error,\n\t\tcontext: RouteContext,\n\t) => void | Promise;\n}\n", + "target": "src/components/btst/comments/client/overrides.ts" + }, + { + "path": "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..a1bbe568 --- /dev/null +++ b/packages/stack/src/plugins/comments/client/components/comment-thread.tsx @@ -0,0 +1,792 @@ +"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 */} + { + 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..bb309515 --- /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 MyCommentsPageProps { + 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 MyCommentsPage({ + apiBaseURL, + apiBasePath, + headers, + currentUserId: currentUserIdProp, + resourceLinks, + localization: localizationProp, +}: MyCommentsPageProps) { + const loc = { ...COMMENTS_LOCALIZATION, ...localizationProp }; + const resolvedUserId = useResolvedCurrentUserId(currentUserIdProp); + + if (!resolvedUserId) { + return ( + + + {loc.COMMENTS_MY_LOGIN_TITLE} + + {loc.COMMENTS_MY_LOGIN_DESCRIPTION} + + + ); + } + + return ( + + ); +} + +// ─── List (suspense boundary is in ComposedRoute) ───────────────────────────── + +function MyCommentsList({ + apiBaseURL, + apiBasePath, + headers, + currentUserId, + resourceLinks, + loc, +}: { + apiBaseURL: string; + apiBasePath: string; + headers?: HeadersInit; + currentUserId: string; + resourceLinks?: CommentsPluginOverrides["resourceLinks"]; + loc: CommentsLocalization; +}) { + const [page, setPage] = useState(1); + const [deleteId, setDeleteId] = useState(null); + + const config = { apiBaseURL, apiBasePath, headers }; + const offset = (page - 1) * PAGE_LIMIT; + + const { comments, total, refetch } = useSuspenseComments(config, { + authorId: currentUserId, + sort: "desc", + limit: PAGE_LIMIT, + offset, + }); + + const deleteMutation = useDeleteComment(config); + + const totalPages = Math.max(1, Math.ceil(total / PAGE_LIMIT)); + + const handleDelete = async () => { + if (!deleteId) return; + try { + await deleteMutation.mutateAsync(deleteId); + toast.success(loc.COMMENTS_MY_TOAST_DELETED); + refetch(); + } catch { + toast.error(loc.COMMENTS_MY_TOAST_DELETE_ERROR); + } finally { + setDeleteId(null); + } + }; + + if (comments.length === 0 && page === 1) { + return ( + + + {loc.COMMENTS_MY_EMPTY_TITLE} + + {loc.COMMENTS_MY_EMPTY_DESCRIPTION} + + + ); + } + + return ( + + + + {loc.COMMENTS_MY_PAGE_TITLE} + + + {total} {loc.COMMENTS_MY_COL_COMMENT.toLowerCase()} + {total !== 1 ? "s" : ""} + + + + + + + + + {loc.COMMENTS_MY_COL_COMMENT} + + {loc.COMMENTS_MY_COL_RESOURCE} + + + {loc.COMMENTS_MY_COL_STATUS} + + + {loc.COMMENTS_MY_COL_DATE} + + + + + + {comments.map((comment) => ( + setDeleteId(comment.id)} + isDeleting={deleteMutation.isPending && deleteId === comment.id} + /> + ))} + + + + { + setPage(p); + window.scrollTo({ top: 0, behavior: "smooth" }); + }} + /> + + + !open && setDeleteId(null)} + > + + + {loc.COMMENTS_MY_DELETE_TITLE} + + {loc.COMMENTS_MY_DELETE_DESCRIPTION} + + + + + {loc.COMMENTS_MY_DELETE_CANCEL} + + + {loc.COMMENTS_MY_DELETE_CONFIRM} + + + + + + ); +} + +// ─── Row ────────────────────────────────────────────────────────────────────── + +function CommentRow({ + comment, + resourceLinks, + loc, + onDelete, + isDeleting, +}: { + comment: SerializedComment; + resourceLinks?: CommentsPluginOverrides["resourceLinks"]; + loc: CommentsLocalization; + onDelete: () => void; + isDeleting: boolean; +}) { + const 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..79992106 --- /dev/null +++ b/packages/stack/src/plugins/comments/client/components/pages/my-comments-page.tsx @@ -0,0 +1,72 @@ +"use client"; + +import { lazy } from "react"; +import { ComposedRoute } from "@btst/stack/client/components"; +import { usePluginOverrides } from "@btst/stack/context"; +import type { CommentsPluginOverrides } from "../../overrides"; +import { COMMENTS_LOCALIZATION } from "../../localization"; +import { useRouteLifecycle } from "@workspace/ui/hooks/use-route-lifecycle"; +import { PageWrapper } from "../shared/page-wrapper"; + +const MyCommentsPageInternal = lazy(() => + import("./my-comments-page.internal").then((m) => ({ + default: m.MyCommentsPage, + })), +); + +function MyCommentsPageSkeleton() { + return ( + + + + + + ); +} + +export function MyCommentsPageComponent() { + return ( + + console.error("[btst/comments] My Comments error:", error) + } + /> + ); +} + +function MyCommentsPageWrapper() { + const overrides = usePluginOverrides("comments"); + const loc = { ...COMMENTS_LOCALIZATION, ...overrides.localization }; + + useRouteLifecycle({ + routeName: "myComments", + context: { + path: "/comments/my-comments", + isSSR: typeof window === "undefined", + }, + overrides, + beforeRenderHook: (o, context) => { + if (o.onBeforeMyCommentsPageRendered) { + const result = o.onBeforeMyCommentsPageRendered(context); + return result === false ? false : true; + } + return true; + }, + }); + + return ( + + + + ); +} diff --git a/packages/stack/src/plugins/comments/client/components/pages/resource-comments-page.internal.tsx b/packages/stack/src/plugins/comments/client/components/pages/resource-comments-page.internal.tsx new file mode 100644 index 00000000..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..fdf46d5c --- /dev/null +++ b/packages/stack/src/plugins/comments/client/hooks/use-comments.tsx @@ -0,0 +1,703 @@ +"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) return old; + return { + ...old, + pages: old.pages.map((page) => ({ + ...page, + items: page.items.map((item) => + item.id === context.optimisticId ? data : item, + ), + })), + }; + }, + ); + } else { + queryClient.setQueryData(context.listKey, (old) => { + if (!old) { + // 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..5835918d --- /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 My 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 My Comments page. + * + * @example + * ```ts + * resourceLinks: { + * "blog-post": (slug) => `/pages/blog/${slug}`, + * "kanban-task": (id) => `/pages/kanban?task=${id}`, + * } + * ``` + * + * When a resource type has no entry the ID is shown as plain text. + */ + resourceLinks?: Record string>; + + // ============ Access Control Hooks ============ + + /** + * Called before the moderation dashboard page is rendered. + * Return false to block rendering (e.g., redirect to login or show 403). + * @param context - Route context + */ + onBeforeModerationPageRendered?: (context: RouteContext) => boolean; + + /** + * Called before the per-resource comments page is rendered. + * Return false to block rendering (e.g., for authorization). + * @param resourceType - The type of resource (e.g., "blog-post") + * @param resourceId - The ID of the resource + * @param context - Route context + */ + onBeforeResourceCommentsRendered?: ( + resourceType: string, + resourceId: string, + context: RouteContext, + ) => boolean; + + /** + * Called before the My Comments page is rendered. + * Throw to block rendering (e.g., when the user is not authenticated). + * @param context - Route context + */ + onBeforeMyCommentsPageRendered?: (context: RouteContext) => boolean | void; + + // ============ Lifecycle Hooks ============ + + /** + * Called when a route is rendered. + * @param routeName - Name of the route (e.g., 'moderation', 'resourceComments') + * @param context - Route context + */ + onRouteRender?: ( + routeName: string, + context: RouteContext, + ) => void | Promise; + + /** + * Called when a route encounters an error. + * @param routeName - Name of the route + * @param error - The error that occurred + * @param context - Route context + */ + onRouteError?: ( + routeName: string, + error: Error, + context: RouteContext, + ) => void | Promise; +} diff --git a/packages/stack/src/plugins/comments/client/plugin.tsx b/packages/stack/src/plugins/comments/client/plugin.tsx new file mode 100644 index 00000000..f1d2d7db --- /dev/null +++ b/packages/stack/src/plugins/comments/client/plugin.tsx @@ -0,0 +1,162 @@ +// NO "use client" here! This file runs on both server and client. +import { lazy } from "react"; +import { + defineClientPlugin, + isConnectionError, +} from "@btst/stack/plugins/client"; +import { createRoute } from "@btst/yar"; +import type { QueryClient } from "@tanstack/react-query"; + +// Lazy load page components for code splitting +const ModerationPageComponent = lazy(() => + import("./components/pages/moderation-page").then((m) => ({ + default: m.ModerationPageComponent, + })), +); + +const MyCommentsPageComponent = lazy(() => + import("./components/pages/my-comments-page").then((m) => ({ + default: m.MyCommentsPageComponent, + })), +); + +/** + * Context passed to loader hooks + */ +export interface LoaderContext { + /** Current route path */ + path: string; + /** Route parameters */ + params?: Record; + /** Whether rendering on server (true) or client (false) */ + isSSR: boolean; + /** Base URL for API calls */ + apiBaseURL: string; + /** Path where the API is mounted */ + apiBasePath: string; + /** Optional headers for the request */ + headers?: Headers; + /** Additional context properties */ + [key: string]: unknown; +} + +/** + * Hooks for Comments client plugin + */ +export interface CommentsClientHooks { + /** + * Called before loading the moderation page. Throw to cancel. + */ + beforeLoadModeration?: (context: LoaderContext) => Promise | void; + /** + * Called before loading the My Comments page. Throw to cancel. + */ + beforeLoadMyComments?: (context: LoaderContext) => Promise | void; + /** + * Called when a loading error occurs. + */ + onLoadError?: (error: Error, context: LoaderContext) => Promise | void; +} + +/** + * Configuration for the Comments client plugin + */ +export interface CommentsClientConfig { + /** Base URL for API calls (e.g., "http://localhost:3000") */ + apiBaseURL: string; + /** Path where the API is mounted (e.g., "/api/data") */ + apiBasePath: string; + /** Base URL of your site */ + siteBaseURL: string; + /** Path where pages are mounted (e.g., "/pages") */ + siteBasePath: string; + /** React Query client instance */ + queryClient: QueryClient; + /** Optional headers for SSR */ + headers?: Headers; + /** Optional lifecycle hooks */ + hooks?: CommentsClientHooks; +} + +function createModerationLoader(config: CommentsClientConfig) { + return async () => { + if (typeof window === "undefined") { + const { apiBasePath, apiBaseURL, headers, hooks } = config; + const context: LoaderContext = { + path: "/comments/moderation", + isSSR: true, + apiBaseURL, + apiBasePath, + headers, + }; + try { + if (hooks?.beforeLoadModeration) { + await hooks.beforeLoadModeration(context); + } + } catch (error) { + if (isConnectionError(error)) { + console.warn( + "[btst/comments] route.loader() failed — no server running at build time.", + ); + } + if (hooks?.onLoadError) { + await hooks.onLoadError(error as Error, context); + } + } + } + }; +} + +function createMyCommentsLoader(config: CommentsClientConfig) { + return async () => { + if (typeof window === "undefined") { + const { apiBasePath, apiBaseURL, headers, hooks } = config; + const context: LoaderContext = { + path: "/comments/my-comments", + isSSR: true, + apiBaseURL, + apiBasePath, + headers, + }; + try { + if (hooks?.beforeLoadMyComments) { + await hooks.beforeLoadMyComments(context); + } + } catch (error) { + if (isConnectionError(error)) { + console.warn( + "[btst/comments] route.loader() failed — no server running at build time.", + ); + } + if (hooks?.onLoadError) { + await hooks.onLoadError(error as Error, context); + } + } + } + }; +} + +/** + * Comments client plugin — registers admin moderation routes. + * + * The embeddable `CommentThread` and `CommentCount` components are standalone + * and do not require this plugin to be registered. Register them manually + * via the layout overrides pattern or use them directly in your pages. + */ +export const commentsClientPlugin = (config: CommentsClientConfig) => + defineClientPlugin({ + name: "comments", + + routes: () => ({ + moderation: createRoute("/comments/moderation", () => ({ + PageComponent: ModerationPageComponent, + loader: createModerationLoader(config), + meta: () => [{ title: "Comment Moderation" }], + })), + myComments: createRoute("/comments/my-comments", () => ({ + PageComponent: MyCommentsPageComponent, + loader: createMyCommentsLoader(config), + meta: () => [{ title: "My Comments" }], + })), + }), + }); diff --git a/packages/stack/src/plugins/comments/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>, + headers?: HeadersInit, +) { + return createQueryKeys("comments", { + list: (params?: CommentsListParams) => ({ + queryKey: [commentsListDiscriminator(params)], + queryFn: async (): Promise => { + const response = await client("/comments", { + method: "GET", + query: { + resourceId: params?.resourceId, + resourceType: params?.resourceType, + parentId: params?.parentId === null ? "null" : params?.parentId, + status: params?.status, + // currentUserId is intentionally NOT sent to the server. + // The server resolves the caller's identity server-side via the + // resolveCurrentUserId hook. Sending it would allow any caller to + // impersonate another user and read their pending comments. + // It is still included in the queryKey above for client-side + // cache segregation (different users get different cache entries). + authorId: params?.authorId, + sort: params?.sort, + limit: params?.limit ?? 20, + offset: params?.offset ?? 0, + }, + headers, + }); + + if (isErrorResponse(response)) { + throw toError((response as { error: unknown }).error); + } + + const data = (response as { data?: unknown }).data as + | CommentListResult + | undefined; + return data ?? { items: [], total: 0, limit: 20, offset: 0 }; + }, + }), + }); +} + +function createCommentCountQueries( + client: ReturnType>, + headers?: HeadersInit, +) { + return createQueryKeys("commentCount", { + byResource: (params: CommentCountParams) => ({ + queryKey: [commentCountDiscriminator(params)], + queryFn: async (): Promise => { + const response = await client("/comments/count", { + method: "GET", + query: { + resourceId: params.resourceId, + resourceType: params.resourceType, + status: params.status, + }, + headers, + }); + + if (isErrorResponse(response)) { + throw toError((response as { error: unknown }).error); + } + + const data = (response as { data?: unknown }).data as + | { count: number } + | undefined; + return data?.count ?? 0; + }, + }), + }); +} + +/** + * Factory for the infinite thread query key family. + * Mirrors the blog's `createPostsQueries` pattern: the key is stable (no offset), + * and pages are fetched via `pageParam` passed to the queryFn. + */ +function createCommentsThreadQueries( + client: ReturnType>, + headers?: HeadersInit, +) { + return createQueryKeys("commentsThread", { + list: (params?: { + resourceId?: string; + resourceType?: string; + parentId?: string | null; + status?: "pending" | "approved" | "spam"; + currentUserId?: string; + limit?: number; + }) => ({ + // Offset is excluded from the key — it is driven by pageParam. + queryKey: [commentsThreadDiscriminator(params)], + queryFn: async ({ + pageParam, + }: { + pageParam?: number; + } = {}): Promise => { + const response = await client("/comments", { + method: "GET", + query: { + resourceId: params?.resourceId, + resourceType: params?.resourceType, + parentId: params?.parentId === null ? "null" : params?.parentId, + status: params?.status, + // currentUserId is intentionally NOT sent to the server. + // The server resolves the caller's identity server-side via the + // resolveCurrentUserId hook. It is still included in the queryKey + // above for client-side cache segregation. + limit: params?.limit ?? 20, + offset: pageParam ?? 0, + }, + headers, + }); + + if (isErrorResponse(response)) { + throw toError((response as { error: unknown }).error); + } + + const data = (response as { data?: unknown }).data as + | CommentListResult + | undefined; + return ( + data ?? { + items: [], + total: 0, + limit: params?.limit ?? 20, + offset: pageParam ?? 0, + } + ); + }, + }), + }); +} diff --git a/packages/stack/src/plugins/comments/schemas.ts b/packages/stack/src/plugins/comments/schemas.ts new file mode 100644 index 00000000..2ea2d766 --- /dev/null +++ b/packages/stack/src/plugins/comments/schemas.ts @@ -0,0 +1,72 @@ +import { z } from "zod"; + +export const CommentStatusSchema = z.enum(["pending", "approved", "spam"]); + +// ============ Comment Schemas ============ + +/** + * Schema for the POST /comments request body. + * authorId is intentionally absent — the server resolves identity from the + * session inside onBeforePost and injects it. Never trust authorId from the + * client body. + */ +export const createCommentSchema = z.object({ + resourceId: z.string().min(1, "Resource ID is required"), + resourceType: z.string().min(1, "Resource type is required"), + parentId: z.string().optional().nullable(), + body: z.string().min(1, "Body is required").max(10000, "Comment too long"), +}); + +/** + * Internal schema used after the authorId has been resolved server-side. + * This is what gets passed to createComment() in mutations.ts. + */ +export const createCommentInternalSchema = createCommentSchema.extend({ + authorId: z.string().min(1, "Author ID is required"), +}); + +export const updateCommentSchema = z.object({ + body: z.string().min(1, "Body is required").max(10000, "Comment too long"), +}); + +export const updateCommentStatusSchema = z.object({ + status: CommentStatusSchema, +}); + +// ============ Query Schemas ============ + +/** + * Schema for GET /comments query parameters. + * + * `currentUserId` is intentionally absent — it is never accepted from the client. + * The server always resolves the caller's identity via the `resolveCurrentUserId` + * hook and injects it internally. Accepting it from the client would allow any + * anonymous caller to supply an arbitrary user ID and read that user's pending + * (pre-moderation) comments. + */ +export const CommentListQuerySchema = z.object({ + resourceId: z.string().optional(), + resourceType: z.string().optional(), + parentId: z.string().optional().nullable(), + status: CommentStatusSchema.optional(), + authorId: z.string().optional(), + sort: z.enum(["asc", "desc"]).optional(), + limit: z.coerce.number().int().min(1).max(100).optional(), + offset: z.coerce.number().int().min(0).optional(), +}); + +/** + * Internal params schema used by `listComments()` and the `api` factory. + * Extends the HTTP query schema with `currentUserId`, which is always injected + * server-side (either by the HTTP handler via `resolveCurrentUserId`, or by a + * trusted server-side caller such as a Server Component or cron job). + */ +export const CommentListParamsSchema = CommentListQuerySchema.extend({ + currentUserId: z.string().optional(), +}); + +export const CommentCountQuerySchema = z.object({ + resourceId: z.string().min(1), + resourceType: z.string().min(1), + status: CommentStatusSchema.optional(), +}); diff --git a/packages/stack/src/plugins/comments/style.css b/packages/stack/src/plugins/comments/style.css new file mode 100644 index 00000000..aca480b1 --- /dev/null +++ b/packages/stack/src/plugins/comments/style.css @@ -0,0 +1,15 @@ +@import "./client.css"; + +/* + * Comments Plugin CSS - Includes Tailwind class scanning + * + * When consumed from npm, Tailwind v4 will automatically scan this package's + * source files for Tailwind classes. Consumers only need: + * @import "@btst/stack/plugins/comments/css"; + */ + +/* Scan this package's source files for Tailwind classes */ +@source "../../../src/**/*.{ts,tsx}"; + +/* Scan UI package components (when installed as npm package the UI package will be in this dir) */ +@source "../../packages/ui/src"; diff --git a/packages/stack/src/plugins/comments/types.ts b/packages/stack/src/plugins/comments/types.ts new file mode 100644 index 00000000..006a86e4 --- /dev/null +++ b/packages/stack/src/plugins/comments/types.ts @@ -0,0 +1,73 @@ +/** + * Comment status values + */ +export type CommentStatus = "pending" | "approved" | "spam"; + +/** + * A comment record as stored in the database + */ +export type Comment = { + id: string; + resourceId: string; + resourceType: string; + parentId: string | null; + authorId: string; + body: string; + status: CommentStatus; + likes: number; + editedAt?: Date; + createdAt: Date; + updatedAt: Date; +}; + +/** + * A like record linking an author to a comment + */ +export type CommentLike = { + id: string; + commentId: string; + authorId: string; + createdAt: Date; +}; + +/** + * A comment enriched with server-resolved author info and like status. + * All dates are ISO strings (safe for serialisation over HTTP / React Query cache). + */ +export interface SerializedComment { + id: string; + resourceId: string; + resourceType: string; + parentId: string | null; + authorId: string; + /** Resolved from resolveUser(authorId). Falls back to "[deleted]" when user cannot be found. */ + resolvedAuthorName: string; + /** Resolved avatar URL or null */ + resolvedAvatarUrl: string | null; + body: string; + status: CommentStatus; + /** Denormalized counter — updated atomically on toggleLike */ + likes: number; + /** True when the currentUserId query param matches an existing commentLike row */ + isLikedByCurrentUser: boolean; + /** ISO string set when the comment body was edited; null for unedited comments */ + editedAt: string | null; + createdAt: string; + updatedAt: string; + /** + * Number of direct replies visible to the requesting user. + * Includes approved replies plus any pending replies authored by `currentUserId`. + * Always 0 for reply comments (non-null parentId). + */ + replyCount: number; +} + +/** + * Paginated list result for comments + */ +export interface CommentListResult { + items: SerializedComment[]; + total: number; + limit: number; + offset: number; +} diff --git a/packages/stack/src/plugins/kanban/client/components/forms/task-form.tsx b/packages/stack/src/plugins/kanban/client/components/forms/task-form.tsx index b4a7ba7e..4fac944a 100644 --- a/packages/stack/src/plugins/kanban/client/components/forms/task-form.tsx +++ b/packages/stack/src/plugins/kanban/client/components/forms/task-form.tsx @@ -226,7 +226,6 @@ export function TaskForm({ } output="markdown" placeholder="Describe the task..." - editable={!isPending} className="min-h-[150px]" /> diff --git a/packages/stack/src/plugins/kanban/client/components/pages/board-page.internal.tsx b/packages/stack/src/plugins/kanban/client/components/pages/board-page.internal.tsx index f5a0c180..26b25831 100644 --- a/packages/stack/src/plugins/kanban/client/components/pages/board-page.internal.tsx +++ b/packages/stack/src/plugins/kanban/client/components/pages/board-page.internal.tsx @@ -66,8 +66,11 @@ export function BoardPage({ boardId }: BoardPageProps) { throw error; } - const { Link: OverrideLink, navigate: overrideNavigate } = - usePluginOverrides("kanban"); + const { + Link: OverrideLink, + navigate: overrideNavigate, + taskDetailBottomSlot, + } = usePluginOverrides("kanban"); const navigate = overrideNavigate || ((path: string) => { @@ -540,33 +543,49 @@ export function BoardPage({ boardId }: BoardPageProps) { Update task details. {modalState.type === "editTask" && ( - c.id === modalState.columnId) - ?.tasks?.find((t) => t.id === modalState.taskId)} - columns={board.columns || []} - onClose={closeModal} - onSuccess={() => { - closeModal(); - refetch(); - }} - onDelete={async () => { - try { - await deleteTask(modalState.taskId); + <> + c.id === modalState.columnId) + ?.tasks?.find((t) => t.id === modalState.taskId)} + columns={board.columns || []} + onClose={closeModal} + onSuccess={() => { closeModal(); refetch(); - } catch (error) { - const message = - error instanceof Error - ? error.message - : "Failed to delete task"; - toast.error(message); - } - }} - /> + }} + onDelete={async () => { + try { + await deleteTask(modalState.taskId); + closeModal(); + refetch(); + } catch (error) { + const message = + error instanceof Error + ? error.message + : "Failed to delete task"; + toast.error(message); + } + }} + /> + {taskDetailBottomSlot && + (() => { + const task = board.columns + ?.find((c) => c.id === modalState.columnId) + ?.tasks?.find((t) => t.id === modalState.taskId); + return task ? ( + + {taskDetailBottomSlot(task)} + + ) : null; + })()} + > )} diff --git a/packages/stack/src/plugins/kanban/client/overrides.ts b/packages/stack/src/plugins/kanban/client/overrides.ts index 5f6d1200..9c90cc33 100644 --- a/packages/stack/src/plugins/kanban/client/overrides.ts +++ b/packages/stack/src/plugins/kanban/client/overrides.ts @@ -1,5 +1,6 @@ -import type { ComponentType } from "react"; +import type { ComponentType, ReactNode } from "react"; import type { KanbanLocalization } from "./localization"; +import type { SerializedTask } from "../types"; /** * User information for assignee display/selection @@ -142,4 +143,29 @@ export interface KanbanPluginOverrides { * @param context - Route context */ onBeforeNewBoardPageRendered?: (context: RouteContext) => boolean; + + // ============ Slot Overrides ============ + + /** + * Optional slot rendered at the bottom of the task detail dialog. + * Use this to inject a comment thread or any custom content without + * coupling the kanban plugin to the comments plugin. + * + * @example + * ```tsx + * kanban: { + * taskDetailBottomSlot: (task) => ( + * + * ), + * } + * ``` + */ + taskDetailBottomSlot?: (task: SerializedTask) => ReactNode; } diff --git a/packages/ui/src/components/pagination-controls.tsx b/packages/ui/src/components/pagination-controls.tsx new file mode 100644 index 00000000..1fa777f5 --- /dev/null +++ b/packages/ui/src/components/pagination-controls.tsx @@ -0,0 +1,80 @@ +"use client"; + +import { Button } from "@workspace/ui/components/button"; +import { ChevronLeft, ChevronRight } from "lucide-react"; + +export interface PaginationControlsProps { + /** Current page, 1-based */ + currentPage: number; + totalPages: number; + total: number; + limit: number; + offset: number; + onPageChange: (page: number) => void; + labels?: { + previous?: string; + next?: string; + /** Template string; use {from}, {to}, {total} as placeholders */ + showing?: string; + }; +} + +/** + * Generic Prev/Next pagination control with a "Showing X–Y of Z" label. + * Plugin-agnostic — pass localized labels as props. + * Returns null when totalPages ≤ 1. + */ +export function PaginationControls({ + currentPage, + totalPages, + total, + limit, + offset, + onPageChange, + labels, +}: PaginationControlsProps) { + const previous = labels?.previous ?? "Previous"; + const next = labels?.next ?? "Next"; + const showingTemplate = labels?.showing ?? "Showing {from}–{to} of {total}"; + + const from = offset + 1; + const to = Math.min(offset + limit, total); + + const showingText = showingTemplate + .replace("{from}", String(from)) + .replace("{to}", String(to)) + .replace("{total}", String(total)); + + if (totalPages <= 1) { + return null; + } + + return ( + + {showingText} + + onPageChange(currentPage - 1)} + disabled={currentPage === 1} + > + + {previous} + + + {currentPage} / {totalPages} + + onPageChange(currentPage + 1)} + disabled={currentPage === totalPages} + > + {next} + + + + + ); +} diff --git a/packages/ui/src/components/when-visible.tsx b/packages/ui/src/components/when-visible.tsx new file mode 100644 index 00000000..16777c8b --- /dev/null +++ b/packages/ui/src/components/when-visible.tsx @@ -0,0 +1,65 @@ +"use client"; + +import { useEffect, useRef, useState, type ReactNode } from "react"; + +export interface WhenVisibleProps { + /** Content to render once the element scrolls into view */ + children: ReactNode; + /** Optional placeholder rendered before the element enters the viewport */ + fallback?: ReactNode; + /** IntersectionObserver threshold (0–1). Defaults to 0 (any pixel visible). */ + threshold?: number; + /** Root margin passed to IntersectionObserver. Defaults to "200px" to preload slightly early. */ + rootMargin?: string; + /** Additional className applied to the sentinel wrapper div */ + className?: string; +} + +/** + * Lazy-mounts children only when the sentinel element scrolls into the viewport. + * Once mounted, children remain mounted even if the element scrolls out of view. + * + * Use this to defer expensive renders (comment threads, carousels, etc.) until + * the user actually scrolls to that section. + */ +export function WhenVisible({ + children, + fallback = null, + threshold = 0, + rootMargin = "200px", + className, +}: WhenVisibleProps) { + const [isVisible, setIsVisible] = useState(false); + const sentinelRef = useRef(null); + + useEffect(() => { + const el = sentinelRef.current; + if (!el) return; + + // If IntersectionObserver is not available (SSR/old browsers), show immediately + if (typeof IntersectionObserver === "undefined") { + setIsVisible(true); + return; + } + + const observer = new IntersectionObserver( + (entries) => { + const entry = entries[0]; + if (entry?.isIntersecting) { + setIsVisible(true); + observer.disconnect(); + } + }, + { threshold, rootMargin }, + ); + + observer.observe(el); + return () => observer.disconnect(); + }, [threshold, rootMargin]); + + return ( + + {isVisible ? children : fallback} + + ); +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 44f4c2ee..696a44bb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -169,19 +169,19 @@ importers: version: 2.0.94(react@19.2.0)(zod@4.2.1) '@btst/adapter-drizzle': specifier: ^2.1.0 - version: 2.1.0(2db6b3aa7c76b2a39655c7878ff38a0b) + version: 2.1.0(d36894e800afb4db2f9e5e0221688c82) '@btst/adapter-kysely': specifier: ^2.1.0 - version: 2.1.0(2db6b3aa7c76b2a39655c7878ff38a0b) + version: 2.1.0(d36894e800afb4db2f9e5e0221688c82) '@btst/adapter-memory': specifier: ^2.1.0 - version: 2.1.0(2db6b3aa7c76b2a39655c7878ff38a0b) + version: 2.1.0(d36894e800afb4db2f9e5e0221688c82) '@btst/adapter-mongodb': specifier: ^2.1.0 - version: 2.1.0(2db6b3aa7c76b2a39655c7878ff38a0b) + version: 2.1.0(d36894e800afb4db2f9e5e0221688c82) '@btst/adapter-prisma': specifier: ^2.1.0 - version: 2.1.0(2db6b3aa7c76b2a39655c7878ff38a0b) + version: 2.1.0(d36894e800afb4db2f9e5e0221688c82) '@btst/stack': specifier: workspace:* version: link:../../packages/stack @@ -237,8 +237,8 @@ importers: specifier: ^6.0.0 version: 6.21.0(socks@2.8.7) next: - specifier: 16.0.10 - version: 16.0.10(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + specifier: 16.1.6 + version: 16.1.6(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) next-themes: specifier: ^0.4.6 version: 0.4.6(react-dom@19.2.0(react@19.2.0))(react@19.2.0) @@ -587,9 +587,6 @@ importers: react-hook-form: specifier: '>=7.55.0' version: 7.66.1(react@19.2.0) - react-intersection-observer: - specifier: '>=9.0.0' - version: 10.0.0(react-dom@19.2.0(react@19.2.0))(react@19.2.0) react-markdown: specifier: '>=9.1.0' version: 9.1.0(@types/react@19.2.6)(react@19.2.0) @@ -4852,6 +4849,7 @@ packages: basic-ftp@5.0.5: resolution: {integrity: sha512-4Bcg1P8xhUuqcii/S0Z9wiHIrQVPMermM1any+MX5GeGD7faD3/msQUDGLol9wOcz4/jbg/WJnGqoJF6LiBdtg==} engines: {node: '>=10.0.0'} + deprecated: Security vulnerability fixed in 5.2.0, please upgrade better-auth@1.5.4: resolution: {integrity: sha512-ReykcEKx6Kp9560jG1wtlDBnftA7L7xb3ZZdDWm5yGXKKe2pUf+oBjH0fqekrkRII0m4XBVQbQ0mOrFv+3FdYg==} @@ -6280,11 +6278,12 @@ packages: glob@10.5.0: resolution: {integrity: sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==} + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me hasBin: true glob@7.2.3: resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} - deprecated: Glob versions prior to v9 are no longer supported + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me globals@13.24.0: resolution: {integrity: sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==} @@ -8301,15 +8300,6 @@ packages: peerDependencies: react: ^19.2.0 - react-intersection-observer@10.0.0: - resolution: {integrity: sha512-JJRgcnFQoVXmbE5+GXr1OS1NDD1gHk0HyfpLcRf0575IbJz+io8yzs4mWVlfaqOQq1FiVjLvuYAdEEcrrCfveg==} - peerDependencies: - react: ^19.2.0 - react-dom: ^17.0.0 || ^18.0.0 || ^19.0.0 - peerDependenciesMeta: - react-dom: - optional: true - react-is@16.13.1: resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} @@ -9597,6 +9587,7 @@ packages: whatwg-encoding@3.1.1: resolution: {integrity: sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==} engines: {node: '>=18'} + deprecated: Use @exodus/bytes instead for a more spec-conformant and faster implementation whatwg-mimetype@4.0.0: resolution: {integrity: sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==} @@ -10170,11 +10161,11 @@ snapshots: '@biomejs/cli-win32-x64@2.2.4': optional: true - '@btst/adapter-drizzle@2.1.0(2db6b3aa7c76b2a39655c7878ff38a0b)': + '@btst/adapter-drizzle@2.1.0(d36894e800afb4db2f9e5e0221688c82)': dependencies: '@better-auth/core': 1.5.4(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(better-call@2.0.1(zod@4.2.1))(jose@6.2.0)(kysely@0.28.8)(nanostores@1.1.1) - '@btst/db': 2.1.0(73a7114bf4fe13bb99896f432f871757) - better-auth: 1.5.4(67ecb4f4b8fb673bf1075802f9ddd254) + '@btst/db': 2.1.0(3581347f658bc9f101e33e2c3f774f40) + better-auth: 1.5.4(767c499e38114c4057ee7367645c5c65) drizzle-orm: 0.41.0(@electric-sql/pglite@0.3.15)(@opentelemetry/api@1.9.0)(@prisma/client@6.19.0(prisma@7.5.0(@types/react@19.2.6)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(typescript@5.9.3))(typescript@5.9.3))(bun-types@1.3.2(@types/react@19.2.6))(kysely@0.28.8)(mysql2@3.15.3)(postgres@3.4.7)(prisma@7.5.0(@types/react@19.2.6)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(typescript@5.9.3)) transitivePeerDependencies: - '@better-auth/utils' @@ -10203,11 +10194,11 @@ snapshots: - vitest - vue - '@btst/adapter-kysely@2.1.0(2db6b3aa7c76b2a39655c7878ff38a0b)': + '@btst/adapter-kysely@2.1.0(d36894e800afb4db2f9e5e0221688c82)': dependencies: '@better-auth/core': 1.5.4(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(better-call@2.0.1(zod@4.2.1))(jose@6.2.0)(kysely@0.28.8)(nanostores@1.1.1) - '@btst/db': 2.1.0(73a7114bf4fe13bb99896f432f871757) - better-auth: 1.5.4(67ecb4f4b8fb673bf1075802f9ddd254) + '@btst/db': 2.1.0(3581347f658bc9f101e33e2c3f774f40) + better-auth: 1.5.4(767c499e38114c4057ee7367645c5c65) kysely: 0.28.8 transitivePeerDependencies: - '@better-auth/utils' @@ -10236,11 +10227,11 @@ snapshots: - vitest - vue - '@btst/adapter-memory@2.1.0(2db6b3aa7c76b2a39655c7878ff38a0b)': + '@btst/adapter-memory@2.1.0(a5510a3f22769efb216707712fe268fb)': dependencies: - '@better-auth/core': 1.5.4(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(better-call@2.0.1(zod@4.2.1))(jose@6.2.0)(kysely@0.28.8)(nanostores@1.1.1) - '@btst/db': 2.1.0(73a7114bf4fe13bb99896f432f871757) - better-auth: 1.5.4(67ecb4f4b8fb673bf1075802f9ddd254) + '@better-auth/core': 1.5.4(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(better-call@1.3.2(zod@4.2.1))(jose@6.2.0)(kysely@0.28.11)(nanostores@1.1.1) + '@btst/db': 2.1.0(96474bc0743c1003c33dade349465747) + better-auth: 1.5.4(b7f95548c7a541548ed762b7d2b5e699) transitivePeerDependencies: - '@better-auth/utils' - '@better-fetch/fetch' @@ -10269,11 +10260,11 @@ snapshots: - vitest - vue - '@btst/adapter-memory@2.1.0(a5510a3f22769efb216707712fe268fb)': + '@btst/adapter-memory@2.1.0(bf56e8a25a6567ae0984dd8c13d80350)': dependencies: - '@better-auth/core': 1.5.4(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(better-call@1.3.2(zod@4.2.1))(jose@6.2.0)(kysely@0.28.11)(nanostores@1.1.1) - '@btst/db': 2.1.0(96474bc0743c1003c33dade349465747) - better-auth: 1.5.4(b7f95548c7a541548ed762b7d2b5e699) + '@better-auth/core': 1.5.4(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(better-call@2.0.1(zod@4.2.1))(jose@6.2.0)(kysely@0.28.11)(nanostores@1.1.1) + '@btst/db': 2.1.0(7888b9ecd667b8b1329619338f767b91) + better-auth: 1.5.4(1dc77348176d3c9bfb27d1ebc08f2d41) transitivePeerDependencies: - '@better-auth/utils' - '@better-fetch/fetch' @@ -10302,11 +10293,11 @@ snapshots: - vitest - vue - '@btst/adapter-memory@2.1.0(bf56e8a25a6567ae0984dd8c13d80350)': + '@btst/adapter-memory@2.1.0(c2198a24bea9a8ec2b3f81da5c5a813c)': dependencies: '@better-auth/core': 1.5.4(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(better-call@2.0.1(zod@4.2.1))(jose@6.2.0)(kysely@0.28.11)(nanostores@1.1.1) - '@btst/db': 2.1.0(7888b9ecd667b8b1329619338f767b91) - better-auth: 1.5.4(1dc77348176d3c9bfb27d1ebc08f2d41) + '@btst/db': 2.1.0(1e0eff0d8983e02d86024039dbc291a5) + better-auth: 1.5.4(130cbf1f38546bcec8ebe7ee6b767f41) transitivePeerDependencies: - '@better-auth/utils' - '@better-fetch/fetch' @@ -10335,11 +10326,11 @@ snapshots: - vitest - vue - '@btst/adapter-memory@2.1.0(c2198a24bea9a8ec2b3f81da5c5a813c)': + '@btst/adapter-memory@2.1.0(d36894e800afb4db2f9e5e0221688c82)': dependencies: - '@better-auth/core': 1.5.4(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(better-call@2.0.1(zod@4.2.1))(jose@6.2.0)(kysely@0.28.11)(nanostores@1.1.1) - '@btst/db': 2.1.0(1e0eff0d8983e02d86024039dbc291a5) - better-auth: 1.5.4(130cbf1f38546bcec8ebe7ee6b767f41) + '@better-auth/core': 1.5.4(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(better-call@2.0.1(zod@4.2.1))(jose@6.2.0)(kysely@0.28.8)(nanostores@1.1.1) + '@btst/db': 2.1.0(3581347f658bc9f101e33e2c3f774f40) + better-auth: 1.5.4(767c499e38114c4057ee7367645c5c65) transitivePeerDependencies: - '@better-auth/utils' - '@better-fetch/fetch' @@ -10368,11 +10359,11 @@ snapshots: - vitest - vue - '@btst/adapter-mongodb@2.1.0(2db6b3aa7c76b2a39655c7878ff38a0b)': + '@btst/adapter-mongodb@2.1.0(d36894e800afb4db2f9e5e0221688c82)': dependencies: '@better-auth/core': 1.5.4(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(better-call@2.0.1(zod@4.2.1))(jose@6.2.0)(kysely@0.28.8)(nanostores@1.1.1) - '@btst/db': 2.1.0(73a7114bf4fe13bb99896f432f871757) - better-auth: 1.5.4(67ecb4f4b8fb673bf1075802f9ddd254) + '@btst/db': 2.1.0(3581347f658bc9f101e33e2c3f774f40) + better-auth: 1.5.4(767c499e38114c4057ee7367645c5c65) mongodb: 6.21.0(socks@2.8.7) transitivePeerDependencies: - '@better-auth/utils' @@ -10401,12 +10392,12 @@ snapshots: - vitest - vue - '@btst/adapter-prisma@2.1.0(2db6b3aa7c76b2a39655c7878ff38a0b)': + '@btst/adapter-prisma@2.1.0(d36894e800afb4db2f9e5e0221688c82)': dependencies: '@better-auth/core': 1.5.4(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(better-call@2.0.1(zod@4.2.1))(jose@6.2.0)(kysely@0.28.8)(nanostores@1.1.1) - '@btst/db': 2.1.0(73a7114bf4fe13bb99896f432f871757) + '@btst/db': 2.1.0(3581347f658bc9f101e33e2c3f774f40) '@prisma/client': 6.19.0(prisma@7.5.0(@types/react@19.2.6)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(typescript@5.9.3))(typescript@5.9.3) - better-auth: 1.5.4(67ecb4f4b8fb673bf1075802f9ddd254) + better-auth: 1.5.4(767c499e38114c4057ee7367645c5c65) transitivePeerDependencies: - '@better-auth/utils' - '@better-fetch/fetch' @@ -10467,10 +10458,10 @@ snapshots: - vitest - vue - '@btst/db@2.1.0(73a7114bf4fe13bb99896f432f871757)': + '@btst/db@2.1.0(3581347f658bc9f101e33e2c3f774f40)': dependencies: '@better-auth/core': 1.5.4(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(better-call@2.0.1(zod@4.2.1))(jose@6.2.0)(kysely@0.28.8)(nanostores@1.1.1) - better-auth: 1.5.4(67ecb4f4b8fb673bf1075802f9ddd254) + better-auth: 1.5.4(767c499e38114c4057ee7367645c5c65) zod: 4.2.1 transitivePeerDependencies: - '@better-auth/utils' @@ -11865,8 +11856,7 @@ snapshots: '@next/env@16.0.10': {} - '@next/env@16.1.6': - optional: true + '@next/env@16.1.6': {} '@next/eslint-plugin-next@15.3.4': dependencies: @@ -14866,7 +14856,7 @@ snapshots: transitivePeerDependencies: - '@cloudflare/workers-types' - better-auth@1.5.4(67ecb4f4b8fb673bf1075802f9ddd254): + better-auth@1.5.4(767c499e38114c4057ee7367645c5c65): dependencies: '@better-auth/core': 1.5.4(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(better-call@1.3.2(zod@4.2.1))(jose@6.2.0)(kysely@0.28.11)(nanostores@1.1.1) '@better-auth/drizzle-adapter': 1.5.4(@better-auth/core@1.5.4(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(better-call@2.0.1(zod@4.2.1))(jose@6.2.0)(kysely@0.28.8)(nanostores@1.1.1))(@better-auth/utils@0.3.1)(drizzle-orm@0.41.0(@electric-sql/pglite@0.3.15)(@opentelemetry/api@1.9.0)(@prisma/client@6.19.0(prisma@7.5.0(@types/react@19.2.6)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(typescript@5.9.3))(typescript@5.9.3))(bun-types@1.3.2(@types/react@19.2.6))(kysely@0.28.8)(mysql2@3.15.3)(postgres@3.4.7)(prisma@7.5.0(@types/react@19.2.6)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(typescript@5.9.3))) @@ -14891,7 +14881,7 @@ snapshots: drizzle-orm: 0.41.0(@electric-sql/pglite@0.3.15)(@opentelemetry/api@1.9.0)(@prisma/client@6.19.0(prisma@7.5.0(@types/react@19.2.6)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(typescript@5.9.3))(typescript@5.9.3))(bun-types@1.3.2(@types/react@19.2.6))(kysely@0.28.8)(mysql2@3.15.3)(postgres@3.4.7)(prisma@7.5.0(@types/react@19.2.6)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(typescript@5.9.3)) mongodb: 6.21.0(socks@2.8.7) mysql2: 3.15.3 - next: 16.0.10(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + next: 16.1.6(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) prisma: 7.5.0(@types/react@19.2.6)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(typescript@5.9.3) react: 19.2.0 react-dom: 19.2.0(react@19.2.0) @@ -15884,7 +15874,7 @@ snapshots: '@typescript-eslint/parser': 8.47.0(eslint@8.57.1)(typescript@5.9.3) eslint: 8.57.1 eslint-import-resolver-node: 0.3.9 - eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@8.57.1) + eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.47.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1))(eslint@8.57.1) eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.47.0(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.1) eslint-plugin-jsx-a11y: 6.10.2(eslint@8.57.1) eslint-plugin-react: 7.37.5(eslint@8.57.1) @@ -15924,7 +15914,7 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0)(eslint@8.57.1): + eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.47.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1))(eslint@8.57.1): dependencies: '@nolyfill/is-core-module': 1.0.39 debug: 4.4.3 @@ -15954,14 +15944,14 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-module-utils@2.12.1(@typescript-eslint/parser@8.47.0(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.1): + eslint-module-utils@2.12.1(@typescript-eslint/parser@8.47.0(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.47.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1): dependencies: debug: 3.2.7 optionalDependencies: '@typescript-eslint/parser': 8.47.0(eslint@8.57.1)(typescript@5.9.3) eslint: 8.57.1 eslint-import-resolver-node: 0.3.9 - eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@8.57.1) + eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.47.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1))(eslint@8.57.1) transitivePeerDependencies: - supports-color @@ -15987,7 +15977,7 @@ snapshots: doctrine: 2.1.0 eslint: 8.57.1 eslint-import-resolver-node: 0.3.9 - eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.47.0(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.1) + eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.47.0(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.47.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1) hasown: 2.0.2 is-core-module: 2.16.1 is-glob: 4.0.3 @@ -18333,7 +18323,6 @@ snapshots: transitivePeerDependencies: - '@babel/core' - babel-plugin-macros - optional: true nf3@0.1.12: {} @@ -19305,12 +19294,6 @@ snapshots: dependencies: react: 19.2.0 - react-intersection-observer@10.0.0(react-dom@19.2.0(react@19.2.0))(react@19.2.0): - dependencies: - react: 19.2.0 - optionalDependencies: - react-dom: 19.2.0(react@19.2.0) - react-is@16.13.1: {} react-markdown@9.1.0(@types/react@19.2.6)(react@19.2.0): diff --git a/turbo.json b/turbo.json index 42c4cc69..4777bb74 100644 --- a/turbo.json +++ b/turbo.json @@ -35,6 +35,24 @@ "cache": false, "env": ["OPENAI_API_KEY"] }, + "e2e:smoke:nextjs": { + "dependsOn": ["^build"], + "outputs": [], + "cache": false, + "env": ["OPENAI_API_KEY", "BTST_FRAMEWORK"] + }, + "e2e:smoke:tanstack": { + "dependsOn": ["^build"], + "outputs": [], + "cache": false, + "env": ["OPENAI_API_KEY", "BTST_FRAMEWORK"] + }, + "e2e:smoke:react-router": { + "dependsOn": ["^build"], + "outputs": [], + "cache": false, + "env": ["OPENAI_API_KEY", "BTST_FRAMEWORK"] + }, "e2e:integration": { "dependsOn": ["^build"], "outputs": [],
`) | + +### Blog Post Integration + +The blog plugin exposes a `postBottomSlot` override that renders below every post: + +```tsx +import { CommentThread } from "@btst/stack/plugins/comments/client/components" + +overrides={{ + blog: { + postBottomSlot: (post) => ( + + ), + } +}} +``` + +### Kanban Task Integration + +The Kanban plugin exposes a `taskDetailBottomSlot` override that renders at the bottom of the task detail dialog: + +```tsx +import { CommentThread } from "@btst/stack/plugins/comments/client/components" + +overrides={{ + kanban: { + taskDetailBottomSlot: (task) => ( + + ), + } +}} +``` + +### Comment Count Badge + +Use `CommentCount` to show the number of approved comments anywhere (e.g., in a post listing): + +```tsx +import { CommentCount } from "@btst/stack/plugins/comments/client/components" + + +``` + +## Moderation Dashboard + +The comments plugin adds a `/comments/moderation` admin route with: + +- **Tabbed views** — Pending, Approved, Spam +- **Bulk actions** — Approve, Mark as spam, Delete +- **Comment detail dialog** — View full body and metadata +- **Per-row actions** — Approve, spam, delete from the table row + +Access is controlled by the `onBeforeModerationPageRendered` hook in `CommentsPluginOverrides`. + +## Backend Configuration + +### `commentsBackendPlugin` Options + +| Option | Type | Default | Description | +|--------|------|---------|-------------| +| `autoApprove` | `boolean` | `false` | Automatically approve new comments | +| `resolveUser` | `(authorId: string) => Promise<{ name: string; avatarUrl?: string } \| null>` | — | Map author IDs to display info; returns `null` → shows `"[deleted]"` | +| `onBeforeList` | hook | — | Called before the comment list or count is returned. Throw to reject. When absent, any `status` filter other than `"approved"` is automatically rejected with 403 on both `GET /comments` and `GET /comments/count` — preventing anonymous access to, or probing of, the moderation queues. | +| `onBeforePost` | hook | **required** | Called before a comment is saved. Must return `{ authorId: string }` derived from the authenticated session. Throw to reject. Plugin throws at startup if absent. | +| `onAfterPost` | hook | — | Called after a comment is saved. | +| `onBeforeEdit` | hook | — | Called before a comment body is updated. Throw to reject. When absent, **all edit requests return 403** — preventing any unauthenticated caller from tampering with comment bodies. Configure to verify the caller owns the comment. | +| `onAfterEdit` | hook | — | Called after a comment body is updated. | +| `onBeforeLike` | hook | — | Called before a like is toggled. Throw to reject. When absent, **all like/unlike requests return 403** — preventing unauthenticated callers from toggling likes on behalf of arbitrary user IDs. Configure to verify `authorId` matches the authenticated session. | +| `onBeforeStatusChange` | hook | — | Called before moderation status is changed. Throw to reject. When absent, **all status-change requests return 403** — preventing unauthenticated callers from moderating comments. Configure to verify the caller has admin/moderator privileges. | +| `onAfterApprove` | hook | — | Called after a comment is approved. | +| `onBeforeDelete` | hook | — | Called before a comment is deleted. Throw to reject. When absent, **all delete requests return 403** — preventing unauthenticated callers from deleting comments. Configure to enforce admin-only access. | +| `onAfterDelete` | hook | — | Called after a comment is deleted. | +| `onBeforeListByAuthor` | hook | — | Called before returning comments filtered by `authorId`. Throw to reject. When absent, **any request with `authorId` returns 403** — preventing anonymous callers from reading any user's comment history. Use to verify `authorId` matches the authenticated session. | +| `resolveCurrentUserId` | hook | **required** | Resolve the current authenticated user's ID from the session. Used to safely include the user's own pending comments alongside approved ones in `GET /comments`. The client-supplied `currentUserId` query parameter is never trusted — identity is resolved exclusively via this hook. Return `null`/`undefined` for unauthenticated requests. Plugin throws at startup if absent. | + + +**`onBeforePost` and `resolveCurrentUserId` are both required.** `commentsBackendPlugin` throws at startup if either is absent. + +- `onBeforePost` must return `{ authorId: string }` derived from the session — `authorId` is intentionally absent from the POST body so clients can never forge authorship. +- `resolveCurrentUserId` must return the session-verified user ID (or `null` when unauthenticated) — the `?currentUserId=…` query parameter sent by the client is completely discarded. + + +### Server-Side API (`stack.api.comments`) + +Direct database access without HTTP, useful in Server Components, cron jobs, or AI tools: + +```ts +const items = await myStack.api.comments.listComments({ + resourceId: "my-post", + resourceType: "blog-post", + status: "approved", +}) + +const count = await myStack.api.comments.getCommentCount({ + resourceId: "my-post", + resourceType: "blog-post", +}) +``` + + +`stack().api.*` calls bypass authorization hooks. Callers are responsible for access control. + + +## React Hooks + +Import hooks from `@btst/stack/plugins/comments/client/hooks`: + +```tsx +import { + useComments, + useCommentCount, + usePostComment, + useUpdateComment, + useDeleteComment, + useToggleLike, + useUpdateCommentStatus, +} from "@btst/stack/plugins/comments/client/hooks" + +// Fetch approved comments for a resource +const { data, isLoading } = useComments({ + resourceId: "my-post", + resourceType: "blog-post", + status: "approved", +}) + +// Post a new comment (includes optimistic update) +const { mutate: postComment } = usePostComment() +postComment({ + resourceId: "my-post", + resourceType: "blog-post", + authorId: "user-123", + body: "Great post!", +}) + +// Toggle like (one per user; optimistic update) +const { mutate: toggleLike } = useToggleLike() +toggleLike({ commentId: "comment-id", authorId: "user-123" }) + +// Moderate a comment +const { mutate: updateStatus } = useUpdateCommentStatus() +updateStatus({ id: "comment-id", status: "approved" }) +``` + +## My Comments Page + +The comments plugin registers a `/comments/my-comments` route that shows the current user's full comment history — all statuses (approved, pending, spam) in a single paginated table, newest first. + +**Features:** +- All comment statuses visible to the owner in one list, each with an inline status badge +- Prev / Next pagination (20 per page) +- Resource link column — click through to the original resource when `resourceLinks` is configured (links automatically include `#comments` so the page scrolls to the comment thread) +- Delete button with confirmation dialog — calls `DELETE /comments/:id` (governed by `onBeforeDelete`) +- Login prompt when `currentUserId` is not configured + +### Setup + +Configure the overrides in your layout and the security hook in your backend: + +```tsx title="app/pages/layout.tsx" +overrides={{ + comments: { + apiBaseURL: baseURL, + apiBasePath: "/api/data", + + // Provide the current user's ID so the page can scope the query + currentUserId: session?.user?.id, + + // Map resource types to URLs so comments link back to their resource + resourceLinks: { + "blog-post": (slug) => `/pages/blog/${slug}`, + "kanban-task": (id) => `/pages/kanban?task=${id}`, + }, + + onBeforeMyCommentsPageRendered: (context) => { + if (!session?.user) throw new Error("Authentication required") + }, + } +}} +``` + +```ts title="lib/stack.ts" +commentsBackendPlugin({ + // ... + onBeforeListByAuthor: async (authorId, _query, ctx) => { + const session = await getSession(ctx.headers) + if (!session?.user) throw new Error("Authentication required") + if (authorId !== session.user.id && !session.user.isAdmin) + throw new Error("Forbidden") + }, +}) +``` + + +**`onBeforeListByAuthor` is 403 by default.** Any `GET /comments?authorId=...` request returns 403 unless `onBeforeListByAuthor` is configured. This prevents anonymous callers from reading any user's comment history. Always validate that `authorId` matches the authenticated session. + + +## API Reference + +### Client Plugin Overrides + +Configure the comments plugin behavior from your layout: + +#### `CommentsPluginOverrides` + +| Field | Type | Description | +|-------|------|-------------| +| `apiBaseURL` | `string` | Base URL for API requests | +| `apiBasePath` | `string` | Path prefix for the API | +| `currentUserId` | `string \| (() => string \| undefined \| Promise)` | Authenticated user's ID — used by the My Comments page. Supports async functions for session-based resolution. | +| `defaultCommentPageSize` | `number` | Default number of top-level comments per page for all `CommentThread` instances. Overridden per-instance by the `pageSize` prop. Defaults to `100` when not set. | +| `resourceLinks` | `Record string>` | Per-resource-type URL builders for linking comments back to their resource on the My Comments page (e.g. `{ "blog-post": (slug) => "/pages/blog/" + slug }`). The plugin appends `#comments` automatically so the page scrolls to the thread. | +| `localization` | `Partial` | Override any UI string in the plugin. Import `COMMENTS_LOCALIZATION` from `@btst/stack/plugins/comments/client` to see all available keys. | +| `onBeforeModerationPageRendered` | hook | Called before rendering the moderation dashboard. Throw to deny access. | +| `onBeforeResourceCommentsRendered` | hook | Called before rendering the per-resource comments admin view. Throw to deny access. | +| `onBeforeMyCommentsPageRendered` | hook | Called before rendering the My Comments page. Throw to deny access (e.g. when no session exists). | + +### HTTP Endpoints + +| Method | Path | Description | +|--------|------|-------------| +| `GET` | `/comments` | List comments for a resource | +| `POST` | `/comments` | Create a new comment | +| `PATCH` | `/comments/:id` | Edit a comment body | +| `GET` | `/comments/count` | Get approved comment count | +| `POST` | `/comments/:id/like` | Toggle like on a comment | +| `PATCH` | `/comments/:id/status` | Update moderation status | +| `DELETE` | `/comments/:id` | Delete a comment | + +### `SerializedComment` + +Comments returned by the API include resolved author information: + +| Field | Type | Description | +|-------|------|-------------| +| `id` | `string` | Comment ID | +| `resourceId` | `string` | Resource identifier | +| `resourceType` | `string` | Resource type | +| `parentId` | `string \| null` | Parent comment ID for replies | +| `authorId` | `string` | Author user ID | +| `resolvedAuthorName` | `string` | Display name from `resolveUser`, or `"[deleted]"` | +| `resolvedAvatarUrl` | `string \| null` | Avatar URL from `resolveUser` | +| `body` | `string` | Comment body | +| `status` | `"pending" \| "approved" \| "spam"` | Moderation status | +| `likes` | `number` | Denormalized like count | +| `isLikedByCurrentUser` | `boolean` | Whether the requesting user has liked this comment | +| `editedAt` | `string \| null` | ISO date string if the comment was edited | +| `createdAt` | `string` | ISO date string | +| `updatedAt` | `string` | ISO date string | diff --git a/docs/content/docs/plugins/kanban.mdx b/docs/content/docs/plugins/kanban.mdx index 715e031f..1149005f 100644 --- a/docs/content/docs/plugins/kanban.mdx +++ b/docs/content/docs/plugins/kanban.mdx @@ -696,6 +696,32 @@ overrides={{ | `resolveUser` | `(userId: string) => KanbanUser \| null` | Resolve user info from ID | | `searchUsers` | `(query: string, boardId?: string) => KanbanUser[]` | Search/list users for picker | +**Slot overrides:** + +| Override | Type | Description | +|----------|------|-------------| +| `taskDetailBottomSlot` | `(task: SerializedTask) => ReactNode` | Render additional content below task details — use to embed a `CommentThread` | + +```tsx +import { CommentThread } from "@btst/stack/plugins/comments/client/components" + +overrides={{ + kanban: { + // ... + taskDetailBottomSlot: (task) => ( + + ), + } +}} +``` + ## React Hooks Import hooks from `@btst/stack/plugins/kanban/client/hooks` to use in your components: diff --git a/e2e/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..39f2e836 --- /dev/null +++ b/e2e/tests/smoke.comments.spec.ts @@ -0,0 +1,911 @@ +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* userId should not see this pending comment + const response = await request.get( + `/api/data/comments?resourceId=${encodeURIComponent(resourceId)}&resourceType=${encodeURIComponent(resourceType)}¤tUserId=some-other-user`, + ); + expect(response.ok()).toBeTruthy(); + const body = await response.json(); + + const found = body.items.find((c: { id: string }) => c.id === comment.id); + expect( + found, + "Pending comment from another author must NOT appear for a different currentUserId", + ).toBeUndefined(); + }); + + test("replyCount on parent includes own pending reply when currentUserId is provided", async ({ + request, + }) => { + const resourceId = `e2e-replycount-${Date.now()}`; + const resourceType = "e2e-test"; + + // Create and approve parent so it appears in the top-level list + const parent = await createComment(request, { + resourceId, + resourceType, + body: "Parent comment for reply-count test.", + }); + await approveComment(request, parent.id); + + // Post a pending reply + await createComment(request, { + resourceId, + resourceType, + parentId: parent.id, + body: "My pending reply — should increment replyCount.", + }); + + // Fetch top-level comments WITH currentUserId (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/my-comments", { + waitUntil: "networkidle", + }); + + // Either the list or the empty-state element must be visible + const hasPage = await page + .locator('[data-testid="my-comments-page"]') + .isVisible() + .catch(() => false); + const hasEmpty = await page + .locator('[data-testid="my-comments-empty"]') + .isVisible() + .catch(() => false); + expect( + hasPage || hasEmpty, + "Expected my-comments-page or my-comments-empty to be visible", + ).toBe(true); + + expect(errors, `Console errors detected:\n${errors.join("\n")}`).toEqual( + [], + ); + }); + + test("populated state — comment created by current user appears in list", async ({ + page, + request, + }) => { + const errors: string[] = []; + page.on("console", (msg) => { + if (msg.type() === "error") errors.push(msg.text()); + }); + + // Create a comment — the example app's onBeforePost assigns authorId "olliethedev" + const uniqueBody = `My comment e2e ${Date.now()}`; + await createComment(request, { + resourceId: `e2e-mycomments-${Date.now()}`, + resourceType: "e2e-test", + body: uniqueBody, + }); + + await page.goto("/pages/comments/my-comments", { + waitUntil: "networkidle", + }); + + await expect( + page.locator('[data-testid="my-comments-list"]'), + ).toBeVisible(); + + // The comment body should appear somewhere in the list (possibly on page 1) + await expect(page.getByText(uniqueBody)).toBeVisible(); + + expect(errors, `Console errors detected:\n${errors.join("\n")}`).toEqual( + [], + ); + }); + + test("delete from list — comment disappears after confirmation", async ({ + page, + request, + }) => { + const errors: string[] = []; + page.on("console", (msg) => { + if (msg.type() === "error") errors.push(msg.text()); + }); + + const uniqueBody = `Delete me e2e ${Date.now()}`; + await createComment(request, { + resourceId: `e2e-delete-mycomments-${Date.now()}`, + resourceType: "e2e-test", + body: uniqueBody, + }); + + await page.goto("/pages/comments/my-comments", { + waitUntil: "networkidle", + }); + + // Find the row containing our comment + const row = page + .locator('[data-testid="my-comment-row"]') + .filter({ hasText: uniqueBody }); + await expect(row).toBeVisible(); + + // Click the delete button on that row + await row.locator('[data-testid="my-comment-delete-button"]').click(); + + // Confirm the AlertDialog + await page.locator("button", { hasText: "Delete" }).last().click(); + await page.waitForLoadState("networkidle"); + + // Row should no longer be visible + await expect(page.getByText(uniqueBody)).not.toBeVisible({ timeout: 5000 }); + + expect(errors, `Console errors detected:\n${errors.join("\n")}`).toEqual( + [], + ); + }); + + test("API security — GET /comments?authorId=unknown returns 403", async ({ + request, + }) => { + // The example app's onBeforeListByAuthor only allows "olliethedev" + const response = await request.get( + `/api/data/comments?authorId=unknown-user-12345`, + ); + expect( + response.status(), + "Expected 403 when onBeforeListByAuthor is absent or rejects", + ).toBe(403); + }); + + test("API — GET /comments?authorId=olliethedev returns comments", async ({ + request, + }) => { + // Seed a comment so we have at least one + await createComment(request, { + resourceId: `e2e-api-author-${Date.now()}`, + resourceType: "e2e-test", + body: "Author filter API test", + }); + + const response = await request.get( + `/api/data/comments?authorId=${encodeURIComponent(AUTHOR_ID)}`, + ); + expect(response.ok(), "Expected 200 for own-author query").toBeTruthy(); + const body = await response.json(); + expect(Array.isArray(body.items)).toBe(true); + // All returned comments must belong to the requested author + for (const item of body.items) { + expect(item.authorId).toBe(AUTHOR_ID); + } + }); +}); + +// ─── Load More ──────────────────────────────────────────────────────────────── +// +// These tests verify the comment thread pagination that powers the "Load more +// comments" button. They mirror the blog smoke tests for load-more: an API +// contract test validates server-side limit/offset, and a UI test exercises +// the full click-to-fetch cycle in the browser. +// +// The example app layouts set defaultCommentPageSize: 5 so that pagination +// triggers after 5 comments — mirroring the blog's 10-per-page default. + +test.describe("Comment thread — load more", () => { + test("API pagination contract: limit/offset return correct slices", async ({ + request, + }) => { + const resourceId = `e2e-pagination-${Date.now()}`; + const resourceType = "e2e-test"; + + // Create and approve 7 top-level comments + await createApprovedComments(request, resourceId, resourceType, 7); + + // First page: 5 items, total = 7 + const page1 = await request.get( + `/api/data/comments?resourceId=${encodeURIComponent(resourceId)}&resourceType=${encodeURIComponent(resourceType)}&parentId=null&status=approved&limit=5&offset=0`, + ); + expect(page1.ok()).toBeTruthy(); + const body1 = await page1.json(); + expect(body1.items).toHaveLength(5); + expect(body1.total).toBe(7); + expect(body1.limit).toBe(5); + expect(body1.offset).toBe(0); + + // Second page: 2 items, total still = 7 + const page2 = await request.get( + `/api/data/comments?resourceId=${encodeURIComponent(resourceId)}&resourceType=${encodeURIComponent(resourceType)}&parentId=null&status=approved&limit=5&offset=5`, + ); + expect(page2.ok()).toBeTruthy(); + const body2 = await page2.json(); + expect(body2.items).toHaveLength(2); + expect(body2.total).toBe(7); + expect(body2.limit).toBe(5); + expect(body2.offset).toBe(5); + + // Third page (beyond end): 0 items + const page3 = await request.get( + `/api/data/comments?resourceId=${encodeURIComponent(resourceId)}&resourceType=${encodeURIComponent(resourceType)}&parentId=null&status=approved&limit=5&offset=10`, + ); + expect(page3.ok()).toBeTruthy(); + const body3 = await page3.json(); + expect(body3.items).toHaveLength(0); + expect(body3.total).toBe(7); + }); + + test("load more button on blog post page", async ({ page, request }) => { + const errors: string[] = []; + page.on("console", (msg) => { + if (msg.type() === "error") errors.push(msg.text()); + }); + + const slug = `e2e-lm-comments-${Date.now()}`; + + // Create a published blog post to host the comment thread + await createBlogPost(request, { + title: "Load More Comments Test Post", + slug, + }); + + // Create 7 approved comments so two pages are needed (pageSize = 5) + await createApprovedComments(request, slug, "blog-post", 7); + + await testLoadMoreComments(page, slug, 7, { + pageSize: 5, + bodyPrefix: "Load More Comment", + }); + + expect(errors, `Console errors detected:\n${errors.join("\n")}`).toEqual( + [], + ); + }); +}); diff --git a/examples/nextjs/app/api/data/[[...all]]/route.ts b/examples/nextjs/app/api/data/[[...all]]/route.ts index 8f4d4e31..d60f8ef4 100644 --- a/examples/nextjs/app/api/data/[[...all]]/route.ts +++ b/examples/nextjs/app/api/data/[[...all]]/route.ts @@ -3,4 +3,5 @@ import { handler } from "@/lib/stack" export const GET = handler export const POST = handler export const PUT = handler +export const PATCH = handler export const DELETE = handler diff --git a/examples/nextjs/app/globals.css b/examples/nextjs/app/globals.css index d37c0845..580ce0b6 100644 --- a/examples/nextjs/app/globals.css +++ b/examples/nextjs/app/globals.css @@ -23,6 +23,7 @@ /* Import Kanban plugin styles */ @import "@btst/stack/plugins/kanban/css"; +@import "@btst/stack/plugins/comments/css"; @custom-variant dark (&:is(.dark *)); diff --git a/examples/nextjs/app/pages/layout.tsx b/examples/nextjs/app/pages/layout.tsx index 98117fbe..54d52ab3 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 + }, + onBeforeMyCommentsPageRendered: (context) => { + console.log(`[${context.isSSR ? 'SSR' : 'CSR'}] onBeforeMyCommentsPageRendered`); + return true; // In production: check authenticated session + }, } }} > diff --git a/examples/nextjs/lib/stack-client.tsx b/examples/nextjs/lib/stack-client.tsx index 02e2e282..5dcccd8c 100644 --- a/examples/nextjs/lib/stack-client.tsx +++ b/examples/nextjs/lib/stack-client.tsx @@ -7,6 +7,7 @@ import { formBuilderClientPlugin } from "@btst/stack/plugins/form-builder/client import { uiBuilderClientPlugin, defaultComponentRegistry } from "@btst/stack/plugins/ui-builder/client" import { routeDocsClientPlugin } from "@btst/stack/plugins/route-docs/client" import { kanbanClientPlugin } from "@btst/stack/plugins/kanban/client" +import { commentsClientPlugin } from "@btst/stack/plugins/comments/client" import { QueryClient } from "@tanstack/react-query" // Get base URL function - works on both server and client @@ -175,6 +176,15 @@ export const getStackClient = ( }, }, }), + // Comments plugin — registers the /comments/moderation admin route + comments: commentsClientPlugin({ + apiBaseURL: baseURL, + apiBasePath: "/api/data", + siteBaseURL: baseURL, + siteBasePath: "/pages", + queryClient: queryClient, + headers: options?.headers, + }), } }) } diff --git a/examples/nextjs/lib/stack.ts b/examples/nextjs/lib/stack.ts index 0af9829e..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..cd05869e 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 + }, + onBeforeMyCommentsPageRendered: (context) => { + console.log(`[${context.isSSR ? 'SSR' : 'CSR'}] onBeforeMyCommentsPageRendered`); + return true; // In production: check authenticated session + }, } }} > diff --git a/examples/tanstack/src/lib/stack-client.tsx b/examples/tanstack/src/lib/stack-client.tsx index 5cb52761..043ce077 100644 --- a/examples/tanstack/src/lib/stack-client.tsx +++ b/examples/tanstack/src/lib/stack-client.tsx @@ -6,6 +6,7 @@ import { formBuilderClientPlugin } from "@btst/stack/plugins/form-builder/client import { uiBuilderClientPlugin, defaultComponentRegistry } from "@btst/stack/plugins/ui-builder/client" import { routeDocsClientPlugin } from "@btst/stack/plugins/route-docs/client" import { kanbanClientPlugin } from "@btst/stack/plugins/kanban/client" +import { commentsClientPlugin } from "@btst/stack/plugins/comments/client" import { QueryClient } from "@tanstack/react-query" // Get base URL function - works on both server and client @@ -133,6 +134,14 @@ export const getStackClient = (queryClient: QueryClient) => { description: "Manage your projects with kanban boards", }, }), + // Comments plugin — registers the /comments/moderation admin route + comments: commentsClientPlugin({ + apiBaseURL: baseURL, + apiBasePath: "/api/data", + siteBaseURL: baseURL, + siteBasePath: "/pages", + queryClient: queryClient, + }), } }) } diff --git a/examples/tanstack/src/lib/stack.ts b/examples/tanstack/src/lib/stack.ts index ac4b0be1..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..0f5f80f6 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 + }, + onBeforeMyCommentsPageRendered: (context) => { + console.log(`[${context.isSSR ? 'SSR' : 'CSR'}] onBeforeMyCommentsPageRendered`); + return true; // In production: check authenticated session + }, } }} > diff --git a/examples/tanstack/src/styles/globals.css b/examples/tanstack/src/styles/globals.css index 57c5835a..59c07329 100644 --- a/examples/tanstack/src/styles/globals.css +++ b/examples/tanstack/src/styles/globals.css @@ -7,6 +7,7 @@ @import "@btst/stack/plugins/ai-chat/css"; @import "@btst/stack/plugins/ui-builder/css"; @import "@btst/stack/plugins/kanban/css"; +@import "@btst/stack/plugins/comments/css"; @custom-variant dark (&:is(.dark *)); diff --git a/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..e75813d6 --- /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\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 MyCommentsPageProps {\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 MyCommentsPage({\n\tapiBaseURL,\n\tapiBasePath,\n\theaders,\n\tcurrentUserId: currentUserIdProp,\n\tresourceLinks,\n\tlocalization: localizationProp,\n}: MyCommentsPageProps) {\n\tconst loc = { ...COMMENTS_LOCALIZATION, ...localizationProp };\n\tconst resolvedUserId = useResolvedCurrentUserId(currentUserIdProp);\n\n\tif (!resolvedUserId) {\n\t\treturn (\n\t\t\t\n\t\t\t\t\n\t\t\t\t{loc.COMMENTS_MY_LOGIN_TITLE}\n\t\t\t\t\n\t\t\t\t\t{loc.COMMENTS_MY_LOGIN_DESCRIPTION}\n\t\t\t\t\n\t\t\t\n\t\t);\n\t}\n\n\treturn (\n\t\t\n\t);\n}\n\n// ─── List (suspense boundary is in ComposedRoute) ─────────────────────────────\n\nfunction MyCommentsList({\n\tapiBaseURL,\n\tapiBasePath,\n\theaders,\n\tcurrentUserId,\n\tresourceLinks,\n\tloc,\n}: {\n\tapiBaseURL: string;\n\tapiBasePath: string;\n\theaders?: HeadersInit;\n\tcurrentUserId: string;\n\tresourceLinks?: CommentsPluginOverrides[\"resourceLinks\"];\n\tloc: CommentsLocalization;\n}) {\n\tconst [page, setPage] = useState(1);\n\tconst [deleteId, setDeleteId] = useState(null);\n\n\tconst config = { apiBaseURL, apiBasePath, headers };\n\tconst offset = (page - 1) * PAGE_LIMIT;\n\n\tconst { comments, total, refetch } = useSuspenseComments(config, {\n\t\tauthorId: currentUserId,\n\t\tsort: \"desc\",\n\t\tlimit: PAGE_LIMIT,\n\t\toffset,\n\t});\n\n\tconst deleteMutation = useDeleteComment(config);\n\n\tconst totalPages = Math.max(1, Math.ceil(total / PAGE_LIMIT));\n\n\tconst handleDelete = async () => {\n\t\tif (!deleteId) return;\n\t\ttry {\n\t\t\tawait deleteMutation.mutateAsync(deleteId);\n\t\t\ttoast.success(loc.COMMENTS_MY_TOAST_DELETED);\n\t\t\trefetch();\n\t\t} catch {\n\t\t\ttoast.error(loc.COMMENTS_MY_TOAST_DELETE_ERROR);\n\t\t} finally {\n\t\t\tsetDeleteId(null);\n\t\t}\n\t};\n\n\tif (comments.length === 0 && page === 1) {\n\t\treturn (\n\t\t\t\n\t\t\t\t\n\t\t\t\t{loc.COMMENTS_MY_EMPTY_TITLE}\n\t\t\t\t\n\t\t\t\t\t{loc.COMMENTS_MY_EMPTY_DESCRIPTION}\n\t\t\t\t\n\t\t\t\n\t\t);\n\t}\n\n\treturn (\n\t\t\n\t\t\t\n\t\t\t\t\n\t\t\t\t\t{loc.COMMENTS_MY_PAGE_TITLE}\n\t\t\t\t\n\t\t\t\t\n\t\t\t\t\t{total} {loc.COMMENTS_MY_COL_COMMENT.toLowerCase()}\n\t\t\t\t\t{total !== 1 ? \"s\" : \"\"}\n\t\t\t\t\n\t\t\t\n\n\t\t\t\n\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t{loc.COMMENTS_MY_COL_COMMENT}\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t{loc.COMMENTS_MY_COL_RESOURCE}\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t{loc.COMMENTS_MY_COL_STATUS}\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t{loc.COMMENTS_MY_COL_DATE}\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\t{comments.map((comment) => (\n\t\t\t\t\t\t\t setDeleteId(comment.id)}\n\t\t\t\t\t\t\t\tisDeleting={deleteMutation.isPending && deleteId === comment.id}\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t))}\n\t\t\t\t\t\n\t\t\t\t\n\n\t\t\t\t {\n\t\t\t\t\t\tsetPage(p);\n\t\t\t\t\t\twindow.scrollTo({ top: 0, behavior: \"smooth\" });\n\t\t\t\t\t}}\n\t\t\t\t/>\n\t\t\t\n\n\t\t\t !open && setDeleteId(null)}\n\t\t\t>\n\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\t{loc.COMMENTS_MY_DELETE_TITLE}\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t{loc.COMMENTS_MY_DELETE_DESCRIPTION}\n\t\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t{loc.COMMENTS_MY_DELETE_CANCEL}\n\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t{loc.COMMENTS_MY_DELETE_CONFIRM}\n\t\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\n\t\t\t\n\t\t\n\t);\n}\n\n// ─── Row ──────────────────────────────────────────────────────────────────────\n\nfunction CommentRow({\n\tcomment,\n\tresourceLinks,\n\tloc,\n\tonDelete,\n\tisDeleting,\n}: {\n\tcomment: SerializedComment;\n\tresourceLinks?: CommentsPluginOverrides[\"resourceLinks\"];\n\tloc: CommentsLocalization;\n\tonDelete: () => void;\n\tisDeleting: boolean;\n}) {\n\tconst 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 MyCommentsPageInternal = lazy(() =>\n\timport(\"./my-comments-page.internal\").then((m) => ({\n\t\tdefault: m.MyCommentsPage,\n\t})),\n);\n\nfunction MyCommentsPageSkeleton() {\n\treturn (\n\t\t\n\t\t\t\n\t\t\t\n\t\t\t\n\t\t\n\t);\n}\n\nexport function MyCommentsPageComponent() {\n\treturn (\n\t\t\n\t\t\t\tconsole.error(\"[btst/comments] My Comments error:\", error)\n\t\t\t}\n\t\t/>\n\t);\n}\n\nfunction MyCommentsPageWrapper() {\n\tconst overrides = usePluginOverrides(\"comments\");\n\tconst loc = { ...COMMENTS_LOCALIZATION, ...overrides.localization };\n\n\tuseRouteLifecycle({\n\t\trouteName: \"myComments\",\n\t\tcontext: {\n\t\t\tpath: \"/comments/my-comments\",\n\t\t\tisSSR: typeof window === \"undefined\",\n\t\t},\n\t\toverrides,\n\t\tbeforeRenderHook: (o, context) => {\n\t\t\tif (o.onBeforeMyCommentsPageRendered) {\n\t\t\t\tconst result = o.onBeforeMyCommentsPageRendered(context);\n\t\t\t\treturn result === false ? false : true;\n\t\t\t}\n\t\t\treturn true;\n\t\t},\n\t});\n\n\treturn (\n\t\t\n\t\t\t\n\t\t\n\t);\n}\n", + "target": "src/components/btst/comments/client/components/pages/my-comments-page.tsx" + }, + { + "path": "btst/comments/client/components/pages/resource-comments-page.internal.tsx", + "type": "registry:component", + "content": "\"use client\";\n\nimport type { SerializedComment } from \"../../../types\";\nimport {\n\tuseSuspenseComments,\n\tuseUpdateCommentStatus,\n\tuseDeleteComment,\n} from \"@btst/stack/plugins/comments/client/hooks\";\nimport { CommentThread } from \"../comment-thread\";\nimport { Button } from \"@/components/ui/button\";\nimport { Badge } from \"@/components/ui/badge\";\nimport {\n\tAvatar,\n\tAvatarFallback,\n\tAvatarImage,\n} from \"@/components/ui/avatar\";\nimport { CheckCircle, ShieldOff, Trash2 } from \"lucide-react\";\nimport { formatDistanceToNow } from \"date-fns\";\nimport { toast } from \"sonner\";\nimport {\n\tCOMMENTS_LOCALIZATION,\n\ttype CommentsLocalization,\n} from \"../../localization\";\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 My 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 My Comments page.\n\t *\n\t * @example\n\t * ```ts\n\t * resourceLinks: {\n\t * \"blog-post\": (slug) => `/pages/blog/${slug}`,\n\t * \"kanban-task\": (id) => `/pages/kanban?task=${id}`,\n\t * }\n\t * ```\n\t *\n\t * When a resource type has no entry the ID is shown as plain text.\n\t */\n\tresourceLinks?: Record string>;\n\n\t// ============ Access Control Hooks ============\n\n\t/**\n\t * Called before the moderation dashboard page is rendered.\n\t * Return false to block rendering (e.g., redirect to login or show 403).\n\t * @param context - Route context\n\t */\n\tonBeforeModerationPageRendered?: (context: RouteContext) => boolean;\n\n\t/**\n\t * Called before the per-resource comments page is rendered.\n\t * Return false to block rendering (e.g., for authorization).\n\t * @param resourceType - The type of resource (e.g., \"blog-post\")\n\t * @param resourceId - The ID of the resource\n\t * @param context - Route context\n\t */\n\tonBeforeResourceCommentsRendered?: (\n\t\tresourceType: string,\n\t\tresourceId: string,\n\t\tcontext: RouteContext,\n\t) => boolean;\n\n\t/**\n\t * Called before the My Comments page is rendered.\n\t * Throw to block rendering (e.g., when the user is not authenticated).\n\t * @param context - Route context\n\t */\n\tonBeforeMyCommentsPageRendered?: (context: RouteContext) => boolean | void;\n\n\t// ============ Lifecycle Hooks ============\n\n\t/**\n\t * Called when a route is rendered.\n\t * @param routeName - Name of the route (e.g., 'moderation', 'resourceComments')\n\t * @param context - Route context\n\t */\n\tonRouteRender?: (\n\t\trouteName: string,\n\t\tcontext: RouteContext,\n\t) => void | Promise;\n\n\t/**\n\t * Called when a route encounters an error.\n\t * @param routeName - Name of the route\n\t * @param error - The error that occurred\n\t * @param context - Route context\n\t */\n\tonRouteError?: (\n\t\trouteName: string,\n\t\terror: Error,\n\t\tcontext: RouteContext,\n\t) => void | Promise;\n}\n", + "target": "src/components/btst/comments/client/overrides.ts" + }, + { + "path": "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..a1bbe568 --- /dev/null +++ b/packages/stack/src/plugins/comments/client/components/comment-thread.tsx @@ -0,0 +1,792 @@ +"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 */} + { + 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..bb309515 --- /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 MyCommentsPageProps { + 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 MyCommentsPage({ + apiBaseURL, + apiBasePath, + headers, + currentUserId: currentUserIdProp, + resourceLinks, + localization: localizationProp, +}: MyCommentsPageProps) { + const loc = { ...COMMENTS_LOCALIZATION, ...localizationProp }; + const resolvedUserId = useResolvedCurrentUserId(currentUserIdProp); + + if (!resolvedUserId) { + return ( + + + {loc.COMMENTS_MY_LOGIN_TITLE} + + {loc.COMMENTS_MY_LOGIN_DESCRIPTION} + + + ); + } + + return ( + + ); +} + +// ─── List (suspense boundary is in ComposedRoute) ───────────────────────────── + +function MyCommentsList({ + apiBaseURL, + apiBasePath, + headers, + currentUserId, + resourceLinks, + loc, +}: { + apiBaseURL: string; + apiBasePath: string; + headers?: HeadersInit; + currentUserId: string; + resourceLinks?: CommentsPluginOverrides["resourceLinks"]; + loc: CommentsLocalization; +}) { + const [page, setPage] = useState(1); + const [deleteId, setDeleteId] = useState(null); + + const config = { apiBaseURL, apiBasePath, headers }; + const offset = (page - 1) * PAGE_LIMIT; + + const { comments, total, refetch } = useSuspenseComments(config, { + authorId: currentUserId, + sort: "desc", + limit: PAGE_LIMIT, + offset, + }); + + const deleteMutation = useDeleteComment(config); + + const totalPages = Math.max(1, Math.ceil(total / PAGE_LIMIT)); + + const handleDelete = async () => { + if (!deleteId) return; + try { + await deleteMutation.mutateAsync(deleteId); + toast.success(loc.COMMENTS_MY_TOAST_DELETED); + refetch(); + } catch { + toast.error(loc.COMMENTS_MY_TOAST_DELETE_ERROR); + } finally { + setDeleteId(null); + } + }; + + if (comments.length === 0 && page === 1) { + return ( + + + {loc.COMMENTS_MY_EMPTY_TITLE} + + {loc.COMMENTS_MY_EMPTY_DESCRIPTION} + + + ); + } + + return ( + + + + {loc.COMMENTS_MY_PAGE_TITLE} + + + {total} {loc.COMMENTS_MY_COL_COMMENT.toLowerCase()} + {total !== 1 ? "s" : ""} + + + + + + + + + {loc.COMMENTS_MY_COL_COMMENT} + + {loc.COMMENTS_MY_COL_RESOURCE} + + + {loc.COMMENTS_MY_COL_STATUS} + + + {loc.COMMENTS_MY_COL_DATE} + + + + + + {comments.map((comment) => ( + setDeleteId(comment.id)} + isDeleting={deleteMutation.isPending && deleteId === comment.id} + /> + ))} + + + + { + setPage(p); + window.scrollTo({ top: 0, behavior: "smooth" }); + }} + /> + + + !open && setDeleteId(null)} + > + + + {loc.COMMENTS_MY_DELETE_TITLE} + + {loc.COMMENTS_MY_DELETE_DESCRIPTION} + + + + + {loc.COMMENTS_MY_DELETE_CANCEL} + + + {loc.COMMENTS_MY_DELETE_CONFIRM} + + + + + + ); +} + +// ─── Row ────────────────────────────────────────────────────────────────────── + +function CommentRow({ + comment, + resourceLinks, + loc, + onDelete, + isDeleting, +}: { + comment: SerializedComment; + resourceLinks?: CommentsPluginOverrides["resourceLinks"]; + loc: CommentsLocalization; + onDelete: () => void; + isDeleting: boolean; +}) { + const 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..79992106 --- /dev/null +++ b/packages/stack/src/plugins/comments/client/components/pages/my-comments-page.tsx @@ -0,0 +1,72 @@ +"use client"; + +import { lazy } from "react"; +import { ComposedRoute } from "@btst/stack/client/components"; +import { usePluginOverrides } from "@btst/stack/context"; +import type { CommentsPluginOverrides } from "../../overrides"; +import { COMMENTS_LOCALIZATION } from "../../localization"; +import { useRouteLifecycle } from "@workspace/ui/hooks/use-route-lifecycle"; +import { PageWrapper } from "../shared/page-wrapper"; + +const MyCommentsPageInternal = lazy(() => + import("./my-comments-page.internal").then((m) => ({ + default: m.MyCommentsPage, + })), +); + +function MyCommentsPageSkeleton() { + return ( + + + + + + ); +} + +export function MyCommentsPageComponent() { + return ( + + console.error("[btst/comments] My Comments error:", error) + } + /> + ); +} + +function MyCommentsPageWrapper() { + const overrides = usePluginOverrides("comments"); + const loc = { ...COMMENTS_LOCALIZATION, ...overrides.localization }; + + useRouteLifecycle({ + routeName: "myComments", + context: { + path: "/comments/my-comments", + isSSR: typeof window === "undefined", + }, + overrides, + beforeRenderHook: (o, context) => { + if (o.onBeforeMyCommentsPageRendered) { + const result = o.onBeforeMyCommentsPageRendered(context); + return result === false ? false : true; + } + return true; + }, + }); + + return ( + + + + ); +} diff --git a/packages/stack/src/plugins/comments/client/components/pages/resource-comments-page.internal.tsx b/packages/stack/src/plugins/comments/client/components/pages/resource-comments-page.internal.tsx new file mode 100644 index 00000000..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..fdf46d5c --- /dev/null +++ b/packages/stack/src/plugins/comments/client/hooks/use-comments.tsx @@ -0,0 +1,703 @@ +"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) return old; + return { + ...old, + pages: old.pages.map((page) => ({ + ...page, + items: page.items.map((item) => + item.id === context.optimisticId ? data : item, + ), + })), + }; + }, + ); + } else { + queryClient.setQueryData(context.listKey, (old) => { + if (!old) { + // 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..5835918d --- /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 My 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 My Comments page. + * + * @example + * ```ts + * resourceLinks: { + * "blog-post": (slug) => `/pages/blog/${slug}`, + * "kanban-task": (id) => `/pages/kanban?task=${id}`, + * } + * ``` + * + * When a resource type has no entry the ID is shown as plain text. + */ + resourceLinks?: Record string>; + + // ============ Access Control Hooks ============ + + /** + * Called before the moderation dashboard page is rendered. + * Return false to block rendering (e.g., redirect to login or show 403). + * @param context - Route context + */ + onBeforeModerationPageRendered?: (context: RouteContext) => boolean; + + /** + * Called before the per-resource comments page is rendered. + * Return false to block rendering (e.g., for authorization). + * @param resourceType - The type of resource (e.g., "blog-post") + * @param resourceId - The ID of the resource + * @param context - Route context + */ + onBeforeResourceCommentsRendered?: ( + resourceType: string, + resourceId: string, + context: RouteContext, + ) => boolean; + + /** + * Called before the My Comments page is rendered. + * Throw to block rendering (e.g., when the user is not authenticated). + * @param context - Route context + */ + onBeforeMyCommentsPageRendered?: (context: RouteContext) => boolean | void; + + // ============ Lifecycle Hooks ============ + + /** + * Called when a route is rendered. + * @param routeName - Name of the route (e.g., 'moderation', 'resourceComments') + * @param context - Route context + */ + onRouteRender?: ( + routeName: string, + context: RouteContext, + ) => void | Promise; + + /** + * Called when a route encounters an error. + * @param routeName - Name of the route + * @param error - The error that occurred + * @param context - Route context + */ + onRouteError?: ( + routeName: string, + error: Error, + context: RouteContext, + ) => void | Promise; +} diff --git a/packages/stack/src/plugins/comments/client/plugin.tsx b/packages/stack/src/plugins/comments/client/plugin.tsx new file mode 100644 index 00000000..f1d2d7db --- /dev/null +++ b/packages/stack/src/plugins/comments/client/plugin.tsx @@ -0,0 +1,162 @@ +// NO "use client" here! This file runs on both server and client. +import { lazy } from "react"; +import { + defineClientPlugin, + isConnectionError, +} from "@btst/stack/plugins/client"; +import { createRoute } from "@btst/yar"; +import type { QueryClient } from "@tanstack/react-query"; + +// Lazy load page components for code splitting +const ModerationPageComponent = lazy(() => + import("./components/pages/moderation-page").then((m) => ({ + default: m.ModerationPageComponent, + })), +); + +const MyCommentsPageComponent = lazy(() => + import("./components/pages/my-comments-page").then((m) => ({ + default: m.MyCommentsPageComponent, + })), +); + +/** + * Context passed to loader hooks + */ +export interface LoaderContext { + /** Current route path */ + path: string; + /** Route parameters */ + params?: Record; + /** Whether rendering on server (true) or client (false) */ + isSSR: boolean; + /** Base URL for API calls */ + apiBaseURL: string; + /** Path where the API is mounted */ + apiBasePath: string; + /** Optional headers for the request */ + headers?: Headers; + /** Additional context properties */ + [key: string]: unknown; +} + +/** + * Hooks for Comments client plugin + */ +export interface CommentsClientHooks { + /** + * Called before loading the moderation page. Throw to cancel. + */ + beforeLoadModeration?: (context: LoaderContext) => Promise | void; + /** + * Called before loading the My Comments page. Throw to cancel. + */ + beforeLoadMyComments?: (context: LoaderContext) => Promise | void; + /** + * Called when a loading error occurs. + */ + onLoadError?: (error: Error, context: LoaderContext) => Promise | void; +} + +/** + * Configuration for the Comments client plugin + */ +export interface CommentsClientConfig { + /** Base URL for API calls (e.g., "http://localhost:3000") */ + apiBaseURL: string; + /** Path where the API is mounted (e.g., "/api/data") */ + apiBasePath: string; + /** Base URL of your site */ + siteBaseURL: string; + /** Path where pages are mounted (e.g., "/pages") */ + siteBasePath: string; + /** React Query client instance */ + queryClient: QueryClient; + /** Optional headers for SSR */ + headers?: Headers; + /** Optional lifecycle hooks */ + hooks?: CommentsClientHooks; +} + +function createModerationLoader(config: CommentsClientConfig) { + return async () => { + if (typeof window === "undefined") { + const { apiBasePath, apiBaseURL, headers, hooks } = config; + const context: LoaderContext = { + path: "/comments/moderation", + isSSR: true, + apiBaseURL, + apiBasePath, + headers, + }; + try { + if (hooks?.beforeLoadModeration) { + await hooks.beforeLoadModeration(context); + } + } catch (error) { + if (isConnectionError(error)) { + console.warn( + "[btst/comments] route.loader() failed — no server running at build time.", + ); + } + if (hooks?.onLoadError) { + await hooks.onLoadError(error as Error, context); + } + } + } + }; +} + +function createMyCommentsLoader(config: CommentsClientConfig) { + return async () => { + if (typeof window === "undefined") { + const { apiBasePath, apiBaseURL, headers, hooks } = config; + const context: LoaderContext = { + path: "/comments/my-comments", + isSSR: true, + apiBaseURL, + apiBasePath, + headers, + }; + try { + if (hooks?.beforeLoadMyComments) { + await hooks.beforeLoadMyComments(context); + } + } catch (error) { + if (isConnectionError(error)) { + console.warn( + "[btst/comments] route.loader() failed — no server running at build time.", + ); + } + if (hooks?.onLoadError) { + await hooks.onLoadError(error as Error, context); + } + } + } + }; +} + +/** + * Comments client plugin — registers admin moderation routes. + * + * The embeddable `CommentThread` and `CommentCount` components are standalone + * and do not require this plugin to be registered. Register them manually + * via the layout overrides pattern or use them directly in your pages. + */ +export const commentsClientPlugin = (config: CommentsClientConfig) => + defineClientPlugin({ + name: "comments", + + routes: () => ({ + moderation: createRoute("/comments/moderation", () => ({ + PageComponent: ModerationPageComponent, + loader: createModerationLoader(config), + meta: () => [{ title: "Comment Moderation" }], + })), + myComments: createRoute("/comments/my-comments", () => ({ + PageComponent: MyCommentsPageComponent, + loader: createMyCommentsLoader(config), + meta: () => [{ title: "My Comments" }], + })), + }), + }); diff --git a/packages/stack/src/plugins/comments/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>, + headers?: HeadersInit, +) { + return createQueryKeys("comments", { + list: (params?: CommentsListParams) => ({ + queryKey: [commentsListDiscriminator(params)], + queryFn: async (): Promise => { + const response = await client("/comments", { + method: "GET", + query: { + resourceId: params?.resourceId, + resourceType: params?.resourceType, + parentId: params?.parentId === null ? "null" : params?.parentId, + status: params?.status, + // currentUserId is intentionally NOT sent to the server. + // The server resolves the caller's identity server-side via the + // resolveCurrentUserId hook. Sending it would allow any caller to + // impersonate another user and read their pending comments. + // It is still included in the queryKey above for client-side + // cache segregation (different users get different cache entries). + authorId: params?.authorId, + sort: params?.sort, + limit: params?.limit ?? 20, + offset: params?.offset ?? 0, + }, + headers, + }); + + if (isErrorResponse(response)) { + throw toError((response as { error: unknown }).error); + } + + const data = (response as { data?: unknown }).data as + | CommentListResult + | undefined; + return data ?? { items: [], total: 0, limit: 20, offset: 0 }; + }, + }), + }); +} + +function createCommentCountQueries( + client: ReturnType>, + headers?: HeadersInit, +) { + return createQueryKeys("commentCount", { + byResource: (params: CommentCountParams) => ({ + queryKey: [commentCountDiscriminator(params)], + queryFn: async (): Promise => { + const response = await client("/comments/count", { + method: "GET", + query: { + resourceId: params.resourceId, + resourceType: params.resourceType, + status: params.status, + }, + headers, + }); + + if (isErrorResponse(response)) { + throw toError((response as { error: unknown }).error); + } + + const data = (response as { data?: unknown }).data as + | { count: number } + | undefined; + return data?.count ?? 0; + }, + }), + }); +} + +/** + * Factory for the infinite thread query key family. + * Mirrors the blog's `createPostsQueries` pattern: the key is stable (no offset), + * and pages are fetched via `pageParam` passed to the queryFn. + */ +function createCommentsThreadQueries( + client: ReturnType>, + headers?: HeadersInit, +) { + return createQueryKeys("commentsThread", { + list: (params?: { + resourceId?: string; + resourceType?: string; + parentId?: string | null; + status?: "pending" | "approved" | "spam"; + currentUserId?: string; + limit?: number; + }) => ({ + // Offset is excluded from the key — it is driven by pageParam. + queryKey: [commentsThreadDiscriminator(params)], + queryFn: async ({ + pageParam, + }: { + pageParam?: number; + } = {}): Promise => { + const response = await client("/comments", { + method: "GET", + query: { + resourceId: params?.resourceId, + resourceType: params?.resourceType, + parentId: params?.parentId === null ? "null" : params?.parentId, + status: params?.status, + // currentUserId is intentionally NOT sent to the server. + // The server resolves the caller's identity server-side via the + // resolveCurrentUserId hook. It is still included in the queryKey + // above for client-side cache segregation. + limit: params?.limit ?? 20, + offset: pageParam ?? 0, + }, + headers, + }); + + if (isErrorResponse(response)) { + throw toError((response as { error: unknown }).error); + } + + const data = (response as { data?: unknown }).data as + | CommentListResult + | undefined; + return ( + data ?? { + items: [], + total: 0, + limit: params?.limit ?? 20, + offset: pageParam ?? 0, + } + ); + }, + }), + }); +} diff --git a/packages/stack/src/plugins/comments/schemas.ts b/packages/stack/src/plugins/comments/schemas.ts new file mode 100644 index 00000000..2ea2d766 --- /dev/null +++ b/packages/stack/src/plugins/comments/schemas.ts @@ -0,0 +1,72 @@ +import { z } from "zod"; + +export const CommentStatusSchema = z.enum(["pending", "approved", "spam"]); + +// ============ Comment Schemas ============ + +/** + * Schema for the POST /comments request body. + * authorId is intentionally absent — the server resolves identity from the + * session inside onBeforePost and injects it. Never trust authorId from the + * client body. + */ +export const createCommentSchema = z.object({ + resourceId: z.string().min(1, "Resource ID is required"), + resourceType: z.string().min(1, "Resource type is required"), + parentId: z.string().optional().nullable(), + body: z.string().min(1, "Body is required").max(10000, "Comment too long"), +}); + +/** + * Internal schema used after the authorId has been resolved server-side. + * This is what gets passed to createComment() in mutations.ts. + */ +export const createCommentInternalSchema = createCommentSchema.extend({ + authorId: z.string().min(1, "Author ID is required"), +}); + +export const updateCommentSchema = z.object({ + body: z.string().min(1, "Body is required").max(10000, "Comment too long"), +}); + +export const updateCommentStatusSchema = z.object({ + status: CommentStatusSchema, +}); + +// ============ Query Schemas ============ + +/** + * Schema for GET /comments query parameters. + * + * `currentUserId` is intentionally absent — it is never accepted from the client. + * The server always resolves the caller's identity via the `resolveCurrentUserId` + * hook and injects it internally. Accepting it from the client would allow any + * anonymous caller to supply an arbitrary user ID and read that user's pending + * (pre-moderation) comments. + */ +export const CommentListQuerySchema = z.object({ + resourceId: z.string().optional(), + resourceType: z.string().optional(), + parentId: z.string().optional().nullable(), + status: CommentStatusSchema.optional(), + authorId: z.string().optional(), + sort: z.enum(["asc", "desc"]).optional(), + limit: z.coerce.number().int().min(1).max(100).optional(), + offset: z.coerce.number().int().min(0).optional(), +}); + +/** + * Internal params schema used by `listComments()` and the `api` factory. + * Extends the HTTP query schema with `currentUserId`, which is always injected + * server-side (either by the HTTP handler via `resolveCurrentUserId`, or by a + * trusted server-side caller such as a Server Component or cron job). + */ +export const CommentListParamsSchema = CommentListQuerySchema.extend({ + currentUserId: z.string().optional(), +}); + +export const CommentCountQuerySchema = z.object({ + resourceId: z.string().min(1), + resourceType: z.string().min(1), + status: CommentStatusSchema.optional(), +}); diff --git a/packages/stack/src/plugins/comments/style.css b/packages/stack/src/plugins/comments/style.css new file mode 100644 index 00000000..aca480b1 --- /dev/null +++ b/packages/stack/src/plugins/comments/style.css @@ -0,0 +1,15 @@ +@import "./client.css"; + +/* + * Comments Plugin CSS - Includes Tailwind class scanning + * + * When consumed from npm, Tailwind v4 will automatically scan this package's + * source files for Tailwind classes. Consumers only need: + * @import "@btst/stack/plugins/comments/css"; + */ + +/* Scan this package's source files for Tailwind classes */ +@source "../../../src/**/*.{ts,tsx}"; + +/* Scan UI package components (when installed as npm package the UI package will be in this dir) */ +@source "../../packages/ui/src"; diff --git a/packages/stack/src/plugins/comments/types.ts b/packages/stack/src/plugins/comments/types.ts new file mode 100644 index 00000000..006a86e4 --- /dev/null +++ b/packages/stack/src/plugins/comments/types.ts @@ -0,0 +1,73 @@ +/** + * Comment status values + */ +export type CommentStatus = "pending" | "approved" | "spam"; + +/** + * A comment record as stored in the database + */ +export type Comment = { + id: string; + resourceId: string; + resourceType: string; + parentId: string | null; + authorId: string; + body: string; + status: CommentStatus; + likes: number; + editedAt?: Date; + createdAt: Date; + updatedAt: Date; +}; + +/** + * A like record linking an author to a comment + */ +export type CommentLike = { + id: string; + commentId: string; + authorId: string; + createdAt: Date; +}; + +/** + * A comment enriched with server-resolved author info and like status. + * All dates are ISO strings (safe for serialisation over HTTP / React Query cache). + */ +export interface SerializedComment { + id: string; + resourceId: string; + resourceType: string; + parentId: string | null; + authorId: string; + /** Resolved from resolveUser(authorId). Falls back to "[deleted]" when user cannot be found. */ + resolvedAuthorName: string; + /** Resolved avatar URL or null */ + resolvedAvatarUrl: string | null; + body: string; + status: CommentStatus; + /** Denormalized counter — updated atomically on toggleLike */ + likes: number; + /** True when the currentUserId query param matches an existing commentLike row */ + isLikedByCurrentUser: boolean; + /** ISO string set when the comment body was edited; null for unedited comments */ + editedAt: string | null; + createdAt: string; + updatedAt: string; + /** + * Number of direct replies visible to the requesting user. + * Includes approved replies plus any pending replies authored by `currentUserId`. + * Always 0 for reply comments (non-null parentId). + */ + replyCount: number; +} + +/** + * Paginated list result for comments + */ +export interface CommentListResult { + items: SerializedComment[]; + total: number; + limit: number; + offset: number; +} diff --git a/packages/stack/src/plugins/kanban/client/components/forms/task-form.tsx b/packages/stack/src/plugins/kanban/client/components/forms/task-form.tsx index b4a7ba7e..4fac944a 100644 --- a/packages/stack/src/plugins/kanban/client/components/forms/task-form.tsx +++ b/packages/stack/src/plugins/kanban/client/components/forms/task-form.tsx @@ -226,7 +226,6 @@ export function TaskForm({ } output="markdown" placeholder="Describe the task..." - editable={!isPending} className="min-h-[150px]" /> diff --git a/packages/stack/src/plugins/kanban/client/components/pages/board-page.internal.tsx b/packages/stack/src/plugins/kanban/client/components/pages/board-page.internal.tsx index f5a0c180..26b25831 100644 --- a/packages/stack/src/plugins/kanban/client/components/pages/board-page.internal.tsx +++ b/packages/stack/src/plugins/kanban/client/components/pages/board-page.internal.tsx @@ -66,8 +66,11 @@ export function BoardPage({ boardId }: BoardPageProps) { throw error; } - const { Link: OverrideLink, navigate: overrideNavigate } = - usePluginOverrides("kanban"); + const { + Link: OverrideLink, + navigate: overrideNavigate, + taskDetailBottomSlot, + } = usePluginOverrides("kanban"); const navigate = overrideNavigate || ((path: string) => { @@ -540,33 +543,49 @@ export function BoardPage({ boardId }: BoardPageProps) { Update task details. {modalState.type === "editTask" && ( - c.id === modalState.columnId) - ?.tasks?.find((t) => t.id === modalState.taskId)} - columns={board.columns || []} - onClose={closeModal} - onSuccess={() => { - closeModal(); - refetch(); - }} - onDelete={async () => { - try { - await deleteTask(modalState.taskId); + <> + c.id === modalState.columnId) + ?.tasks?.find((t) => t.id === modalState.taskId)} + columns={board.columns || []} + onClose={closeModal} + onSuccess={() => { closeModal(); refetch(); - } catch (error) { - const message = - error instanceof Error - ? error.message - : "Failed to delete task"; - toast.error(message); - } - }} - /> + }} + onDelete={async () => { + try { + await deleteTask(modalState.taskId); + closeModal(); + refetch(); + } catch (error) { + const message = + error instanceof Error + ? error.message + : "Failed to delete task"; + toast.error(message); + } + }} + /> + {taskDetailBottomSlot && + (() => { + const task = board.columns + ?.find((c) => c.id === modalState.columnId) + ?.tasks?.find((t) => t.id === modalState.taskId); + return task ? ( + + {taskDetailBottomSlot(task)} + + ) : null; + })()} + > )} diff --git a/packages/stack/src/plugins/kanban/client/overrides.ts b/packages/stack/src/plugins/kanban/client/overrides.ts index 5f6d1200..9c90cc33 100644 --- a/packages/stack/src/plugins/kanban/client/overrides.ts +++ b/packages/stack/src/plugins/kanban/client/overrides.ts @@ -1,5 +1,6 @@ -import type { ComponentType } from "react"; +import type { ComponentType, ReactNode } from "react"; import type { KanbanLocalization } from "./localization"; +import type { SerializedTask } from "../types"; /** * User information for assignee display/selection @@ -142,4 +143,29 @@ export interface KanbanPluginOverrides { * @param context - Route context */ onBeforeNewBoardPageRendered?: (context: RouteContext) => boolean; + + // ============ Slot Overrides ============ + + /** + * Optional slot rendered at the bottom of the task detail dialog. + * Use this to inject a comment thread or any custom content without + * coupling the kanban plugin to the comments plugin. + * + * @example + * ```tsx + * kanban: { + * taskDetailBottomSlot: (task) => ( + * + * ), + * } + * ``` + */ + taskDetailBottomSlot?: (task: SerializedTask) => ReactNode; } diff --git a/packages/ui/src/components/pagination-controls.tsx b/packages/ui/src/components/pagination-controls.tsx new file mode 100644 index 00000000..1fa777f5 --- /dev/null +++ b/packages/ui/src/components/pagination-controls.tsx @@ -0,0 +1,80 @@ +"use client"; + +import { Button } from "@workspace/ui/components/button"; +import { ChevronLeft, ChevronRight } from "lucide-react"; + +export interface PaginationControlsProps { + /** Current page, 1-based */ + currentPage: number; + totalPages: number; + total: number; + limit: number; + offset: number; + onPageChange: (page: number) => void; + labels?: { + previous?: string; + next?: string; + /** Template string; use {from}, {to}, {total} as placeholders */ + showing?: string; + }; +} + +/** + * Generic Prev/Next pagination control with a "Showing X–Y of Z" label. + * Plugin-agnostic — pass localized labels as props. + * Returns null when totalPages ≤ 1. + */ +export function PaginationControls({ + currentPage, + totalPages, + total, + limit, + offset, + onPageChange, + labels, +}: PaginationControlsProps) { + const previous = labels?.previous ?? "Previous"; + const next = labels?.next ?? "Next"; + const showingTemplate = labels?.showing ?? "Showing {from}–{to} of {total}"; + + const from = offset + 1; + const to = Math.min(offset + limit, total); + + const showingText = showingTemplate + .replace("{from}", String(from)) + .replace("{to}", String(to)) + .replace("{total}", String(total)); + + if (totalPages <= 1) { + return null; + } + + return ( + + {showingText} + + onPageChange(currentPage - 1)} + disabled={currentPage === 1} + > + + {previous} + + + {currentPage} / {totalPages} + + onPageChange(currentPage + 1)} + disabled={currentPage === totalPages} + > + {next} + + + + + ); +} diff --git a/packages/ui/src/components/when-visible.tsx b/packages/ui/src/components/when-visible.tsx new file mode 100644 index 00000000..16777c8b --- /dev/null +++ b/packages/ui/src/components/when-visible.tsx @@ -0,0 +1,65 @@ +"use client"; + +import { useEffect, useRef, useState, type ReactNode } from "react"; + +export interface WhenVisibleProps { + /** Content to render once the element scrolls into view */ + children: ReactNode; + /** Optional placeholder rendered before the element enters the viewport */ + fallback?: ReactNode; + /** IntersectionObserver threshold (0–1). Defaults to 0 (any pixel visible). */ + threshold?: number; + /** Root margin passed to IntersectionObserver. Defaults to "200px" to preload slightly early. */ + rootMargin?: string; + /** Additional className applied to the sentinel wrapper div */ + className?: string; +} + +/** + * Lazy-mounts children only when the sentinel element scrolls into the viewport. + * Once mounted, children remain mounted even if the element scrolls out of view. + * + * Use this to defer expensive renders (comment threads, carousels, etc.) until + * the user actually scrolls to that section. + */ +export function WhenVisible({ + children, + fallback = null, + threshold = 0, + rootMargin = "200px", + className, +}: WhenVisibleProps) { + const [isVisible, setIsVisible] = useState(false); + const sentinelRef = useRef(null); + + useEffect(() => { + const el = sentinelRef.current; + if (!el) return; + + // If IntersectionObserver is not available (SSR/old browsers), show immediately + if (typeof IntersectionObserver === "undefined") { + setIsVisible(true); + return; + } + + const observer = new IntersectionObserver( + (entries) => { + const entry = entries[0]; + if (entry?.isIntersecting) { + setIsVisible(true); + observer.disconnect(); + } + }, + { threshold, rootMargin }, + ); + + observer.observe(el); + return () => observer.disconnect(); + }, [threshold, rootMargin]); + + return ( + + {isVisible ? children : fallback} + + ); +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 44f4c2ee..696a44bb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -169,19 +169,19 @@ importers: version: 2.0.94(react@19.2.0)(zod@4.2.1) '@btst/adapter-drizzle': specifier: ^2.1.0 - version: 2.1.0(2db6b3aa7c76b2a39655c7878ff38a0b) + version: 2.1.0(d36894e800afb4db2f9e5e0221688c82) '@btst/adapter-kysely': specifier: ^2.1.0 - version: 2.1.0(2db6b3aa7c76b2a39655c7878ff38a0b) + version: 2.1.0(d36894e800afb4db2f9e5e0221688c82) '@btst/adapter-memory': specifier: ^2.1.0 - version: 2.1.0(2db6b3aa7c76b2a39655c7878ff38a0b) + version: 2.1.0(d36894e800afb4db2f9e5e0221688c82) '@btst/adapter-mongodb': specifier: ^2.1.0 - version: 2.1.0(2db6b3aa7c76b2a39655c7878ff38a0b) + version: 2.1.0(d36894e800afb4db2f9e5e0221688c82) '@btst/adapter-prisma': specifier: ^2.1.0 - version: 2.1.0(2db6b3aa7c76b2a39655c7878ff38a0b) + version: 2.1.0(d36894e800afb4db2f9e5e0221688c82) '@btst/stack': specifier: workspace:* version: link:../../packages/stack @@ -237,8 +237,8 @@ importers: specifier: ^6.0.0 version: 6.21.0(socks@2.8.7) next: - specifier: 16.0.10 - version: 16.0.10(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + specifier: 16.1.6 + version: 16.1.6(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) next-themes: specifier: ^0.4.6 version: 0.4.6(react-dom@19.2.0(react@19.2.0))(react@19.2.0) @@ -587,9 +587,6 @@ importers: react-hook-form: specifier: '>=7.55.0' version: 7.66.1(react@19.2.0) - react-intersection-observer: - specifier: '>=9.0.0' - version: 10.0.0(react-dom@19.2.0(react@19.2.0))(react@19.2.0) react-markdown: specifier: '>=9.1.0' version: 9.1.0(@types/react@19.2.6)(react@19.2.0) @@ -4852,6 +4849,7 @@ packages: basic-ftp@5.0.5: resolution: {integrity: sha512-4Bcg1P8xhUuqcii/S0Z9wiHIrQVPMermM1any+MX5GeGD7faD3/msQUDGLol9wOcz4/jbg/WJnGqoJF6LiBdtg==} engines: {node: '>=10.0.0'} + deprecated: Security vulnerability fixed in 5.2.0, please upgrade better-auth@1.5.4: resolution: {integrity: sha512-ReykcEKx6Kp9560jG1wtlDBnftA7L7xb3ZZdDWm5yGXKKe2pUf+oBjH0fqekrkRII0m4XBVQbQ0mOrFv+3FdYg==} @@ -6280,11 +6278,12 @@ packages: glob@10.5.0: resolution: {integrity: sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==} + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me hasBin: true glob@7.2.3: resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} - deprecated: Glob versions prior to v9 are no longer supported + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me globals@13.24.0: resolution: {integrity: sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==} @@ -8301,15 +8300,6 @@ packages: peerDependencies: react: ^19.2.0 - react-intersection-observer@10.0.0: - resolution: {integrity: sha512-JJRgcnFQoVXmbE5+GXr1OS1NDD1gHk0HyfpLcRf0575IbJz+io8yzs4mWVlfaqOQq1FiVjLvuYAdEEcrrCfveg==} - peerDependencies: - react: ^19.2.0 - react-dom: ^17.0.0 || ^18.0.0 || ^19.0.0 - peerDependenciesMeta: - react-dom: - optional: true - react-is@16.13.1: resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} @@ -9597,6 +9587,7 @@ packages: whatwg-encoding@3.1.1: resolution: {integrity: sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==} engines: {node: '>=18'} + deprecated: Use @exodus/bytes instead for a more spec-conformant and faster implementation whatwg-mimetype@4.0.0: resolution: {integrity: sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==} @@ -10170,11 +10161,11 @@ snapshots: '@biomejs/cli-win32-x64@2.2.4': optional: true - '@btst/adapter-drizzle@2.1.0(2db6b3aa7c76b2a39655c7878ff38a0b)': + '@btst/adapter-drizzle@2.1.0(d36894e800afb4db2f9e5e0221688c82)': dependencies: '@better-auth/core': 1.5.4(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(better-call@2.0.1(zod@4.2.1))(jose@6.2.0)(kysely@0.28.8)(nanostores@1.1.1) - '@btst/db': 2.1.0(73a7114bf4fe13bb99896f432f871757) - better-auth: 1.5.4(67ecb4f4b8fb673bf1075802f9ddd254) + '@btst/db': 2.1.0(3581347f658bc9f101e33e2c3f774f40) + better-auth: 1.5.4(767c499e38114c4057ee7367645c5c65) drizzle-orm: 0.41.0(@electric-sql/pglite@0.3.15)(@opentelemetry/api@1.9.0)(@prisma/client@6.19.0(prisma@7.5.0(@types/react@19.2.6)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(typescript@5.9.3))(typescript@5.9.3))(bun-types@1.3.2(@types/react@19.2.6))(kysely@0.28.8)(mysql2@3.15.3)(postgres@3.4.7)(prisma@7.5.0(@types/react@19.2.6)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(typescript@5.9.3)) transitivePeerDependencies: - '@better-auth/utils' @@ -10203,11 +10194,11 @@ snapshots: - vitest - vue - '@btst/adapter-kysely@2.1.0(2db6b3aa7c76b2a39655c7878ff38a0b)': + '@btst/adapter-kysely@2.1.0(d36894e800afb4db2f9e5e0221688c82)': dependencies: '@better-auth/core': 1.5.4(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(better-call@2.0.1(zod@4.2.1))(jose@6.2.0)(kysely@0.28.8)(nanostores@1.1.1) - '@btst/db': 2.1.0(73a7114bf4fe13bb99896f432f871757) - better-auth: 1.5.4(67ecb4f4b8fb673bf1075802f9ddd254) + '@btst/db': 2.1.0(3581347f658bc9f101e33e2c3f774f40) + better-auth: 1.5.4(767c499e38114c4057ee7367645c5c65) kysely: 0.28.8 transitivePeerDependencies: - '@better-auth/utils' @@ -10236,11 +10227,11 @@ snapshots: - vitest - vue - '@btst/adapter-memory@2.1.0(2db6b3aa7c76b2a39655c7878ff38a0b)': + '@btst/adapter-memory@2.1.0(a5510a3f22769efb216707712fe268fb)': dependencies: - '@better-auth/core': 1.5.4(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(better-call@2.0.1(zod@4.2.1))(jose@6.2.0)(kysely@0.28.8)(nanostores@1.1.1) - '@btst/db': 2.1.0(73a7114bf4fe13bb99896f432f871757) - better-auth: 1.5.4(67ecb4f4b8fb673bf1075802f9ddd254) + '@better-auth/core': 1.5.4(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(better-call@1.3.2(zod@4.2.1))(jose@6.2.0)(kysely@0.28.11)(nanostores@1.1.1) + '@btst/db': 2.1.0(96474bc0743c1003c33dade349465747) + better-auth: 1.5.4(b7f95548c7a541548ed762b7d2b5e699) transitivePeerDependencies: - '@better-auth/utils' - '@better-fetch/fetch' @@ -10269,11 +10260,11 @@ snapshots: - vitest - vue - '@btst/adapter-memory@2.1.0(a5510a3f22769efb216707712fe268fb)': + '@btst/adapter-memory@2.1.0(bf56e8a25a6567ae0984dd8c13d80350)': dependencies: - '@better-auth/core': 1.5.4(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(better-call@1.3.2(zod@4.2.1))(jose@6.2.0)(kysely@0.28.11)(nanostores@1.1.1) - '@btst/db': 2.1.0(96474bc0743c1003c33dade349465747) - better-auth: 1.5.4(b7f95548c7a541548ed762b7d2b5e699) + '@better-auth/core': 1.5.4(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(better-call@2.0.1(zod@4.2.1))(jose@6.2.0)(kysely@0.28.11)(nanostores@1.1.1) + '@btst/db': 2.1.0(7888b9ecd667b8b1329619338f767b91) + better-auth: 1.5.4(1dc77348176d3c9bfb27d1ebc08f2d41) transitivePeerDependencies: - '@better-auth/utils' - '@better-fetch/fetch' @@ -10302,11 +10293,11 @@ snapshots: - vitest - vue - '@btst/adapter-memory@2.1.0(bf56e8a25a6567ae0984dd8c13d80350)': + '@btst/adapter-memory@2.1.0(c2198a24bea9a8ec2b3f81da5c5a813c)': dependencies: '@better-auth/core': 1.5.4(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(better-call@2.0.1(zod@4.2.1))(jose@6.2.0)(kysely@0.28.11)(nanostores@1.1.1) - '@btst/db': 2.1.0(7888b9ecd667b8b1329619338f767b91) - better-auth: 1.5.4(1dc77348176d3c9bfb27d1ebc08f2d41) + '@btst/db': 2.1.0(1e0eff0d8983e02d86024039dbc291a5) + better-auth: 1.5.4(130cbf1f38546bcec8ebe7ee6b767f41) transitivePeerDependencies: - '@better-auth/utils' - '@better-fetch/fetch' @@ -10335,11 +10326,11 @@ snapshots: - vitest - vue - '@btst/adapter-memory@2.1.0(c2198a24bea9a8ec2b3f81da5c5a813c)': + '@btst/adapter-memory@2.1.0(d36894e800afb4db2f9e5e0221688c82)': dependencies: - '@better-auth/core': 1.5.4(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(better-call@2.0.1(zod@4.2.1))(jose@6.2.0)(kysely@0.28.11)(nanostores@1.1.1) - '@btst/db': 2.1.0(1e0eff0d8983e02d86024039dbc291a5) - better-auth: 1.5.4(130cbf1f38546bcec8ebe7ee6b767f41) + '@better-auth/core': 1.5.4(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(better-call@2.0.1(zod@4.2.1))(jose@6.2.0)(kysely@0.28.8)(nanostores@1.1.1) + '@btst/db': 2.1.0(3581347f658bc9f101e33e2c3f774f40) + better-auth: 1.5.4(767c499e38114c4057ee7367645c5c65) transitivePeerDependencies: - '@better-auth/utils' - '@better-fetch/fetch' @@ -10368,11 +10359,11 @@ snapshots: - vitest - vue - '@btst/adapter-mongodb@2.1.0(2db6b3aa7c76b2a39655c7878ff38a0b)': + '@btst/adapter-mongodb@2.1.0(d36894e800afb4db2f9e5e0221688c82)': dependencies: '@better-auth/core': 1.5.4(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(better-call@2.0.1(zod@4.2.1))(jose@6.2.0)(kysely@0.28.8)(nanostores@1.1.1) - '@btst/db': 2.1.0(73a7114bf4fe13bb99896f432f871757) - better-auth: 1.5.4(67ecb4f4b8fb673bf1075802f9ddd254) + '@btst/db': 2.1.0(3581347f658bc9f101e33e2c3f774f40) + better-auth: 1.5.4(767c499e38114c4057ee7367645c5c65) mongodb: 6.21.0(socks@2.8.7) transitivePeerDependencies: - '@better-auth/utils' @@ -10401,12 +10392,12 @@ snapshots: - vitest - vue - '@btst/adapter-prisma@2.1.0(2db6b3aa7c76b2a39655c7878ff38a0b)': + '@btst/adapter-prisma@2.1.0(d36894e800afb4db2f9e5e0221688c82)': dependencies: '@better-auth/core': 1.5.4(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(better-call@2.0.1(zod@4.2.1))(jose@6.2.0)(kysely@0.28.8)(nanostores@1.1.1) - '@btst/db': 2.1.0(73a7114bf4fe13bb99896f432f871757) + '@btst/db': 2.1.0(3581347f658bc9f101e33e2c3f774f40) '@prisma/client': 6.19.0(prisma@7.5.0(@types/react@19.2.6)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(typescript@5.9.3))(typescript@5.9.3) - better-auth: 1.5.4(67ecb4f4b8fb673bf1075802f9ddd254) + better-auth: 1.5.4(767c499e38114c4057ee7367645c5c65) transitivePeerDependencies: - '@better-auth/utils' - '@better-fetch/fetch' @@ -10467,10 +10458,10 @@ snapshots: - vitest - vue - '@btst/db@2.1.0(73a7114bf4fe13bb99896f432f871757)': + '@btst/db@2.1.0(3581347f658bc9f101e33e2c3f774f40)': dependencies: '@better-auth/core': 1.5.4(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(better-call@2.0.1(zod@4.2.1))(jose@6.2.0)(kysely@0.28.8)(nanostores@1.1.1) - better-auth: 1.5.4(67ecb4f4b8fb673bf1075802f9ddd254) + better-auth: 1.5.4(767c499e38114c4057ee7367645c5c65) zod: 4.2.1 transitivePeerDependencies: - '@better-auth/utils' @@ -11865,8 +11856,7 @@ snapshots: '@next/env@16.0.10': {} - '@next/env@16.1.6': - optional: true + '@next/env@16.1.6': {} '@next/eslint-plugin-next@15.3.4': dependencies: @@ -14866,7 +14856,7 @@ snapshots: transitivePeerDependencies: - '@cloudflare/workers-types' - better-auth@1.5.4(67ecb4f4b8fb673bf1075802f9ddd254): + better-auth@1.5.4(767c499e38114c4057ee7367645c5c65): dependencies: '@better-auth/core': 1.5.4(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(better-call@1.3.2(zod@4.2.1))(jose@6.2.0)(kysely@0.28.11)(nanostores@1.1.1) '@better-auth/drizzle-adapter': 1.5.4(@better-auth/core@1.5.4(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(better-call@2.0.1(zod@4.2.1))(jose@6.2.0)(kysely@0.28.8)(nanostores@1.1.1))(@better-auth/utils@0.3.1)(drizzle-orm@0.41.0(@electric-sql/pglite@0.3.15)(@opentelemetry/api@1.9.0)(@prisma/client@6.19.0(prisma@7.5.0(@types/react@19.2.6)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(typescript@5.9.3))(typescript@5.9.3))(bun-types@1.3.2(@types/react@19.2.6))(kysely@0.28.8)(mysql2@3.15.3)(postgres@3.4.7)(prisma@7.5.0(@types/react@19.2.6)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(typescript@5.9.3))) @@ -14891,7 +14881,7 @@ snapshots: drizzle-orm: 0.41.0(@electric-sql/pglite@0.3.15)(@opentelemetry/api@1.9.0)(@prisma/client@6.19.0(prisma@7.5.0(@types/react@19.2.6)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(typescript@5.9.3))(typescript@5.9.3))(bun-types@1.3.2(@types/react@19.2.6))(kysely@0.28.8)(mysql2@3.15.3)(postgres@3.4.7)(prisma@7.5.0(@types/react@19.2.6)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(typescript@5.9.3)) mongodb: 6.21.0(socks@2.8.7) mysql2: 3.15.3 - next: 16.0.10(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + next: 16.1.6(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) prisma: 7.5.0(@types/react@19.2.6)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(typescript@5.9.3) react: 19.2.0 react-dom: 19.2.0(react@19.2.0) @@ -15884,7 +15874,7 @@ snapshots: '@typescript-eslint/parser': 8.47.0(eslint@8.57.1)(typescript@5.9.3) eslint: 8.57.1 eslint-import-resolver-node: 0.3.9 - eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@8.57.1) + eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.47.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1))(eslint@8.57.1) eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.47.0(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.1) eslint-plugin-jsx-a11y: 6.10.2(eslint@8.57.1) eslint-plugin-react: 7.37.5(eslint@8.57.1) @@ -15924,7 +15914,7 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0)(eslint@8.57.1): + eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.47.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1))(eslint@8.57.1): dependencies: '@nolyfill/is-core-module': 1.0.39 debug: 4.4.3 @@ -15954,14 +15944,14 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-module-utils@2.12.1(@typescript-eslint/parser@8.47.0(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.1): + eslint-module-utils@2.12.1(@typescript-eslint/parser@8.47.0(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.47.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1): dependencies: debug: 3.2.7 optionalDependencies: '@typescript-eslint/parser': 8.47.0(eslint@8.57.1)(typescript@5.9.3) eslint: 8.57.1 eslint-import-resolver-node: 0.3.9 - eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@8.57.1) + eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.47.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1))(eslint@8.57.1) transitivePeerDependencies: - supports-color @@ -15987,7 +15977,7 @@ snapshots: doctrine: 2.1.0 eslint: 8.57.1 eslint-import-resolver-node: 0.3.9 - eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.47.0(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.1) + eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.47.0(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.47.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1) hasown: 2.0.2 is-core-module: 2.16.1 is-glob: 4.0.3 @@ -18333,7 +18323,6 @@ snapshots: transitivePeerDependencies: - '@babel/core' - babel-plugin-macros - optional: true nf3@0.1.12: {} @@ -19305,12 +19294,6 @@ snapshots: dependencies: react: 19.2.0 - react-intersection-observer@10.0.0(react-dom@19.2.0(react@19.2.0))(react@19.2.0): - dependencies: - react: 19.2.0 - optionalDependencies: - react-dom: 19.2.0(react@19.2.0) - react-is@16.13.1: {} react-markdown@9.1.0(@types/react@19.2.6)(react@19.2.0): diff --git a/turbo.json b/turbo.json index 42c4cc69..4777bb74 100644 --- a/turbo.json +++ b/turbo.json @@ -35,6 +35,24 @@ "cache": false, "env": ["OPENAI_API_KEY"] }, + "e2e:smoke:nextjs": { + "dependsOn": ["^build"], + "outputs": [], + "cache": false, + "env": ["OPENAI_API_KEY", "BTST_FRAMEWORK"] + }, + "e2e:smoke:tanstack": { + "dependsOn": ["^build"], + "outputs": [], + "cache": false, + "env": ["OPENAI_API_KEY", "BTST_FRAMEWORK"] + }, + "e2e:smoke:react-router": { + "dependsOn": ["^build"], + "outputs": [], + "cache": false, + "env": ["OPENAI_API_KEY", "BTST_FRAMEWORK"] + }, "e2e:integration": { "dependsOn": ["^build"], "outputs": [],
\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} +