diff --git a/.claude/skills/vitnode-docs/SKILL.md b/.claude/skills/vitnode-docs/SKILL.md index 2f4c6569a9..fe2e002558 100644 --- a/.claude/skills/vitnode-docs/SKILL.md +++ b/.claude/skills/vitnode-docs/SKILL.md @@ -1,7 +1,6 @@ --- name: vitnode-docs -description: > - Write, update, and review VitNode MDX documentation whenever implementing or documenting VitNode features, APIs, plugins, or AdminCP workflows. Use for tutorials, how-to guides, reference pages, explanations, documentation cleanup, and real UI screenshots. Require concise, focused pages, splitting broad topics into linked MDX files when needed, plain language, working step-by-step examples, supported Fumadocs components, and screenshots rendered through apps/web/src/components/fumadocs/img.tsx. +description: Write, update, and review VitNode MDX documentation whenever implementing or documenting VitNode features, APIs, plugins, or AdminCP workflows. Use for beginner learning paths, navigation, tutorials, feature recipes, reference pages, documentation cleanup, and real UI screenshots. Require short task-based titles, consistent runnable examples, verified prerequisites, focused pages, plain language, SEO and AI-readable content, supported Fumadocs components, and screenshots rendered through apps/web/src/components/fumadocs/img.tsx. --- # VitNode Docs @@ -17,18 +16,29 @@ Write documentation that helps the reader complete one concrete task. Explain wh - Check available Fumadocs components and the installed version. Prefer existing project wrappers and conventions. - Verify API names, imports, paths, defaults, permissions, and supported behavior against current source code. Treat source code as authoritative when existing docs disagree. - Document implemented behavior. Label experimental behavior when supported by the project; do not present plans as available features. +- For follow-up reviews, resolve the requested branch's current commit and compare it with the last reviewed commit. State when no new changes exist. Review the actual diff and affected dependencies; do not infer completion from a commit message or invent new scope to prolong an iteration. ## 2. Choose the page type Choose one primary page type and one reader outcome. Plan the page boundaries before drafting; do not write an exhaustive page and shorten it only afterward. -| Type | Purpose | Structure | -| ----------- | ----------------------------------------- | --------------------------------------------------------- | -| Overview | Choose a guide within a broad topic | Purpose → choices → focused guides | -| Tutorial | Learn by building a small working example | Goal → prerequisites → steps → working result | -| How-to | Complete a specific task | Outcome → requirements → steps → verify | -| Reference | Look up exact behavior | Purpose → signature → options/defaults → example → errors | -| Explanation | Understand a design or tradeoff | Problem → concrete example → how it works → tradeoffs | +| Type | Purpose | Structure | +| --- | --- | --- | +| Overview | Choose a guide within a broad topic | Purpose → choices → focused guides | +| Tutorial | Learn by building a small working example | Goal → prerequisites → steps → working result | +| How-to | Complete a specific task | Outcome → requirements → steps → verify | +| Reference | Look up exact behavior | Purpose → signature → options/defaults → example → errors | +| Explanation | Understand a design or tradeoff | Problem → concrete example → how it works → tradeoffs | + +### Design the learning path and navigation + +- Keep sidebar titles short, usually one to three words, and distinguish outcomes: `Content list`, `Content page`, `Create form`, `Reports`. Put the fuller learning goal in the description and opening. Avoid overlapping destinations such as `Public frontend` and `Public pages` without a clear difference; put task-to-helper maps in an overview. +- Keep the beginner sequence uninterrupted. For Content Engine, teach first content type → drafts and publishing → public API → content list → content page. Place embedded lists and optional extensions after the complete baseline; keep API reference separate. +- Continue one minimal example across consecutive lessons. Reuse the same plugin, type, fields, paths, query keys and demo records. Prefer the existing newsroom article baseline when present; do not force this fixture into unrelated documentation. +- Build only the capability needed for the current lesson. Introduce basic list rendering before filters, and basic editing with required authorization and version handling before optional approval proposals. Separate comments, comment editing and nested replies when each is an independently useful task. +- Make feature recipes accessible from their real minimum prerequisites. Reporting needs public content and a page, not member article forms. Comments own their author and write configuration; staff-written articles do not need public writing just to receive comments. +- Explain generated backend behavior, core frontend helpers, and plugin-owned UI separately. Show the whole path to an observable outcome: for reporting, include configuration, viewer permissions, the button, submission, ModCP and opening the reported content. +- Preserve existing URLs when shortening titles. Update navigation and related links together. Do not add placeholder pages for unimplemented reactions, following or other planned capabilities. ### Use the VitNode documentation schema @@ -39,17 +49,17 @@ Follow this self-contained schema. Do not mention external documentation brands 1. **Frontmatter:** provide a short, searchable `title` and a one-sentence `description`. Follow the local schema for optional fields. Let the renderer display the title; do not duplicate it as an H1 unless the project requires that. 2. **Opening:** write one or two sentences stating the outcome and when to use it. Do not add a general essay about the topic. 3. **Main content:** use the structure for the chosen page type below. Keep headings descriptive and paragraphs short. -4. **Related link:** add up to three specific links only when they offer a useful next action. Omit a generic conclusion or recap. +4. **Related link:** add up to three specific links under `## Related` only when they offer a useful next action. Never end a page with a "Next step", "Next" or "What's next" section; the sidebar and the page footer already link the following page. Omit a generic conclusion or recap. Use these structures in the stated order. Omit conditional sections when they add no value; do not fill empty sections just to satisfy a template. -| Page type | Main content schema | -| ----------- | ---------------------------------------------------------------------------------------------------------------------- | -| Overview | Brief purpose → decision table or short list of choices → links to focused guides | -| Tutorial | Before you begin (if needed) → Build the example (ordered steps) → Check the result | -| How-to | Before you begin (if needed) → task-specific steps → Check the result | -| Reference | Signature or API shape → parameters/options table → return value → minimal usage example → relevant errors/constraints | -| Explanation | Concrete problem → how it works → practical consequences/tradeoffs → related implementation guide | +| Page type | Main content schema | +| --- | --- | +| Overview | Brief purpose → decision table or short list of choices → links to focused guides | +| Tutorial | Before you begin (if needed) → Build the example (ordered steps) → Check the result | +| How-to | Before you begin (if needed) → task-specific steps → Check the result | +| Reference | Signature or API shape → parameters/options table → return value → minimal usage example → relevant errors/constraints | +| Explanation | Concrete problem → how it works → practical consequences/tradeoffs → related implementation guide | #### Step schema @@ -65,7 +75,7 @@ Keep the final verification in “Check the result” instead of repeating verif #### Code and reference schema - Give every code block a language and, for file edits, a supported file title. -- Keep title short. +- Keep code-block titles concise while retaining enough of the file path to identify the file; state create/edit placement in the step text. - Show a complete minimal file when creating one; show a labeled excerpt when changing an existing file. State where an excerpt belongs and link to required setup. - Exclude unrelated styling, route metadata, schemas, and error handling from focused excerpts. Preserve code required to reproduce the task safely and correctly. - Explain a new concept once, next to its first use. Do not narrate obvious code line by line. @@ -88,11 +98,11 @@ Keep the final verification in “Check the result” instead of repeating verif Example split for a page covering both app and API caching (adapt names and paths to the repository): -| Page | Keep on this page | -| ------------------- | ------------------------------------------------------------------------------- | -| Cache overview | Which layer to choose; links to the guides | -| Cache app data | Query options → loader/component integration → invalidation → verify | -| Cache API data | Redis prerequisite link → remember → delete after a write → verify | +| Page | Keep on this page | +| --- | --- | +| Cache overview | Which layer to choose; links to the guides | +| Cache app data | Query options → loader/component integration → invalidation → verify | +| Cache API data | Redis prerequisite link → remember → delete after a write → verify | | Cache API reference | Method signatures, defaults, return values, serialization and fallback behavior | Keep important user-isolation and invalidation requirements beside the relevant example. Move unrelated Content Engine behavior to its existing guide rather than expanding the cache overview. @@ -111,7 +121,7 @@ Keep important user-isolation and invalidation requirements beside the relevant - Say each fact once. Do not repeat the same point in the introduction, step text, callout, table, and conclusion. - Default to one short paragraph of instruction per step and, only if needed, one short explanation after the code. Do not narrate obvious code line by line. - Remove sentences that do not help the reader choose, act, understand a non-obvious constraint, or verify the result. Link to deeper explanations instead of adding them to the main path. -- Keep prerequisites to missing requirements, not a general setup tutorial. Prefer one relevant next-step link over a generic closing section. +- Keep prerequisites to missing requirements, not a general setup tutorial. Prefer one relevant related link over a generic closing section. ### Edit for a natural technical voice @@ -135,7 +145,7 @@ Keep important user-isolation and invalidation requirements beside the relevant - Keep important explanations and constraints in selectable text. Screenshots supplement instructions; they must not be the only source of UI labels or outcomes. - Support technical claims with implementation evidence, reproducible examples, or a relevant primary-source link. Add benchmark numbers, expert quotes, author attribution, or dates only when real and useful; do not add them as citation bait. - Keep HTML and Markdown exports equivalent in meaning, including all labeled tab alternatives. Do not add hidden bot-only text, prompt instructions for agents, fabricated FAQs, unsupported superlatives, or repetitive summaries to attract citations. -- For a how-to or tutorial that ends in working code in a reader's plugin or app, consider the optional `implementation` frontmatter behind **Copy implementation prompt**. Follow "Implementation prompts" in `apps/web/content/docs/dev/contribution.mdx`; it is the only place for coding-agent instructions. +- For a how-to or tutorial that ends in working code in a reader's plugin or app, consider the optional `implementation` frontmatter behind **Copy implementation prompt**. Read "Implementation prompts" in `apps/web/content/docs/dev/contribution.mdx` and follow its current schema; keep coding-agent instructions there rather than in reader-facing prose. Ask only decisions needed for that task, and keep constraints aligned with the verified APIs and prerequisites. - Use a short question-and-answer section only for distinct recurring questions that are not already answered. Do not append a generic FAQ to every page. - For a requested SEO/AI-readiness audit or visibility review, read `references/seo-review.md`. Do not run a broad crawl, install audit tools, or require competitor research for every MDX edit. - Read `references/docs-site.md` for canonical metadata, crawlability, sitemaps, structured data, and AI discovery when site-level work is in scope. Clear content and accessible delivery improve usability; do not promise rankings, indexing, or AI citations. @@ -152,9 +162,27 @@ For tutorials and how-to guides: 6. State the observable result of the step when useful. 7. End with a command, route, or UI action that verifies the complete result. 8. Add troubleshooting only for realistic errors supported by the implementation. +9. Call the VitNode API with `fetcher` from `@vitnode/core/tanstack/fetcher` (`rawFetcher` only for generated Content Engine admin routes), never raw `fetch` or a browser-console snippet. When a check needs a request with no UI yet, give a small temporary component with a button that calls `fetcher`, say where to render it, and tell the reader to remove it afterwards. Keep the complete workflow reproducible; not every code block needs to recreate the application. For a new file, include the imports and setup required to run it. For a targeted change, show a clearly labeled excerpt with its file path and placement, and link to the existing setup or complete example. Do not hide task-critical code behind `...`, assume undefined variables without explaining their source, or reproduce unrelated schemas, route metadata, styling, and error handling just to make an excerpt look standalone. Show a full baseline once, then show only the changes. Introduce dependencies before using them. Match repository package-manager conventions; for reader-facing commands, provide equivalent pnpm/npm/bun variants when supported. Do not invent script names or package-manager equivalence. +### Verify prerequisites and example continuity + +- Treat prerequisites as a contract. Every required step and final check must work from exactly that starting state. Mark checks needing publication, a public API, containers or another extension as optional, with their additional setup. An AdminCP-only relations guide must not require a public endpoint to finish. +- Create every verification record in this guide or a named prerequisite. Do not assume a seeded record, another tutorial's member post, board or account exists without saying how to obtain it. +- Introduce every local import, helper, type, variable, dependency and translation namespace before use, or link directly to its setup. Do not describe a simplified form as runnable against a richer content type with additional required fields. +- Show required build commands before migrations that read compiled plugins. Label edits as additions or replacements and preserve earlier configuration, query warm-ups and registered types when extending an existing file. +- Use focused excerpts for instruction and, when useful, a complete file in a supported accordion or direct source link as optional assembly reference. Keep required instructions visible. Do not repeat the complete file after every step or hide essential setup in an accordion. +- Match code, request payloads, expected responses and screenshots to the same configuration and stage of the tutorial. Review downstream prerequisites when changing an earlier tutorial's example. + +### Document server state truthfully + +- Verify publication and moderation states independently. A plain draft can have `approval: null`; enabling draft creation does not itself submit content to moderators. Show the container policy and inherited relation configuration when approval depends on them. +- Check approval and publication state before choosing a success message or redirect. Only claim a create is public when it is published; send drafts and pending creates to an available author-facing destination. A pending edit leaves the earlier published values visible. +- Derive approval notices from the server's `approval.status` and `approval.kind`, and open pending edits on proposed values when supported. Never infer moderator review from a draft status or a URL flag such as `submitted`. +- Keep required correctness in the basic example: authorization, field allowlists, ownership, relevant error states, cache invalidation and form defaults. State whether concurrent writes use versions or last-write-wins; do not promise conflict protection without enabling it. +- Report a demonstrated implementation/docs mismatch concretely. For a docs-only task, label any intentionally corrected example and track the implementation issue separately; do not silently expand scope into a product feature. Reconcile both descriptions when an authorized implementation fix lands. + For reference pages, include exact types, required fields, defaults, return values, and relevant errors. Avoid forcing a step sequence onto a lookup page. Link to a tutorial instead. ## 5. Use Fumadocs components deliberately @@ -164,7 +192,7 @@ Inspect the local MDX registry and existing pages before choosing imports or syn - Use `Steps` / `Step` for ordered workflows, following local heading conventions. - Use `Tabs` / `Tab` for equivalent alternatives, such as supported package managers. Keep required steps outside tabs. - Use `Callout` only for an easily missed prerequisite, limitation, or behavior that changes the reader's next action. Keep ordinary explanations in prose. Aim for at most two callouts on a short guide; retain more only when necessary for correctness. -- Use `Cards` / `Card` for related guides or next steps when locally supported. +- Use `Cards` / `Card` for related guides when locally supported. - Use supported file-tree components when a directory structure helps the reader place files. - Use supported type tables or Markdown tables for options, types, defaults, and required fields. - Use code-fence language labels, file titles, and focused line highlighting in the syntax supported by this project. @@ -181,6 +209,7 @@ Use components to clarify content. Do not wrap every paragraph in one. - Preserve essential instructions in text and rendered HTML. Do not hide required content behind hover, animation, or an interaction that prevents direct reading or copying. - When editing docs-site infrastructure or reviewing missing copy/export capabilities, read `references/docs-site.md`. Routine MDX writing should use existing infrastructure and report missing capabilities; do not expand it into a site rebuild. + ## 6. Capture real screenshots for UI workflows Capture screenshots when documenting AdminCP, forms, settings, editors, or other visual workflows where an image helps identify controls or verify a result. Skip images that add nothing to an API or code-only page. @@ -194,9 +223,9 @@ Capture screenshots when documenting AdminCP, forms, settings, editors, or other 7. Inspect each saved image. Retake it if labels are unreadable, content is clipped, or the page shows loading/error states unrelated to the guide. 8. Avoid exposing personal data, tokens, cookies, private URLs, or real user records. Prefer preparing clean demo data before capture. 9. Save assets in the repository's existing documentation image location. Use descriptive kebab-case names and stable repository paths. Save every new or re-captured screenshot as WebP; keep text sharp and file sizes reasonable. - - Capture at a 2x device scale factor. If the tool only writes PNG, capture to PNG, then convert it to WebP and delete the intermediate PNG. Never commit PNG screenshots. - - Convert with high quality to keep UI text sharp, for example Pillow lossy `quality=90` with `method=6`. Use lossless WebP when small text blurs. Inspect the WebP after conversion. - - When replacing an existing PNG screenshot, change the MDX import from `./name.png` to `./name.webp` and delete the old PNG. Leave no reference to the removed file. + - Capture at a 2x device scale factor. If the tool only writes PNG, capture to PNG, convert to WebP and delete the intermediate PNG. Never commit PNG screenshots. + - Convert at high quality, for example Pillow lossy `quality=90` with `method=6`. Use lossless WebP when small text blurs. Inspect the converted WebP. + - When replacing an existing PNG screenshot, update its MDX import to `.webp` and delete the old PNG. Leave no reference to the removed file. 10. Place the image beside the step it illustrates. Add useful alt text and a short caption when the state needs explanation. Keep the action and result in text as well. Never generate, draw, or fabricate an application screenshot. Do not substitute a mockup or claim a screenshot was captured when it was not. If the app, credentials, or capture tools are unavailable, finish the text and report the missing capture and its precise prerequisite. Do not commit broken image references or placeholders as completed screenshots. @@ -214,20 +243,25 @@ Use `apps/web/src/components/fumadocs/img.tsx` for every documentation screensho ## 7. Verify and finish -- Walk through the guide in order using its stated prerequisites. Verify commands and code against the implementation; run examples when the environment supports them. +- Walk through the guide in order using only its stated prerequisites. For independent recipes, skip unrelated lessons deliberately, such as Content page → Reports, Content page → Comments, or First content type → Relations. Prefer a fresh plugin/database state where practical; do not let a fully configured demo conceal missing setup. +- Verify commands and code against the implementation; run examples when the environment supports them. Check applicable published, plain draft, pending create, pending edit, rejected, unauthorized and missing states. Verify reloads, saved defaults and invalidation when documenting writes. - Run the relevant existing MDX, formatting, type, link, or docs-build checks. Inspect package scripts first and avoid unrelated test suites. - Preview the changed page. Check headings, table of contents, steps, tabs, code blocks, images, and internal links. Check narrow-screen readability for changed visual content. - Verify screenshot assets exist and each referenced source resolves. Update navigation metadata and related links when adding or moving pages. - Run a brevity pass: remove repetition and unrelated boilerplate, check the page has one outcome, and split independent workflows or reference material when needed. Preserve required setup and correctness-critical detail; move useful optional detail to a linked page instead of deleting it. -- Report changed pages, captured screenshots, verification performed, and any unresolved limitation. Distinguish source inspection from checks actually executed. +- Report changed pages, captured screenshots, verification performed, and any unresolved limitation. Distinguish source inspection, inspected CI results, locally executed checks and browser walkthroughs; a green build does not prove a tutorial was followed successfully. ## Completion checklist - [ ] Explain one concrete outcome in simple language without repeated prose. - [ ] Split independent workflows/reference material into linked MDX pages when needed; preserve URLs and navigation. +- [ ] Keep short, distinct sidebar titles and one consistent beginner example; make optional recipes independently reachable. +- [ ] Complete required steps and checks from only the declared prerequisites, with every helper and demo record accounted for. +- [ ] Match server publication/approval states in messages, notices, form defaults and navigation. - [ ] Match current VitNode code, names, permissions, and UI labels. - [ ] Use a clear title/description, direct answer, descriptive links, and sections understandable on their own without keyword stuffing. - [ ] Provide complete examples with file paths and expected results. +- [ ] Call the VitNode API with `fetcher` or `rawFetcher` in every example and check, never raw `fetch`. - [ ] Use supported Fumadocs components and valid MDX syntax; check code copying and existing Markdown exports when relevant. - [ ] Capture useful real UI screenshots and render them through `img.tsx`. - [ ] Verify navigation, links, assets, and the rendered page. diff --git a/.claude/skills/vitnode-docs/references/docs-site.md b/.claude/skills/vitnode-docs/references/docs-site.md index c8a11c5ab8..65efb74818 100644 --- a/.claude/skills/vitnode-docs/references/docs-site.md +++ b/.claude/skills/vitnode-docs/references/docs-site.md @@ -40,7 +40,6 @@ Apply these checks to public documentation. Preserve access controls on private - Confirm changed pages through direct HTML/Markdown requests, metadata inspection, link checks, and the existing build. Use available crawl diagnostics when authorized. A successful check does not guarantee retrieval or citation by any service. Authoritative references for verification, not text to copy into reader-facing docs: - - https://developers.google.com/search/docs/fundamentals/seo-starter-guide - https://developers.google.com/search/docs/appearance/ai-features - https://developers.openai.com/api/docs/bots diff --git a/AGENTS.md b/AGENTS.md index cc66266b10..6a54ca0564 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -92,6 +92,8 @@ Pick one indicator per piece of pending work, by what is pending: - Document every new feature. Keep it simple, SEO-friendly, and understandable at any skill level. - Friendly and lightly funny tone - don't overdo it. - Skip big comments; code is self-documenting. +- Docs call the VitNode API with `fetcher` (`rawFetcher` for generated Content Engine admin routes), never raw `fetch` or a browser-console snippet. A check that needs a request with no UI yet uses a small temporary component with a button. +- Never end a page with a "Next step" (or "Next", "What's next") section; the sidebar and page footer link the following page. Use `## Related` for links worth following. - Developer-facing APIs in feature code (exports of `@vitnode/core` and plugins that app or plugin authors call) need concise English JSDoc on the original declaration: - Say what it is for, plus constraints, side effects, runtime (server-only, browser-only or universal) and `@throws` only when they are real and non-obvious. - Document configuration properties one by one, including non-obvious defaults and constraints. diff --git a/apps/api/migrations/20261024173113_replace_example_with_newsroom/migration.sql b/apps/api/migrations/20261024173113_replace_example_with_newsroom/migration.sql new file mode 100644 index 0000000000..7cfd6b1a97 --- /dev/null +++ b/apps/api/migrations/20261024173113_replace_example_with_newsroom/migration.sql @@ -0,0 +1,464 @@ +CREATE TABLE "newsroom_articles" ( + "id" serial PRIMARY KEY, + "createdAt" timestamp DEFAULT now() NOT NULL, + "updatedAt" timestamp DEFAULT now() NOT NULL, + "publishedAt" timestamp, + "status" varchar(32) DEFAULT 'draft' NOT NULL, + "version" integer DEFAULT 1 NOT NULL, + "title" varchar(200) NOT NULL, + "slug" varchar(160) NOT NULL, + "body" jsonb NOT NULL, + "author" integer, + "category" integer +); +--> statement-breakpoint +ALTER TABLE "newsroom_articles" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint +CREATE TABLE "newsroom_bulletins" ( + "id" serial PRIMARY KEY, + "createdAt" timestamp DEFAULT now() NOT NULL, + "updatedAt" timestamp DEFAULT now() NOT NULL, + "publishedAt" timestamp, + "status" varchar(32) DEFAULT 'draft' NOT NULL, + "version" integer DEFAULT 1 NOT NULL, + "featured" boolean DEFAULT false NOT NULL +); +--> statement-breakpoint +ALTER TABLE "newsroom_bulletins" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint +CREATE TABLE "newsroom_bulletins_translations" ( + "itemId" integer, + "languageId" integer, + "version" integer DEFAULT 1 NOT NULL, + "createdAt" timestamp DEFAULT now() NOT NULL, + "updatedAt" timestamp DEFAULT now() NOT NULL, + "publishedAt" timestamp, + "status" varchar(32) DEFAULT 'draft' NOT NULL, + "title" varchar(200) NOT NULL, + "slug" varchar(160) NOT NULL, + "body" text NOT NULL, + CONSTRAINT "newsroom_bulletins_translations_item_id_language_id_pk" PRIMARY KEY("itemId","languageId") +); +--> statement-breakpoint +ALTER TABLE "newsroom_bulletins_translations" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint +CREATE TABLE "newsroom_categories" ( + "id" serial PRIMARY KEY, + "createdAt" timestamp DEFAULT now() NOT NULL, + "updatedAt" timestamp DEFAULT now() NOT NULL, + "name" varchar(80) NOT NULL, + "slug" varchar(160) NOT NULL +); +--> statement-breakpoint +ALTER TABLE "newsroom_categories" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint +CREATE TABLE "newsroom_comments" ( + "id" serial PRIMARY KEY, + "createdAt" timestamp DEFAULT now() NOT NULL, + "updatedAt" timestamp DEFAULT now() NOT NULL, + "publishedAt" timestamp, + "status" varchar(32) DEFAULT 'draft' NOT NULL, + "hiddenAt" timestamp, + "hiddenBy" integer, + "slug" varchar(160) NOT NULL, + "body" jsonb NOT NULL, + "author" integer, + "article" integer NOT NULL, + "parent" integer +); +--> statement-breakpoint +ALTER TABLE "newsroom_comments" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint +CREATE TABLE "newsroom_events" ( + "id" bigint PRIMARY KEY GENERATED BY DEFAULT AS IDENTITY (sequence name "newsroom_events_id_seq" INCREMENT BY 1 MINVALUE 1 MAXVALUE 9223372036854775807 START WITH 1 CACHE 1), + "createdAt" timestamp DEFAULT now() NOT NULL, + "updatedAt" timestamp DEFAULT now() NOT NULL, + "publishedAt" timestamp, + "status" varchar(32) DEFAULT 'draft' NOT NULL, + "version" integer DEFAULT 1 NOT NULL, + "hiddenAt" timestamp, + "hiddenBy" integer, + "primaryTag" uuid, + "category" integer +); +--> statement-breakpoint +ALTER TABLE "newsroom_events" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint +CREATE TABLE "newsroom_events_gallery" ( + "itemId" bigint, + "relatedItemId" integer, + "position" integer NOT NULL, + "createdAt" timestamp DEFAULT now() NOT NULL, + CONSTRAINT "newsroom_events_gallery_pk" PRIMARY KEY("itemId","relatedItemId") +); +--> statement-breakpoint +ALTER TABLE "newsroom_events_gallery" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint +CREATE TABLE "newsroom_events_related_events" ( + "itemId" bigint, + "relatedItemId" bigint, + "position" integer NOT NULL, + "createdAt" timestamp DEFAULT now() NOT NULL, + CONSTRAINT "newsroom_events_related_events_pk" PRIMARY KEY("itemId","relatedItemId") +); +--> statement-breakpoint +ALTER TABLE "newsroom_events_related_events" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint +CREATE TABLE "newsroom_events_sessions" ( + "id" serial PRIMARY KEY, + "itemId" bigint NOT NULL, + "position" integer NOT NULL, + "createdAt" timestamp DEFAULT now() NOT NULL, + "updatedAt" timestamp DEFAULT now() NOT NULL, + "title" varchar(200) NOT NULL, + "startsAt" timestamp +); +--> statement-breakpoint +ALTER TABLE "newsroom_events_sessions" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint +CREATE TABLE "newsroom_events_tags" ( + "itemId" bigint, + "relatedItemId" uuid, + "position" integer NOT NULL, + "createdAt" timestamp DEFAULT now() NOT NULL, + CONSTRAINT "newsroom_events_tags_pk" PRIMARY KEY("itemId","relatedItemId") +); +--> statement-breakpoint +ALTER TABLE "newsroom_events_tags" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint +CREATE TABLE "newsroom_events_translations" ( + "itemId" bigint, + "languageId" integer, + "version" integer DEFAULT 1 NOT NULL, + "createdAt" timestamp DEFAULT now() NOT NULL, + "updatedAt" timestamp DEFAULT now() NOT NULL, + "publishedAt" timestamp, + "status" varchar(32) DEFAULT 'draft' NOT NULL, + "title" varchar(200) NOT NULL, + "slug" varchar(160) NOT NULL, + "body" jsonb, + CONSTRAINT "newsroom_events_translations_item_id_language_id_pk" PRIMARY KEY("itemId","languageId") +); +--> statement-breakpoint +ALTER TABLE "newsroom_events_translations" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint +CREATE TABLE "newsroom_explainers" ( + "id" serial PRIMARY KEY, + "createdAt" timestamp DEFAULT now() NOT NULL, + "updatedAt" timestamp DEFAULT now() NOT NULL, + "publishedAt" timestamp, + "status" varchar(32) DEFAULT 'draft' NOT NULL, + "version" integer DEFAULT 1 NOT NULL, + "syndicationIndexable" boolean DEFAULT true NOT NULL, + "syndicationNoIndex" boolean DEFAULT false NOT NULL, + "syndicationPriority" integer DEFAULT 5 NOT NULL +); +--> statement-breakpoint +ALTER TABLE "newsroom_explainers" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint +CREATE TABLE "newsroom_explainers_categories" ( + "itemId" integer, + "relatedItemId" integer, + "position" integer NOT NULL, + "createdAt" timestamp DEFAULT now() NOT NULL, + CONSTRAINT "newsroom_explainers_categories_pk" PRIMARY KEY("itemId","relatedItemId") +); +--> statement-breakpoint +ALTER TABLE "newsroom_explainers_categories" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint +CREATE TABLE "newsroom_explainers_faq" ( + "id" serial PRIMARY KEY, + "itemId" integer NOT NULL, + "position" integer NOT NULL, + "createdAt" timestamp DEFAULT now() NOT NULL, + "updatedAt" timestamp DEFAULT now() NOT NULL, + "question" varchar(200) NOT NULL, + "answer" text NOT NULL +); +--> statement-breakpoint +ALTER TABLE "newsroom_explainers_faq" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint +CREATE TABLE "newsroom_explainers_related_explainers" ( + "itemId" integer, + "relatedItemId" integer, + "position" integer NOT NULL, + "createdAt" timestamp DEFAULT now() NOT NULL, + CONSTRAINT "newsroom_explainers_related_explainers_pk" PRIMARY KEY("itemId","relatedItemId") +); +--> statement-breakpoint +ALTER TABLE "newsroom_explainers_related_explainers" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint +CREATE TABLE "newsroom_explainers_tags" ( + "itemId" integer, + "relatedItemId" uuid, + "position" integer NOT NULL, + "createdAt" timestamp DEFAULT now() NOT NULL, + CONSTRAINT "newsroom_explainers_tags_pk" PRIMARY KEY("itemId","relatedItemId") +); +--> statement-breakpoint +ALTER TABLE "newsroom_explainers_tags" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint +CREATE TABLE "newsroom_explainers_translations" ( + "itemId" integer, + "languageId" integer, + "version" integer DEFAULT 1 NOT NULL, + "createdAt" timestamp DEFAULT now() NOT NULL, + "updatedAt" timestamp DEFAULT now() NOT NULL, + "publishedAt" timestamp, + "status" varchar(32) DEFAULT 'draft' NOT NULL, + "title" varchar(200) NOT NULL, + "slug" varchar(160) NOT NULL, + "seoTitle" varchar(200), + "seoDescription" text, + CONSTRAINT "newsroom_explainers_translations_item_id_language_id_pk" PRIMARY KEY("itemId","languageId") +); +--> statement-breakpoint +ALTER TABLE "newsroom_explainers_translations" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint +CREATE TABLE "newsroom_letters" ( + "id" serial PRIMARY KEY, + "createdAt" timestamp DEFAULT now() NOT NULL, + "updatedAt" timestamp DEFAULT now() NOT NULL, + "publishedAt" timestamp, + "status" varchar(32) DEFAULT 'draft' NOT NULL, + "version" integer DEFAULT 1 NOT NULL, + "hiddenAt" timestamp, + "hiddenBy" integer, + "title" varchar(200) NOT NULL, + "slug" varchar(160) NOT NULL, + "body" jsonb NOT NULL, + "author" integer, + "desk" integer NOT NULL +); +--> statement-breakpoint +ALTER TABLE "newsroom_letters" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint +CREATE TABLE "newsroom_pages" ( + "id" serial PRIMARY KEY, + "createdAt" timestamp DEFAULT now() NOT NULL, + "updatedAt" timestamp DEFAULT now() NOT NULL, + "publishedAt" timestamp, + "status" varchar(32) DEFAULT 'draft' NOT NULL, + "title" varchar(200) NOT NULL, + "slug" varchar(160) NOT NULL, + "content" jsonb DEFAULT '[]' NOT NULL +); +--> statement-breakpoint +ALTER TABLE "newsroom_pages" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint +CREATE TABLE "newsroom_stories" ( + "id" serial PRIMARY KEY, + "createdAt" timestamp DEFAULT now() NOT NULL, + "updatedAt" timestamp DEFAULT now() NOT NULL, + "publishedAt" timestamp, + "status" varchar(32) DEFAULT 'draft' NOT NULL, + "version" integer DEFAULT 1 NOT NULL, + "title" varchar(200) NOT NULL, + "slug" varchar(160) NOT NULL, + "code" varchar(100) NOT NULL, + "excerpt" text, + "views" integer DEFAULT 0 NOT NULL, + "featured" boolean DEFAULT false NOT NULL, + "noIndex" boolean, + "author" integer, + "animation" integer, + "category" integer NOT NULL +); +--> statement-breakpoint +ALTER TABLE "newsroom_stories" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint +CREATE TABLE "newsroom_stories_gallery" ( + "itemId" integer, + "relatedItemId" integer, + "position" integer NOT NULL, + "createdAt" timestamp DEFAULT now() NOT NULL, + CONSTRAINT "newsroom_stories_gallery_pk" PRIMARY KEY("itemId","relatedItemId") +); +--> statement-breakpoint +ALTER TABLE "newsroom_stories_gallery" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint +CREATE TABLE "newsroom_tags" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), + "createdAt" timestamp DEFAULT now() NOT NULL, + "updatedAt" timestamp DEFAULT now() NOT NULL, + "publishedAt" timestamp, + "status" varchar(32) DEFAULT 'draft' NOT NULL, + "version" integer DEFAULT 1 NOT NULL, + "hiddenAt" timestamp, + "hiddenBy" integer, + "category" integer +); +--> statement-breakpoint +ALTER TABLE "newsroom_tags" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint +CREATE TABLE "newsroom_tags_related_tags" ( + "itemId" uuid, + "relatedItemId" uuid, + "position" integer NOT NULL, + "createdAt" timestamp DEFAULT now() NOT NULL, + CONSTRAINT "newsroom_tags_related_tags_pk" PRIMARY KEY("itemId","relatedItemId") +); +--> statement-breakpoint +ALTER TABLE "newsroom_tags_related_tags" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint +CREATE TABLE "newsroom_tags_translations" ( + "itemId" uuid, + "languageId" integer, + "version" integer DEFAULT 1 NOT NULL, + "createdAt" timestamp DEFAULT now() NOT NULL, + "updatedAt" timestamp DEFAULT now() NOT NULL, + "publishedAt" timestamp, + "status" varchar(32) DEFAULT 'draft' NOT NULL, + "name" varchar(100) NOT NULL, + "slug" varchar(160) NOT NULL, + CONSTRAINT "newsroom_tags_translations_item_id_language_id_pk" PRIMARY KEY("itemId","languageId") +); +--> statement-breakpoint +ALTER TABLE "newsroom_tags_translations" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint +ALTER TABLE "example_advanced_articles_categories" DROP CONSTRAINT "example_advanced_articles_categories_itemId_example_advanced_ar";--> statement-breakpoint +ALTER TABLE "example_advanced_articles_categories" DROP CONSTRAINT "example_advanced_articles_categories_relatedItemId_example_cate";--> statement-breakpoint +ALTER TABLE "example_advanced_articles_faq" DROP CONSTRAINT "example_advanced_articles_faq_itemId_example_advanced_articles_";--> statement-breakpoint +ALTER TABLE "example_advanced_articles_related_articles" DROP CONSTRAINT "example_advanced_articles_related_articles_itemId_example_advan";--> statement-breakpoint +ALTER TABLE "example_advanced_articles_related_articles" DROP CONSTRAINT "example_advanced_articles_related_articles_relatedItemId_exampl";--> statement-breakpoint +ALTER TABLE "example_advanced_articles_tags" DROP CONSTRAINT "example_advanced_articles_tags_ATchHLRtvoCM_fkey";--> statement-breakpoint +ALTER TABLE "example_advanced_articles_tags" DROP CONSTRAINT "example_advanced_articles_tags_dEsWdkDmu7nW_fkey";--> statement-breakpoint +ALTER TABLE "example_advanced_articles_translations" DROP CONSTRAINT "example_advanced_articles_translations_itemId_example_advanced_";--> statement-breakpoint +ALTER TABLE "example_articles" DROP CONSTRAINT "example_articles_category_example_categories_id_fk";--> statement-breakpoint +ALTER TABLE "example_articles_gallery" DROP CONSTRAINT "example_articles_gallery_itemId_example_articles_id_fkey";--> statement-breakpoint +ALTER TABLE "example_events" DROP CONSTRAINT "example_events_primaryTag_example_tags_id_fkey";--> statement-breakpoint +ALTER TABLE "example_events" DROP CONSTRAINT "example_events_category_example_categories_id_fkey";--> statement-breakpoint +ALTER TABLE "example_events_gallery" DROP CONSTRAINT "example_events_gallery_itemId_example_events_id_fkey";--> statement-breakpoint +ALTER TABLE "example_events_related_events" DROP CONSTRAINT "example_events_related_events_itemId_example_events_id_fkey";--> statement-breakpoint +ALTER TABLE "example_events_related_events" DROP CONSTRAINT "example_events_related_events_oaRvqnKyAS7L_fkey";--> statement-breakpoint +ALTER TABLE "example_events_sessions" DROP CONSTRAINT "example_events_sessions_itemId_example_events_id_fkey";--> statement-breakpoint +ALTER TABLE "example_events_tags" DROP CONSTRAINT "example_events_tags_itemId_example_events_id_fkey";--> statement-breakpoint +ALTER TABLE "example_events_tags" DROP CONSTRAINT "example_events_tags_relatedItemId_example_tags_id_fkey";--> statement-breakpoint +ALTER TABLE "example_events_translations" DROP CONSTRAINT "example_events_translations_itemId_example_events_id_fkey";--> statement-breakpoint +ALTER TABLE "example_localized_articles_translations" DROP CONSTRAINT "example_localized_articles_translations_itemId_example_localize";--> statement-breakpoint +ALTER TABLE "example_tags" DROP CONSTRAINT "example_tags_category_example_categories_id_fkey";--> statement-breakpoint +ALTER TABLE "example_tags_related_tags" DROP CONSTRAINT "example_tags_related_tags_itemId_example_tags_id_fkey";--> statement-breakpoint +ALTER TABLE "example_tags_related_tags" DROP CONSTRAINT "example_tags_related_tags_relatedItemId_example_tags_id_fkey";--> statement-breakpoint +ALTER TABLE "example_tags_translations" DROP CONSTRAINT "example_tags_translations_itemId_example_tags_id_fkey";--> statement-breakpoint +DROP TABLE "example_advanced_articles";--> statement-breakpoint +DROP TABLE "example_advanced_articles_categories";--> statement-breakpoint +DROP TABLE "example_advanced_articles_faq";--> statement-breakpoint +DROP TABLE "example_advanced_articles_related_articles";--> statement-breakpoint +DROP TABLE "example_advanced_articles_tags";--> statement-breakpoint +DROP TABLE "example_advanced_articles_translations";--> statement-breakpoint +DROP TABLE "example_articles";--> statement-breakpoint +DROP TABLE "example_articles_gallery";--> statement-breakpoint +DROP TABLE "example_categories";--> statement-breakpoint +DROP TABLE "example_discussions";--> statement-breakpoint +DROP TABLE "example_events";--> statement-breakpoint +DROP TABLE "example_events_gallery";--> statement-breakpoint +DROP TABLE "example_events_related_events";--> statement-breakpoint +DROP TABLE "example_events_sessions";--> statement-breakpoint +DROP TABLE "example_events_tags";--> statement-breakpoint +DROP TABLE "example_events_translations";--> statement-breakpoint +DROP TABLE "example_localized_articles";--> statement-breakpoint +DROP TABLE "example_localized_articles_translations";--> statement-breakpoint +DROP TABLE "example_pages";--> statement-breakpoint +DROP TABLE "example_tags";--> statement-breakpoint +DROP TABLE "example_tags_related_tags";--> statement-breakpoint +DROP TABLE "example_tags_translations";--> statement-breakpoint +CREATE UNIQUE INDEX "newsroom_articles_slug_key" ON "newsroom_articles" ("slug");--> statement-breakpoint +CREATE INDEX "newsroom_articles_author_idx" ON "newsroom_articles" ("author");--> statement-breakpoint +CREATE INDEX "newsroom_articles_category_idx" ON "newsroom_articles" ("category");--> statement-breakpoint +CREATE INDEX "newsroom_articles_created_at_idx" ON "newsroom_articles" ("createdAt");--> statement-breakpoint +CREATE INDEX "newsroom_articles_updated_at_idx" ON "newsroom_articles" ("updatedAt");--> statement-breakpoint +CREATE INDEX "newsroom_articles_status_published_at_idx" ON "newsroom_articles" ("status","publishedAt");--> statement-breakpoint +CREATE INDEX "newsroom_bulletins_created_at_idx" ON "newsroom_bulletins" ("createdAt");--> statement-breakpoint +CREATE INDEX "newsroom_bulletins_updated_at_idx" ON "newsroom_bulletins" ("updatedAt");--> statement-breakpoint +CREATE INDEX "newsroom_bulletins_status_published_at_idx" ON "newsroom_bulletins" ("status","publishedAt");--> statement-breakpoint +CREATE INDEX "newsroom_bulletins_translations_language_id_status_idx" ON "newsroom_bulletins_translations" ("languageId","status");--> statement-breakpoint +CREATE UNIQUE INDEX "newsroom_bulletins_translations_language_id_slug_key" ON "newsroom_bulletins_translations" ("languageId","slug");--> statement-breakpoint +CREATE UNIQUE INDEX "newsroom_categories_slug_key" ON "newsroom_categories" ("slug");--> statement-breakpoint +CREATE INDEX "newsroom_categories_created_at_idx" ON "newsroom_categories" ("createdAt");--> statement-breakpoint +CREATE INDEX "newsroom_categories_updated_at_idx" ON "newsroom_categories" ("updatedAt");--> statement-breakpoint +CREATE UNIQUE INDEX "newsroom_comments_slug_key" ON "newsroom_comments" ("slug");--> statement-breakpoint +CREATE INDEX "newsroom_comments_author_idx" ON "newsroom_comments" ("author");--> statement-breakpoint +CREATE INDEX "newsroom_comments_article_idx" ON "newsroom_comments" ("article");--> statement-breakpoint +CREATE INDEX "newsroom_comments_parent_idx" ON "newsroom_comments" ("parent");--> statement-breakpoint +CREATE INDEX "newsroom_comments_created_at_idx" ON "newsroom_comments" ("createdAt");--> statement-breakpoint +CREATE INDEX "newsroom_comments_updated_at_idx" ON "newsroom_comments" ("updatedAt");--> statement-breakpoint +CREATE INDEX "newsroom_comments_status_published_at_idx" ON "newsroom_comments" ("status","publishedAt");--> statement-breakpoint +CREATE INDEX "newsroom_comments_hidden_by_idx" ON "newsroom_comments" ("hiddenBy");--> statement-breakpoint +CREATE INDEX "newsroom_events_primary_tag_idx" ON "newsroom_events" ("primaryTag");--> statement-breakpoint +CREATE INDEX "newsroom_events_category_idx" ON "newsroom_events" ("category");--> statement-breakpoint +CREATE INDEX "newsroom_events_created_at_idx" ON "newsroom_events" ("createdAt");--> statement-breakpoint +CREATE INDEX "newsroom_events_updated_at_idx" ON "newsroom_events" ("updatedAt");--> statement-breakpoint +CREATE INDEX "newsroom_events_status_published_at_idx" ON "newsroom_events" ("status","publishedAt");--> statement-breakpoint +CREATE INDEX "newsroom_events_hidden_by_idx" ON "newsroom_events" ("hiddenBy");--> statement-breakpoint +CREATE UNIQUE INDEX "newsroom_events_gallery_position_key" ON "newsroom_events_gallery" ("itemId","position");--> statement-breakpoint +CREATE INDEX "newsroom_events_gallery_related_item_id_idx" ON "newsroom_events_gallery" ("relatedItemId");--> statement-breakpoint +CREATE UNIQUE INDEX "newsroom_events_related_events_position_key" ON "newsroom_events_related_events" ("itemId","position");--> statement-breakpoint +CREATE INDEX "newsroom_events_related_events_related_item_id_idx" ON "newsroom_events_related_events" ("relatedItemId");--> statement-breakpoint +CREATE UNIQUE INDEX "newsroom_events_sessions_position_key" ON "newsroom_events_sessions" ("itemId","position");--> statement-breakpoint +CREATE UNIQUE INDEX "newsroom_events_tags_position_key" ON "newsroom_events_tags" ("itemId","position");--> statement-breakpoint +CREATE INDEX "newsroom_events_tags_related_item_id_idx" ON "newsroom_events_tags" ("relatedItemId");--> statement-breakpoint +CREATE INDEX "newsroom_events_translations_language_id_status_idx" ON "newsroom_events_translations" ("languageId","status");--> statement-breakpoint +CREATE UNIQUE INDEX "newsroom_events_translations_language_id_slug_key" ON "newsroom_events_translations" ("languageId","slug");--> statement-breakpoint +CREATE INDEX "newsroom_explainers_syndication_priority_idx" ON "newsroom_explainers" ("syndicationPriority");--> statement-breakpoint +CREATE INDEX "newsroom_explainers_created_at_idx" ON "newsroom_explainers" ("createdAt");--> statement-breakpoint +CREATE INDEX "newsroom_explainers_updated_at_idx" ON "newsroom_explainers" ("updatedAt");--> statement-breakpoint +CREATE INDEX "newsroom_explainers_status_published_at_idx" ON "newsroom_explainers" ("status","publishedAt");--> statement-breakpoint +CREATE UNIQUE INDEX "newsroom_explainers_categories_position_key" ON "newsroom_explainers_categories" ("itemId","position");--> statement-breakpoint +CREATE INDEX "newsroom_explainers_categories_related_item_id_idx" ON "newsroom_explainers_categories" ("relatedItemId");--> statement-breakpoint +CREATE UNIQUE INDEX "newsroom_explainers_faq_position_key" ON "newsroom_explainers_faq" ("itemId","position");--> statement-breakpoint +CREATE UNIQUE INDEX "newsroom_explainers_related_explainers_position_key" ON "newsroom_explainers_related_explainers" ("itemId","position");--> statement-breakpoint +CREATE INDEX "newsroom_explainers_related_explainers_related_item_id_idx" ON "newsroom_explainers_related_explainers" ("relatedItemId");--> statement-breakpoint +CREATE UNIQUE INDEX "newsroom_explainers_tags_position_key" ON "newsroom_explainers_tags" ("itemId","position");--> statement-breakpoint +CREATE INDEX "newsroom_explainers_tags_related_item_id_idx" ON "newsroom_explainers_tags" ("relatedItemId");--> statement-breakpoint +CREATE INDEX "newsroom_explainers_translations_language_id_status_idx" ON "newsroom_explainers_translations" ("languageId","status");--> statement-breakpoint +CREATE UNIQUE INDEX "newsroom_explainers_translations_language_id_slug_key" ON "newsroom_explainers_translations" ("languageId","slug");--> statement-breakpoint +CREATE UNIQUE INDEX "newsroom_letters_slug_key" ON "newsroom_letters" ("slug");--> statement-breakpoint +CREATE INDEX "newsroom_letters_author_idx" ON "newsroom_letters" ("author");--> statement-breakpoint +CREATE INDEX "newsroom_letters_desk_published_at_idx" ON "newsroom_letters" ("desk","publishedAt");--> statement-breakpoint +CREATE INDEX "newsroom_letters_created_at_idx" ON "newsroom_letters" ("createdAt");--> statement-breakpoint +CREATE INDEX "newsroom_letters_updated_at_idx" ON "newsroom_letters" ("updatedAt");--> statement-breakpoint +CREATE INDEX "newsroom_letters_status_published_at_idx" ON "newsroom_letters" ("status","publishedAt");--> statement-breakpoint +CREATE INDEX "newsroom_letters_hidden_by_idx" ON "newsroom_letters" ("hiddenBy");--> statement-breakpoint +CREATE UNIQUE INDEX "newsroom_pages_slug_key" ON "newsroom_pages" ("slug");--> statement-breakpoint +CREATE INDEX "newsroom_pages_created_at_idx" ON "newsroom_pages" ("createdAt");--> statement-breakpoint +CREATE INDEX "newsroom_pages_updated_at_idx" ON "newsroom_pages" ("updatedAt");--> statement-breakpoint +CREATE INDEX "newsroom_pages_status_published_at_idx" ON "newsroom_pages" ("status","publishedAt");--> statement-breakpoint +CREATE INDEX "newsroom_stories_status_created_at_idx" ON "newsroom_stories" ("status","createdAt");--> statement-breakpoint +CREATE UNIQUE INDEX "newsroom_stories_slug_key" ON "newsroom_stories" ("slug");--> statement-breakpoint +CREATE UNIQUE INDEX "newsroom_stories_code_key" ON "newsroom_stories" ("code");--> statement-breakpoint +CREATE INDEX "newsroom_stories_author_idx" ON "newsroom_stories" ("author");--> statement-breakpoint +CREATE INDEX "newsroom_stories_animation_idx" ON "newsroom_stories" ("animation");--> statement-breakpoint +CREATE INDEX "newsroom_stories_category_idx" ON "newsroom_stories" ("category");--> statement-breakpoint +CREATE INDEX "newsroom_stories_created_at_idx" ON "newsroom_stories" ("createdAt");--> statement-breakpoint +CREATE INDEX "newsroom_stories_updated_at_idx" ON "newsroom_stories" ("updatedAt");--> statement-breakpoint +CREATE INDEX "newsroom_stories_status_published_at_idx" ON "newsroom_stories" ("status","publishedAt");--> statement-breakpoint +CREATE UNIQUE INDEX "newsroom_stories_gallery_position_key" ON "newsroom_stories_gallery" ("itemId","position");--> statement-breakpoint +CREATE INDEX "newsroom_stories_gallery_related_item_id_idx" ON "newsroom_stories_gallery" ("relatedItemId");--> statement-breakpoint +CREATE INDEX "newsroom_tags_category_idx" ON "newsroom_tags" ("category");--> statement-breakpoint +CREATE INDEX "newsroom_tags_created_at_idx" ON "newsroom_tags" ("createdAt");--> statement-breakpoint +CREATE INDEX "newsroom_tags_updated_at_idx" ON "newsroom_tags" ("updatedAt");--> statement-breakpoint +CREATE INDEX "newsroom_tags_status_published_at_idx" ON "newsroom_tags" ("status","publishedAt");--> statement-breakpoint +CREATE INDEX "newsroom_tags_hidden_by_idx" ON "newsroom_tags" ("hiddenBy");--> statement-breakpoint +CREATE UNIQUE INDEX "newsroom_tags_related_tags_position_key" ON "newsroom_tags_related_tags" ("itemId","position");--> statement-breakpoint +CREATE INDEX "newsroom_tags_related_tags_related_item_id_idx" ON "newsroom_tags_related_tags" ("relatedItemId");--> statement-breakpoint +CREATE INDEX "newsroom_tags_translations_language_id_status_idx" ON "newsroom_tags_translations" ("languageId","status");--> statement-breakpoint +CREATE UNIQUE INDEX "newsroom_tags_translations_language_id_slug_key" ON "newsroom_tags_translations" ("languageId","slug");--> statement-breakpoint +ALTER TABLE "newsroom_articles" ADD CONSTRAINT "newsroom_articles_author_core_users_id_fkey" FOREIGN KEY ("author") REFERENCES "core_users"("id") ON DELETE SET NULL ON UPDATE CASCADE;--> statement-breakpoint +ALTER TABLE "newsroom_articles" ADD CONSTRAINT "newsroom_articles_category_newsroom_categories_id_fkey" FOREIGN KEY ("category") REFERENCES "newsroom_categories"("id") ON DELETE SET NULL ON UPDATE CASCADE;--> statement-breakpoint +ALTER TABLE "newsroom_bulletins_translations" ADD CONSTRAINT "newsroom_bulletins_translations_kgMZwrpzwwU7_fkey" FOREIGN KEY ("itemId") REFERENCES "newsroom_bulletins"("id") ON DELETE CASCADE ON UPDATE CASCADE;--> statement-breakpoint +ALTER TABLE "newsroom_bulletins_translations" ADD CONSTRAINT "newsroom_bulletins_translations_rkkiMJQiOrEo_fkey" FOREIGN KEY ("languageId") REFERENCES "core_languages"("id") ON DELETE RESTRICT ON UPDATE CASCADE;--> statement-breakpoint +ALTER TABLE "newsroom_comments" ADD CONSTRAINT "newsroom_comments_hiddenBy_core_users_id_fkey" FOREIGN KEY ("hiddenBy") REFERENCES "core_users"("id") ON DELETE SET NULL ON UPDATE CASCADE;--> statement-breakpoint +ALTER TABLE "newsroom_comments" ADD CONSTRAINT "newsroom_comments_author_core_users_id_fkey" FOREIGN KEY ("author") REFERENCES "core_users"("id") ON DELETE SET NULL ON UPDATE CASCADE;--> statement-breakpoint +ALTER TABLE "newsroom_comments" ADD CONSTRAINT "newsroom_comments_article_newsroom_articles_id_fkey" FOREIGN KEY ("article") REFERENCES "newsroom_articles"("id") ON DELETE CASCADE ON UPDATE CASCADE;--> statement-breakpoint +ALTER TABLE "newsroom_comments" ADD CONSTRAINT "newsroom_comments_parent_newsroom_comments_id_fkey" FOREIGN KEY ("parent") REFERENCES "newsroom_comments"("id") ON DELETE SET NULL ON UPDATE CASCADE;--> statement-breakpoint +ALTER TABLE "newsroom_events" ADD CONSTRAINT "newsroom_events_hiddenBy_core_users_id_fkey" FOREIGN KEY ("hiddenBy") REFERENCES "core_users"("id") ON DELETE SET NULL ON UPDATE CASCADE;--> statement-breakpoint +ALTER TABLE "newsroom_events" ADD CONSTRAINT "newsroom_events_primaryTag_newsroom_tags_id_fkey" FOREIGN KEY ("primaryTag") REFERENCES "newsroom_tags"("id") ON DELETE SET NULL ON UPDATE CASCADE;--> statement-breakpoint +ALTER TABLE "newsroom_events" ADD CONSTRAINT "newsroom_events_category_newsroom_categories_id_fkey" FOREIGN KEY ("category") REFERENCES "newsroom_categories"("id") ON DELETE SET NULL ON UPDATE CASCADE;--> statement-breakpoint +ALTER TABLE "newsroom_events_gallery" ADD CONSTRAINT "newsroom_events_gallery_itemId_newsroom_events_id_fkey" FOREIGN KEY ("itemId") REFERENCES "newsroom_events"("id") ON DELETE CASCADE ON UPDATE CASCADE;--> statement-breakpoint +ALTER TABLE "newsroom_events_gallery" ADD CONSTRAINT "newsroom_events_gallery_relatedItemId_core_files_id_fkey" FOREIGN KEY ("relatedItemId") REFERENCES "core_files"("id") ON DELETE RESTRICT ON UPDATE CASCADE;--> statement-breakpoint +ALTER TABLE "newsroom_events_related_events" ADD CONSTRAINT "newsroom_events_related_events_itemId_newsroom_events_id_fkey" FOREIGN KEY ("itemId") REFERENCES "newsroom_events"("id") ON DELETE CASCADE ON UPDATE CASCADE;--> statement-breakpoint +ALTER TABLE "newsroom_events_related_events" ADD CONSTRAINT "newsroom_events_related_events_0xHNSLM3JAJj_fkey" FOREIGN KEY ("relatedItemId") REFERENCES "newsroom_events"("id") ON DELETE CASCADE ON UPDATE CASCADE;--> statement-breakpoint +ALTER TABLE "newsroom_events_sessions" ADD CONSTRAINT "newsroom_events_sessions_itemId_newsroom_events_id_fkey" FOREIGN KEY ("itemId") REFERENCES "newsroom_events"("id") ON DELETE CASCADE ON UPDATE CASCADE;--> statement-breakpoint +ALTER TABLE "newsroom_events_tags" ADD CONSTRAINT "newsroom_events_tags_itemId_newsroom_events_id_fkey" FOREIGN KEY ("itemId") REFERENCES "newsroom_events"("id") ON DELETE CASCADE ON UPDATE CASCADE;--> statement-breakpoint +ALTER TABLE "newsroom_events_tags" ADD CONSTRAINT "newsroom_events_tags_relatedItemId_newsroom_tags_id_fkey" FOREIGN KEY ("relatedItemId") REFERENCES "newsroom_tags"("id") ON DELETE RESTRICT ON UPDATE CASCADE;--> statement-breakpoint +ALTER TABLE "newsroom_events_translations" ADD CONSTRAINT "newsroom_events_translations_itemId_newsroom_events_id_fkey" FOREIGN KEY ("itemId") REFERENCES "newsroom_events"("id") ON DELETE CASCADE ON UPDATE CASCADE;--> statement-breakpoint +ALTER TABLE "newsroom_events_translations" ADD CONSTRAINT "newsroom_events_translations_languageId_core_languages_id_fkey" FOREIGN KEY ("languageId") REFERENCES "core_languages"("id") ON DELETE RESTRICT ON UPDATE CASCADE;--> statement-breakpoint +ALTER TABLE "newsroom_explainers_categories" ADD CONSTRAINT "newsroom_explainers_categories_9dOmLv3k6xDX_fkey" FOREIGN KEY ("itemId") REFERENCES "newsroom_explainers"("id") ON DELETE CASCADE ON UPDATE CASCADE;--> statement-breakpoint +ALTER TABLE "newsroom_explainers_categories" ADD CONSTRAINT "newsroom_explainers_categories_mnxKvproEUm5_fkey" FOREIGN KEY ("relatedItemId") REFERENCES "newsroom_categories"("id") ON DELETE RESTRICT ON UPDATE CASCADE;--> statement-breakpoint +ALTER TABLE "newsroom_explainers_faq" ADD CONSTRAINT "newsroom_explainers_faq_itemId_newsroom_explainers_id_fkey" FOREIGN KEY ("itemId") REFERENCES "newsroom_explainers"("id") ON DELETE CASCADE ON UPDATE CASCADE;--> statement-breakpoint +ALTER TABLE "newsroom_explainers_related_explainers" ADD CONSTRAINT "newsroom_explainers_related_explainers_TFvIU3TogSGy_fkey" FOREIGN KEY ("itemId") REFERENCES "newsroom_explainers"("id") ON DELETE CASCADE ON UPDATE CASCADE;--> statement-breakpoint +ALTER TABLE "newsroom_explainers_related_explainers" ADD CONSTRAINT "newsroom_explainers_related_explainers_74JhaXF5PsDV_fkey" FOREIGN KEY ("relatedItemId") REFERENCES "newsroom_explainers"("id") ON DELETE CASCADE ON UPDATE CASCADE;--> statement-breakpoint +ALTER TABLE "newsroom_explainers_tags" ADD CONSTRAINT "newsroom_explainers_tags_itemId_newsroom_explainers_id_fkey" FOREIGN KEY ("itemId") REFERENCES "newsroom_explainers"("id") ON DELETE CASCADE ON UPDATE CASCADE;--> statement-breakpoint +ALTER TABLE "newsroom_explainers_tags" ADD CONSTRAINT "newsroom_explainers_tags_relatedItemId_newsroom_tags_id_fkey" FOREIGN KEY ("relatedItemId") REFERENCES "newsroom_tags"("id") ON DELETE RESTRICT ON UPDATE CASCADE;--> statement-breakpoint +ALTER TABLE "newsroom_explainers_translations" ADD CONSTRAINT "newsroom_explainers_translations_hcPB0jdTm8MV_fkey" FOREIGN KEY ("itemId") REFERENCES "newsroom_explainers"("id") ON DELETE CASCADE ON UPDATE CASCADE;--> statement-breakpoint +ALTER TABLE "newsroom_explainers_translations" ADD CONSTRAINT "newsroom_explainers_translations_qqggtF6jhcsZ_fkey" FOREIGN KEY ("languageId") REFERENCES "core_languages"("id") ON DELETE RESTRICT ON UPDATE CASCADE;--> statement-breakpoint +ALTER TABLE "newsroom_letters" ADD CONSTRAINT "newsroom_letters_hiddenBy_core_users_id_fkey" FOREIGN KEY ("hiddenBy") REFERENCES "core_users"("id") ON DELETE SET NULL ON UPDATE CASCADE;--> statement-breakpoint +ALTER TABLE "newsroom_letters" ADD CONSTRAINT "newsroom_letters_author_core_users_id_fkey" FOREIGN KEY ("author") REFERENCES "core_users"("id") ON DELETE SET NULL ON UPDATE CASCADE;--> statement-breakpoint +ALTER TABLE "newsroom_letters" ADD CONSTRAINT "newsroom_letters_desk_core_content_containers_id_fkey" FOREIGN KEY ("desk") REFERENCES "core_content_containers"("id") ON DELETE RESTRICT ON UPDATE CASCADE;--> statement-breakpoint +ALTER TABLE "newsroom_stories" ADD CONSTRAINT "newsroom_stories_author_core_users_id_fkey" FOREIGN KEY ("author") REFERENCES "core_users"("id") ON DELETE SET NULL ON UPDATE CASCADE;--> statement-breakpoint +ALTER TABLE "newsroom_stories" ADD CONSTRAINT "newsroom_stories_animation_core_files_id_fkey" FOREIGN KEY ("animation") REFERENCES "core_files"("id") ON DELETE RESTRICT ON UPDATE CASCADE;--> statement-breakpoint +ALTER TABLE "newsroom_stories" ADD CONSTRAINT "newsroom_stories_category_newsroom_categories_id_fkey" FOREIGN KEY ("category") REFERENCES "newsroom_categories"("id") ON DELETE RESTRICT ON UPDATE CASCADE;--> statement-breakpoint +ALTER TABLE "newsroom_stories_gallery" ADD CONSTRAINT "newsroom_stories_gallery_itemId_newsroom_stories_id_fkey" FOREIGN KEY ("itemId") REFERENCES "newsroom_stories"("id") ON DELETE CASCADE ON UPDATE CASCADE;--> statement-breakpoint +ALTER TABLE "newsroom_stories_gallery" ADD CONSTRAINT "newsroom_stories_gallery_relatedItemId_core_files_id_fkey" FOREIGN KEY ("relatedItemId") REFERENCES "core_files"("id") ON DELETE RESTRICT ON UPDATE CASCADE;--> statement-breakpoint +ALTER TABLE "newsroom_tags" ADD CONSTRAINT "newsroom_tags_hiddenBy_core_users_id_fkey" FOREIGN KEY ("hiddenBy") REFERENCES "core_users"("id") ON DELETE SET NULL ON UPDATE CASCADE;--> statement-breakpoint +ALTER TABLE "newsroom_tags" ADD CONSTRAINT "newsroom_tags_category_newsroom_categories_id_fkey" FOREIGN KEY ("category") REFERENCES "newsroom_categories"("id") ON DELETE SET NULL ON UPDATE CASCADE;--> statement-breakpoint +ALTER TABLE "newsroom_tags_related_tags" ADD CONSTRAINT "newsroom_tags_related_tags_itemId_newsroom_tags_id_fkey" FOREIGN KEY ("itemId") REFERENCES "newsroom_tags"("id") ON DELETE CASCADE ON UPDATE CASCADE;--> statement-breakpoint +ALTER TABLE "newsroom_tags_related_tags" ADD CONSTRAINT "newsroom_tags_related_tags_relatedItemId_newsroom_tags_id_fkey" FOREIGN KEY ("relatedItemId") REFERENCES "newsroom_tags"("id") ON DELETE CASCADE ON UPDATE CASCADE;--> statement-breakpoint +ALTER TABLE "newsroom_tags_translations" ADD CONSTRAINT "newsroom_tags_translations_itemId_newsroom_tags_id_fkey" FOREIGN KEY ("itemId") REFERENCES "newsroom_tags"("id") ON DELETE CASCADE ON UPDATE CASCADE;--> statement-breakpoint +ALTER TABLE "newsroom_tags_translations" ADD CONSTRAINT "newsroom_tags_translations_languageId_core_languages_id_fkey" FOREIGN KEY ("languageId") REFERENCES "core_languages"("id") ON DELETE RESTRICT ON UPDATE CASCADE; \ No newline at end of file diff --git a/apps/api/migrations/20261024173113_replace_example_with_newsroom/snapshot.json b/apps/api/migrations/20261024173113_replace_example_with_newsroom/snapshot.json new file mode 100644 index 0000000000..0d9bd98e56 --- /dev/null +++ b/apps/api/migrations/20261024173113_replace_example_with_newsroom/snapshot.json @@ -0,0 +1,20998 @@ +{ + "version": "8", + "dialect": "postgres", + "id": "4b66f492-fb3f-4ba0-a722-b4787d0ef9b8", + "prevIds": [ + "0c989133-91e8-46c3-86d3-f9b7ac8ec7a3" + ], + "ddl": [ + { + "isRlsEnabled": true, + "name": "core_admin_permissions", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_admin_sessions", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_ai_action_settings", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_ai_budget_periods", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_ai_calls", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_ai_cost_adjustments", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_ai_reservations", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_ai_role_permissions", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_ai_role_policies", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_ai_runs", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_ai_settings", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_ai_translation_sources", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_ai_user_overrides", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_content_containers", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_content_documents", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_content_drafts", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_content_field_locks", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_content_submissions", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_content_file_refs", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_content_revisions", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_content_schedules", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_content_slug_history", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_cron", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_admin_dashboard", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_files", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_files_alt", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_files_alt_analysis", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_files_alt_state", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_languages", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_languages_words", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_logs", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_moderation_report_events", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_moderation_report_reasons", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_moderation_reports", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_moderators_permissions", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_notification_deliveries", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_notification_events", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_notification_receipts", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_notification_settings", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_notification_user_state", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_notifications", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_page_layouts", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_users_passkey_challenges", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_users_passkeys", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_queue", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_roles", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_search_index", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_search_popularity", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_secrets", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_sessions", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_sessions_known_devices", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_update_lock", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_update_results", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_update_sources", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_users", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_users_confirm_emails", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_users_forgot_password", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_users_secondary_roles", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_users_sso", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_users_sso_operations", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_users_sso_profile_sources", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "blog_categories", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "blog_categories_translations", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "blog_posts", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "blog_posts_author_id", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "blog_posts_category_id", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "blog_posts_translations", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "forum_comments", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "forum_topics", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "newsroom_articles", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "newsroom_bulletins", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "newsroom_bulletins_translations", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "newsroom_categories", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "newsroom_comments", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "newsroom_events", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "newsroom_events_gallery", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "newsroom_events_related_events", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "newsroom_events_sessions", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "newsroom_events_tags", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "newsroom_events_translations", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "newsroom_explainers", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "newsroom_explainers_categories", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "newsroom_explainers_faq", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "newsroom_explainers_related_explainers", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "newsroom_explainers_tags", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "newsroom_explainers_translations", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "newsroom_letters", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "newsroom_pages", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "newsroom_stories", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "newsroom_stories_gallery", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "newsroom_tags", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "newsroom_tags_related_tags", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "newsroom_tags_translations", + "entityType": "tables", + "schema": "public" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_admin_permissions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "roleId", + "entityType": "columns", + "schema": "public", + "table": "core_admin_permissions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_admin_permissions" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_admin_permissions" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_admin_permissions" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "protected", + "entityType": "columns", + "schema": "public", + "table": "core_admin_permissions" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "unrestricted", + "entityType": "columns", + "schema": "public", + "table": "core_admin_permissions" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'[]'", + "generated": null, + "identity": null, + "name": "permissions", + "entityType": "columns", + "schema": "public", + "table": "core_admin_permissions" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_admin_sessions" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "token", + "entityType": "columns", + "schema": "public", + "table": "core_admin_sessions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_admin_sessions" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_admin_sessions" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "lastSeen", + "entityType": "columns", + "schema": "public", + "table": "core_admin_sessions" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "expiresAt", + "entityType": "columns", + "schema": "public", + "table": "core_admin_sessions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "deviceId", + "entityType": "columns", + "schema": "public", + "table": "core_admin_sessions" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "actionKey", + "entityType": "columns", + "schema": "public", + "table": "core_ai_action_settings" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "true", + "generated": null, + "identity": null, + "name": "enabled", + "entityType": "columns", + "schema": "public", + "table": "core_ai_action_settings" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "modelId", + "entityType": "columns", + "schema": "public", + "table": "core_ai_action_settings" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "fallbackModelId", + "entityType": "columns", + "schema": "public", + "table": "core_ai_action_settings" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "maxInputCharacters", + "entityType": "columns", + "schema": "public", + "table": "core_ai_action_settings" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "maxOutputTokens", + "entityType": "columns", + "schema": "public", + "table": "core_ai_action_settings" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "timeoutMs", + "entityType": "columns", + "schema": "public", + "table": "core_ai_action_settings" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "maxRetries", + "entityType": "columns", + "schema": "public", + "table": "core_ai_action_settings" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "maxSteps", + "entityType": "columns", + "schema": "public", + "table": "core_ai_action_settings" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "dailyLimit", + "entityType": "columns", + "schema": "public", + "table": "core_ai_action_settings" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "instructions", + "entityType": "columns", + "schema": "public", + "table": "core_ai_action_settings" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_ai_action_settings" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_ai_budget_periods" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "scopeKey", + "entityType": "columns", + "schema": "public", + "table": "core_ai_budget_periods" + }, + { + "type": "varchar(10)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "unit", + "entityType": "columns", + "schema": "public", + "table": "core_ai_budget_periods" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "periodStart", + "entityType": "columns", + "schema": "public", + "table": "core_ai_budget_periods" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "periodEnd", + "entityType": "columns", + "schema": "public", + "table": "core_ai_budget_periods" + }, + { + "type": "numeric(24,12)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "limitAmount", + "entityType": "columns", + "schema": "public", + "table": "core_ai_budget_periods" + }, + { + "type": "numeric(24,12)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'0'", + "generated": null, + "identity": null, + "name": "spentAmount", + "entityType": "columns", + "schema": "public", + "table": "core_ai_budget_periods" + }, + { + "type": "numeric(24,12)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'0'", + "generated": null, + "identity": null, + "name": "reservedAmount", + "entityType": "columns", + "schema": "public", + "table": "core_ai_budget_periods" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "unknownCount", + "entityType": "columns", + "schema": "public", + "table": "core_ai_budget_periods" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_ai_budget_periods" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "runId", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "attempt", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "modelId", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "provider", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "providerModelId", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "providerRequestId", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "varchar(20)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "errorCode", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "inputTokens", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "outputTokens", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "cacheReadTokens", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "cacheWriteTokens", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "reasoningTokens", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "images", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "numeric(24,12)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "costUsd", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "varchar(20)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "costSource", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "costReason", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pricingVersion", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pricingSnapshot", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "startedAt", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "finishedAt", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "reconciledAt", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_ai_cost_adjustments" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "callId", + "entityType": "columns", + "schema": "public", + "table": "core_ai_cost_adjustments" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "runId", + "entityType": "columns", + "schema": "public", + "table": "core_ai_cost_adjustments" + }, + { + "type": "numeric(24,12)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "previousCostUsd", + "entityType": "columns", + "schema": "public", + "table": "core_ai_cost_adjustments" + }, + { + "type": "varchar(20)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "previousSource", + "entityType": "columns", + "schema": "public", + "table": "core_ai_cost_adjustments" + }, + { + "type": "numeric(24,12)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "newCostUsd", + "entityType": "columns", + "schema": "public", + "table": "core_ai_cost_adjustments" + }, + { + "type": "varchar(20)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "newSource", + "entityType": "columns", + "schema": "public", + "table": "core_ai_cost_adjustments" + }, + { + "type": "numeric(24,12)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "deltaUsd", + "entityType": "columns", + "schema": "public", + "table": "core_ai_cost_adjustments" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "reason", + "entityType": "columns", + "schema": "public", + "table": "core_ai_cost_adjustments" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_ai_cost_adjustments" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_ai_reservations" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "runId", + "entityType": "columns", + "schema": "public", + "table": "core_ai_reservations" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "budgetPeriodId", + "entityType": "columns", + "schema": "public", + "table": "core_ai_reservations" + }, + { + "type": "numeric(24,12)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "amount", + "entityType": "columns", + "schema": "public", + "table": "core_ai_reservations" + }, + { + "type": "varchar(10)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'active'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "core_ai_reservations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_ai_reservations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "settledAt", + "entityType": "columns", + "schema": "public", + "table": "core_ai_reservations" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "roleId", + "entityType": "columns", + "schema": "public", + "table": "core_ai_role_permissions" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "permission", + "entityType": "columns", + "schema": "public", + "table": "core_ai_role_permissions" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "true", + "generated": null, + "identity": null, + "name": "granted", + "entityType": "columns", + "schema": "public", + "table": "core_ai_role_permissions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "dailyLimit", + "entityType": "columns", + "schema": "public", + "table": "core_ai_role_permissions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "roleId", + "entityType": "columns", + "schema": "public", + "table": "core_ai_role_policies" + }, + { + "type": "numeric(24,12)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "monthlyPoints", + "entityType": "columns", + "schema": "public", + "table": "core_ai_role_policies" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "unlimited", + "entityType": "columns", + "schema": "public", + "table": "core_ai_role_policies" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_ai_role_policies" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "actionKey", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pluginId", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "varchar(10)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "actorType", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "idempotencyKey", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "resourceType", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "resourceId", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "varchar(20)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "errorCode", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "promptVersion", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pointsConversionVersion", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "modelId", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "provider", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "providerModelId", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "inputTokens", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "outputTokens", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "cacheReadTokens", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "cacheWriteTokens", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "reasoningTokens", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "numeric(24,12)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "costUsd", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "varchar(20)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "costSource", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "numeric(24,12)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "chargedUsd", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "numeric(24,12)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "chargedPoints", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "numeric(24,12)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'0'", + "generated": null, + "identity": null, + "name": "reservedUsd", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "numeric(24,12)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'0'", + "generated": null, + "identity": null, + "name": "reservedPoints", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "varchar(20)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'pending'", + "generated": null, + "identity": null, + "name": "settlement", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "leaseExpiresAt", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "accepted", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "acceptedAt", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "sourceFingerprint", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "startedAt", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "finishedAt", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "settledAt", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_ai_settings" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "true", + "generated": null, + "identity": null, + "name": "enabled", + "entityType": "columns", + "schema": "public", + "table": "core_ai_settings" + }, + { + "type": "numeric(24,12)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "monthlyBudgetUsd", + "entityType": "columns", + "schema": "public", + "table": "core_ai_settings" + }, + { + "type": "numeric(24,12)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "systemMonthlyBudgetUsd", + "entityType": "columns", + "schema": "public", + "table": "core_ai_settings" + }, + { + "type": "numeric(24,12)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'0'", + "generated": null, + "identity": null, + "name": "defaultMonthlyPoints", + "entityType": "columns", + "schema": "public", + "table": "core_ai_settings" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "pointsConversionVersion", + "entityType": "columns", + "schema": "public", + "table": "core_ai_settings" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "10", + "generated": null, + "identity": null, + "name": "userRequestsPerMinute", + "entityType": "columns", + "schema": "public", + "table": "core_ai_settings" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "2", + "generated": null, + "identity": null, + "name": "userConcurrency", + "entityType": "columns", + "schema": "public", + "table": "core_ai_settings" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "2", + "generated": null, + "identity": null, + "name": "systemConcurrency", + "entityType": "columns", + "schema": "public", + "table": "core_ai_settings" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "365", + "generated": null, + "identity": null, + "name": "historyRetentionDays", + "entityType": "columns", + "schema": "public", + "table": "core_ai_settings" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "altEnabled", + "entityType": "columns", + "schema": "public", + "table": "core_ai_settings" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "10", + "generated": null, + "identity": null, + "name": "altBatchSize", + "entityType": "columns", + "schema": "public", + "table": "core_ai_settings" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "altScanCursor", + "entityType": "columns", + "schema": "public", + "table": "core_ai_settings" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "altScanStartedAt", + "entityType": "columns", + "schema": "public", + "table": "core_ai_settings" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_ai_settings" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_ai_translation_sources" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "contentTypeId", + "entityType": "columns", + "schema": "public", + "table": "core_ai_translation_sources" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "core_ai_translation_sources" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "locale", + "entityType": "columns", + "schema": "public", + "table": "core_ai_translation_sources" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "field", + "entityType": "columns", + "schema": "public", + "table": "core_ai_translation_sources" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "sourceLocale", + "entityType": "columns", + "schema": "public", + "table": "core_ai_translation_sources" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "sourceFingerprint", + "entityType": "columns", + "schema": "public", + "table": "core_ai_translation_sources" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "targetFingerprint", + "entityType": "columns", + "schema": "public", + "table": "core_ai_translation_sources" + }, + { + "type": "varchar(10)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "origin", + "entityType": "columns", + "schema": "public", + "table": "core_ai_translation_sources" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updatedById", + "entityType": "columns", + "schema": "public", + "table": "core_ai_translation_sources" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_ai_translation_sources" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_ai_user_overrides" + }, + { + "type": "numeric(24,12)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "monthlyPoints", + "entityType": "columns", + "schema": "public", + "table": "core_ai_user_overrides" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "unlimited", + "entityType": "columns", + "schema": "public", + "table": "core_ai_user_overrides" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "blocked", + "entityType": "columns", + "schema": "public", + "table": "core_ai_user_overrides" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_ai_user_overrides" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_content_containers" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pluginId", + "entityType": "columns", + "schema": "public", + "table": "core_content_containers" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "definitionId", + "entityType": "columns", + "schema": "public", + "table": "core_content_containers" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "parentId", + "entityType": "columns", + "schema": "public", + "table": "core_content_containers" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "core_content_containers" + }, + { + "type": "varchar(160)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "slug", + "entityType": "columns", + "schema": "public", + "table": "core_content_containers" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "position", + "entityType": "columns", + "schema": "public", + "table": "core_content_containers" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "metadata", + "entityType": "columns", + "schema": "public", + "table": "core_content_containers" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "policies", + "entityType": "columns", + "schema": "public", + "table": "core_content_containers" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_content_containers" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_content_containers" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_content_documents" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "contentTypeId", + "entityType": "columns", + "schema": "public", + "table": "core_content_documents" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "core_content_documents" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "field", + "entityType": "columns", + "schema": "public", + "table": "core_content_documents" + }, + { + "type": "varchar(35)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "''", + "generated": null, + "identity": null, + "name": "language", + "entityType": "columns", + "schema": "public", + "table": "core_content_documents" + }, + { + "type": "bytea", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "state", + "entityType": "columns", + "schema": "public", + "table": "core_content_documents" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "baseVersion", + "entityType": "columns", + "schema": "public", + "table": "core_content_documents" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_content_documents" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updatedBy", + "entityType": "columns", + "schema": "public", + "table": "core_content_documents" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_content_drafts" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "contentTypeId", + "entityType": "columns", + "schema": "public", + "table": "core_content_drafts" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "core_content_drafts" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "''", + "generated": null, + "identity": null, + "name": "language", + "entityType": "columns", + "schema": "public", + "table": "core_content_drafts" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "values", + "entityType": "columns", + "schema": "public", + "table": "core_content_drafts" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "baseVersion", + "entityType": "columns", + "schema": "public", + "table": "core_content_drafts" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_content_drafts" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updatedBy", + "entityType": "columns", + "schema": "public", + "table": "core_content_drafts" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_content_field_locks" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "contentTypeId", + "entityType": "columns", + "schema": "public", + "table": "core_content_field_locks" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "core_content_field_locks" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "field", + "entityType": "columns", + "schema": "public", + "table": "core_content_field_locks" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "''", + "generated": null, + "identity": null, + "name": "language", + "entityType": "columns", + "schema": "public", + "table": "core_content_field_locks" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_content_field_locks" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "acquiredAt", + "entityType": "columns", + "schema": "public", + "table": "core_content_field_locks" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "expiresAt", + "entityType": "columns", + "schema": "public", + "table": "core_content_field_locks" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_content_submissions" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pluginId", + "entityType": "columns", + "schema": "public", + "table": "core_content_submissions" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "contentTypeId", + "entityType": "columns", + "schema": "public", + "table": "core_content_submissions" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "core_content_submissions" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "containerDefinitionId", + "entityType": "columns", + "schema": "public", + "table": "core_content_submissions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "containerId", + "entityType": "columns", + "schema": "public", + "table": "core_content_submissions" + }, + { + "type": "varchar(16)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "kind", + "entityType": "columns", + "schema": "public", + "table": "core_content_submissions" + }, + { + "type": "varchar(16)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'pending'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "core_content_submissions" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "values", + "entityType": "columns", + "schema": "public", + "table": "core_content_submissions" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "baseValues", + "entityType": "columns", + "schema": "public", + "table": "core_content_submissions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "baseVersion", + "entityType": "columns", + "schema": "public", + "table": "core_content_submissions" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "baseUpdatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_content_submissions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "authorId", + "entityType": "columns", + "schema": "public", + "table": "core_content_submissions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "reviewerId", + "entityType": "columns", + "schema": "public", + "table": "core_content_submissions" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "reason", + "entityType": "columns", + "schema": "public", + "table": "core_content_submissions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "revisionId", + "entityType": "columns", + "schema": "public", + "table": "core_content_submissions" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "decidedAt", + "entityType": "columns", + "schema": "public", + "table": "core_content_submissions" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_content_submissions" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_content_submissions" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_content_file_refs" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "revisionId", + "entityType": "columns", + "schema": "public", + "table": "core_content_file_refs" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "fileId", + "entityType": "columns", + "schema": "public", + "table": "core_content_file_refs" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_content_file_refs" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_content_revisions" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pluginId", + "entityType": "columns", + "schema": "public", + "table": "core_content_revisions" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "contentTypeId", + "entityType": "columns", + "schema": "public", + "table": "core_content_revisions" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "core_content_revisions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "languageId", + "entityType": "columns", + "schema": "public", + "table": "core_content_revisions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "version", + "entityType": "columns", + "schema": "public", + "table": "core_content_revisions" + }, + { + "type": "varchar(20)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "operation", + "entityType": "columns", + "schema": "public", + "table": "core_content_revisions" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "snapshot", + "entityType": "columns", + "schema": "public", + "table": "core_content_revisions" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'[]'", + "generated": null, + "identity": null, + "name": "changedFields", + "entityType": "columns", + "schema": "public", + "table": "core_content_revisions" + }, + { + "type": "varchar(16)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'system'", + "generated": null, + "identity": null, + "name": "actorType", + "entityType": "columns", + "schema": "public", + "table": "core_content_revisions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "actorUserId", + "entityType": "columns", + "schema": "public", + "table": "core_content_revisions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "restoredFromRevisionId", + "entityType": "columns", + "schema": "public", + "table": "core_content_revisions" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_content_revisions" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_content_schedules" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pluginId", + "entityType": "columns", + "schema": "public", + "table": "core_content_schedules" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "contentTypeId", + "entityType": "columns", + "schema": "public", + "table": "core_content_schedules" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "core_content_schedules" + }, + { + "type": "varchar(16)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "action", + "entityType": "columns", + "schema": "public", + "table": "core_content_schedules" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "scheduledFor", + "entityType": "columns", + "schema": "public", + "table": "core_content_schedules" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "generation", + "entityType": "columns", + "schema": "public", + "table": "core_content_schedules" + }, + { + "type": "varchar(16)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'pending'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "core_content_schedules" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "createdBy", + "entityType": "columns", + "schema": "public", + "table": "core_content_schedules" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_content_schedules" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_content_schedules" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "completedAt", + "entityType": "columns", + "schema": "public", + "table": "core_content_schedules" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "lastError", + "entityType": "columns", + "schema": "public", + "table": "core_content_schedules" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "effectsError", + "entityType": "columns", + "schema": "public", + "table": "core_content_schedules" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_content_slug_history" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pluginId", + "entityType": "columns", + "schema": "public", + "table": "core_content_slug_history" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "contentTypeId", + "entityType": "columns", + "schema": "public", + "table": "core_content_slug_history" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "core_content_slug_history" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "languageId", + "entityType": "columns", + "schema": "public", + "table": "core_content_slug_history" + }, + { + "type": "varchar(160)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "slug", + "entityType": "columns", + "schema": "public", + "table": "core_content_slug_history" + }, + { + "type": "varchar(512)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "path", + "entityType": "columns", + "schema": "public", + "table": "core_content_slug_history" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_content_slug_history" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "retiredAt", + "entityType": "columns", + "schema": "public", + "table": "core_content_slug_history" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_cron" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "core_cron" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "description", + "entityType": "columns", + "schema": "public", + "table": "core_cron" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "lastRun", + "entityType": "columns", + "schema": "public", + "table": "core_cron" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_cron" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pluginId", + "entityType": "columns", + "schema": "public", + "table": "core_cron" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "module", + "entityType": "columns", + "schema": "public", + "table": "core_cron" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "nextRun", + "entityType": "columns", + "schema": "public", + "table": "core_cron" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "schedule", + "entityType": "columns", + "schema": "public", + "table": "core_cron" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_admin_dashboard" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_admin_dashboard" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'[]'", + "generated": null, + "identity": null, + "name": "widgets", + "entityType": "columns", + "schema": "public", + "table": "core_admin_dashboard" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_admin_dashboard" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_admin_dashboard" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_files" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "core_files" + }, + { + "type": "varchar(512)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "key", + "entityType": "columns", + "schema": "public", + "table": "core_files" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "folder", + "entityType": "columns", + "schema": "public", + "table": "core_files" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "mimeType", + "entityType": "columns", + "schema": "public", + "table": "core_files" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "size", + "entityType": "columns", + "schema": "public", + "table": "core_files" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_files" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pluginId", + "entityType": "columns", + "schema": "public", + "table": "core_files" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "metadata", + "entityType": "columns", + "schema": "public", + "table": "core_files" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "fingerprint", + "entityType": "columns", + "schema": "public", + "table": "core_files" + }, + { + "type": "varchar(16)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'automatic'", + "generated": null, + "identity": null, + "name": "altPolicy", + "entityType": "columns", + "schema": "public", + "table": "core_files" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_files" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_files_alt" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "fileId", + "entityType": "columns", + "schema": "public", + "table": "core_files_alt" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "languageCode", + "entityType": "columns", + "schema": "public", + "table": "core_files_alt" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "text", + "entityType": "columns", + "schema": "public", + "table": "core_files_alt" + }, + { + "type": "varchar(10)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "origin", + "entityType": "columns", + "schema": "public", + "table": "core_files_alt" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "fileFingerprint", + "entityType": "columns", + "schema": "public", + "table": "core_files_alt" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "runId", + "entityType": "columns", + "schema": "public", + "table": "core_files_alt" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updatedById", + "entityType": "columns", + "schema": "public", + "table": "core_files_alt" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_files_alt" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_files_alt_analysis" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "fileId", + "entityType": "columns", + "schema": "public", + "table": "core_files_alt_analysis" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "fileFingerprint", + "entityType": "columns", + "schema": "public", + "table": "core_files_alt_analysis" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "description", + "entityType": "columns", + "schema": "public", + "table": "core_files_alt_analysis" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "runId", + "entityType": "columns", + "schema": "public", + "table": "core_files_alt_analysis" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_files_alt_analysis" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "fileId", + "entityType": "columns", + "schema": "public", + "table": "core_files_alt_state" + }, + { + "type": "varchar(20)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "core_files_alt_state" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "lastError", + "entityType": "columns", + "schema": "public", + "table": "core_files_alt_state" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_files_alt_state" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_languages" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "code", + "entityType": "columns", + "schema": "public", + "table": "core_languages" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "core_languages" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'UTC'", + "generated": null, + "identity": null, + "name": "timezone", + "entityType": "columns", + "schema": "public", + "table": "core_languages" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "protected", + "entityType": "columns", + "schema": "public", + "table": "core_languages" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "default", + "entityType": "columns", + "schema": "public", + "table": "core_languages" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_languages" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_languages" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "time24", + "entityType": "columns", + "schema": "public", + "table": "core_languages" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_languages_words" + }, + { + "type": "varchar", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "languageCode", + "entityType": "columns", + "schema": "public", + "table": "core_languages_words" + }, + { + "type": "varchar(50)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pluginCode", + "entityType": "columns", + "schema": "public", + "table": "core_languages_words" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "core_languages_words" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "value", + "entityType": "columns", + "schema": "public", + "table": "core_languages_words" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "tableName", + "entityType": "columns", + "schema": "public", + "table": "core_languages_words" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "variable", + "entityType": "columns", + "schema": "public", + "table": "core_languages_words" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_logs" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pluginId", + "entityType": "columns", + "schema": "public", + "table": "core_logs" + }, + { + "type": "varchar(10)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "type", + "entityType": "columns", + "schema": "public", + "table": "core_logs" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "content", + "entityType": "columns", + "schema": "public", + "table": "core_logs" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_logs" + }, + { + "type": "varchar(45)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "ipAddress", + "entityType": "columns", + "schema": "public", + "table": "core_logs" + }, + { + "type": "varchar(10)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'GET'", + "generated": null, + "identity": null, + "name": "method", + "entityType": "columns", + "schema": "public", + "table": "core_logs" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'localhost'", + "generated": null, + "identity": null, + "name": "path", + "entityType": "columns", + "schema": "public", + "table": "core_logs" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userAgent", + "entityType": "columns", + "schema": "public", + "table": "core_logs" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "500", + "generated": null, + "identity": null, + "name": "statusCode", + "entityType": "columns", + "schema": "public", + "table": "core_logs" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_logs" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_moderation_report_events" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "reportId", + "entityType": "columns", + "schema": "public", + "table": "core_moderation_report_events" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "type", + "entityType": "columns", + "schema": "public", + "table": "core_moderation_report_events" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "actorId", + "entityType": "columns", + "schema": "public", + "table": "core_moderation_report_events" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "data", + "entityType": "columns", + "schema": "public", + "table": "core_moderation_report_events" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_moderation_report_events" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_moderation_report_reasons" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "code", + "entityType": "columns", + "schema": "public", + "table": "core_moderation_report_reasons" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "requiresDetails", + "entityType": "columns", + "schema": "public", + "table": "core_moderation_report_reasons" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "position", + "entityType": "columns", + "schema": "public", + "table": "core_moderation_report_reasons" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_moderation_report_reasons" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_moderation_report_reasons" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_moderation_reports" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pluginId", + "entityType": "columns", + "schema": "public", + "table": "core_moderation_reports" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "targetType", + "entityType": "columns", + "schema": "public", + "table": "core_moderation_reports" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "targetId", + "entityType": "columns", + "schema": "public", + "table": "core_moderation_reports" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "reporterId", + "entityType": "columns", + "schema": "public", + "table": "core_moderation_reports" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "reason", + "entityType": "columns", + "schema": "public", + "table": "core_moderation_reports" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "description", + "entityType": "columns", + "schema": "public", + "table": "core_moderation_reports" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "snapshot", + "entityType": "columns", + "schema": "public", + "table": "core_moderation_reports" + }, + { + "type": "varchar(500)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "targetTitle", + "entityType": "columns", + "schema": "public", + "table": "core_moderation_reports" + }, + { + "type": "varchar(20)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'new'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "core_moderation_reports" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "assigneeId", + "entityType": "columns", + "schema": "public", + "table": "core_moderation_reports" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "resolution", + "entityType": "columns", + "schema": "public", + "table": "core_moderation_reports" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "resolutionNote", + "entityType": "columns", + "schema": "public", + "table": "core_moderation_reports" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "resolvedAt", + "entityType": "columns", + "schema": "public", + "table": "core_moderation_reports" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "resolvedById", + "entityType": "columns", + "schema": "public", + "table": "core_moderation_reports" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "scopeFamily", + "entityType": "columns", + "schema": "public", + "table": "core_moderation_reports" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "scopeId", + "entityType": "columns", + "schema": "public", + "table": "core_moderation_reports" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "openKey", + "entityType": "columns", + "schema": "public", + "table": "core_moderation_reports" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "version", + "entityType": "columns", + "schema": "public", + "table": "core_moderation_reports" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_moderation_reports" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_moderation_reports" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_moderators_permissions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "roleId", + "entityType": "columns", + "schema": "public", + "table": "core_moderators_permissions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_moderators_permissions" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_moderators_permissions" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_moderators_permissions" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "protected", + "entityType": "columns", + "schema": "public", + "table": "core_moderators_permissions" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "unrestricted", + "entityType": "columns", + "schema": "public", + "table": "core_moderators_permissions" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'[]'", + "generated": null, + "identity": null, + "name": "permissions", + "entityType": "columns", + "schema": "public", + "table": "core_moderators_permissions" + }, + { + "type": "bigserial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "varchar(20)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "channel", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "varchar(20)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "mode", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "bigint", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "eventId", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "idempotencyKey", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "varchar(20)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'pending'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "attempts", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "5", + "generated": null, + "identity": null, + "name": "maxAttempts", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "availableAt", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "periodStart", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "periodEnd", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "itemCount", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "providerMessageId", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "varchar(500)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "lastError", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "varchar(50)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "skipReason", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "sentAt", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "bigserial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_notification_events" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pluginId", + "entityType": "columns", + "schema": "public", + "table": "core_notification_events" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "type", + "entityType": "columns", + "schema": "public", + "table": "core_notification_events" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "schemaVersion", + "entityType": "columns", + "schema": "public", + "table": "core_notification_events" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "idempotencyKey", + "entityType": "columns", + "schema": "public", + "table": "core_notification_events" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "actorId", + "entityType": "columns", + "schema": "public", + "table": "core_notification_events" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "subjectType", + "entityType": "columns", + "schema": "public", + "table": "core_notification_events" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "subjectId", + "entityType": "columns", + "schema": "public", + "table": "core_notification_events" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "groupKey", + "entityType": "columns", + "schema": "public", + "table": "core_notification_events" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "data", + "entityType": "columns", + "schema": "public", + "table": "core_notification_events" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 1, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "recipientIds", + "entityType": "columns", + "schema": "public", + "table": "core_notification_events" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "allowSelf", + "entityType": "columns", + "schema": "public", + "table": "core_notification_events" + }, + { + "type": "varchar(20)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'pending'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "core_notification_events" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "recipientCursor", + "entityType": "columns", + "schema": "public", + "table": "core_notification_events" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "deliveredCount", + "entityType": "columns", + "schema": "public", + "table": "core_notification_events" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "lastError", + "entityType": "columns", + "schema": "public", + "table": "core_notification_events" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_notification_events" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "completedAt", + "entityType": "columns", + "schema": "public", + "table": "core_notification_events" + }, + { + "type": "bigint", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "eventId", + "entityType": "columns", + "schema": "public", + "table": "core_notification_receipts" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_notification_receipts" + }, + { + "type": "bigint", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "notificationId", + "entityType": "columns", + "schema": "public", + "table": "core_notification_receipts" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "seq", + "entityType": "columns", + "schema": "public", + "table": "core_notification_receipts" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "emailPending", + "entityType": "columns", + "schema": "public", + "table": "core_notification_receipts" + }, + { + "type": "bigint", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "emailDeliveryId", + "entityType": "columns", + "schema": "public", + "table": "core_notification_receipts" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_notification_receipts" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "key", + "entityType": "columns", + "schema": "public", + "table": "core_notification_settings" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "value", + "entityType": "columns", + "schema": "public", + "table": "core_notification_settings" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_notification_settings" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_notification_user_state" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "unreadCount", + "entityType": "columns", + "schema": "public", + "table": "core_notification_user_state" + }, + { + "type": "bigint", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "revision", + "entityType": "columns", + "schema": "public", + "table": "core_notification_user_state" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "preferences", + "entityType": "columns", + "schema": "public", + "table": "core_notification_user_state" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_notification_user_state" + }, + { + "type": "bigserial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_notifications" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_notifications" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pluginId", + "entityType": "columns", + "schema": "public", + "table": "core_notifications" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "type", + "entityType": "columns", + "schema": "public", + "table": "core_notifications" + }, + { + "type": "varchar(50)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "category", + "entityType": "columns", + "schema": "public", + "table": "core_notifications" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "groupKey", + "entityType": "columns", + "schema": "public", + "table": "core_notifications" + }, + { + "type": "bigint", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "groupBucket", + "entityType": "columns", + "schema": "public", + "table": "core_notifications" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "subjectType", + "entityType": "columns", + "schema": "public", + "table": "core_notifications" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "subjectId", + "entityType": "columns", + "schema": "public", + "table": "core_notifications" + }, + { + "type": "bigint", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "latestEventId", + "entityType": "columns", + "schema": "public", + "table": "core_notifications" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "eventCount", + "entityType": "columns", + "schema": "public", + "table": "core_notifications" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "activitySeq", + "entityType": "columns", + "schema": "public", + "table": "core_notifications" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "readSeq", + "entityType": "columns", + "schema": "public", + "table": "core_notifications" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "readAt", + "entityType": "columns", + "schema": "public", + "table": "core_notifications" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "archivedAt", + "entityType": "columns", + "schema": "public", + "table": "core_notifications" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_notifications" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "lastActivityAt", + "entityType": "columns", + "schema": "public", + "table": "core_notifications" + }, + { + "type": "varchar(120)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pageId", + "entityType": "columns", + "schema": "public", + "table": "core_page_layouts" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "zones", + "entityType": "columns", + "schema": "public", + "table": "core_page_layouts" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_page_layouts" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_page_layouts" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkey_challenges" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "tokenHash", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkey_challenges" + }, + { + "type": "varchar(16)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "ceremony", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkey_challenges" + }, + { + "type": "varchar(128)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "challenge", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkey_challenges" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkey_challenges" + }, + { + "type": "varchar(128)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "webauthnUserId", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkey_challenges" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkey_challenges" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "expiresAt", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkey_challenges" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "type": "varchar(1024)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "credentialId", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "publicKey", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "type": "bigint", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "counter", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "type": "varchar(128)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "webauthnUserId", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 1, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "transports", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "deviceType", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "backedUp", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "type": "varchar(36)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "aaguid", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "lastUsedAt", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pluginId", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'default'", + "generated": null, + "identity": null, + "name": "queue", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "varchar(20)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'pending'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "payload", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "priority", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "attempts", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "3", + "generated": null, + "identity": null, + "name": "maxAttempts", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "availableAt", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "reservedAt", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "lastError", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "completedAt", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "dedupeKey", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "protected", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "default", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "root", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "guest", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "varchar(50)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "color", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "prefix", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "allowUploadFiles", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "totalMaxStorage", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "maxStorageForSubmit", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "true", + "generated": null, + "identity": null, + "name": "allowUploadAvatar", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "2048", + "generated": null, + "identity": null, + "name": "maxAvatarSize", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "true", + "generated": null, + "identity": null, + "name": "allowUploadCover", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "5120", + "generated": null, + "identity": null, + "name": "maxCoverSize", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "true", + "generated": null, + "identity": null, + "name": "allowEditPersonalInfo", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pluginId", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemType", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "''", + "generated": null, + "identity": null, + "name": "languageCode", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 1, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "authorIds", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "''", + "generated": null, + "identity": null, + "name": "title", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "''", + "generated": null, + "identity": null, + "name": "content", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "tsvector", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": { + "as": "setweight(to_tsvector(CASE lower(split_part(\"core_search_index\".\"languageCode\", '-', 1)) WHEN 'da' THEN 'danish'::regconfig WHEN 'de' THEN 'german'::regconfig WHEN 'en' THEN 'english'::regconfig WHEN 'es' THEN 'spanish'::regconfig WHEN 'fi' THEN 'finnish'::regconfig WHEN 'fr' THEN 'french'::regconfig WHEN 'hu' THEN 'hungarian'::regconfig WHEN 'it' THEN 'italian'::regconfig WHEN 'nl' THEN 'dutch'::regconfig WHEN 'no' THEN 'norwegian'::regconfig WHEN 'pl' THEN 'polish'::regconfig WHEN 'pt' THEN 'portuguese'::regconfig WHEN 'ro' THEN 'romanian'::regconfig WHEN 'ru' THEN 'russian'::regconfig WHEN 'sv' THEN 'swedish'::regconfig WHEN 'tr' THEN 'turkish'::regconfig ELSE 'simple'::regconfig END, coalesce(\"core_search_index\".\"title\", '')), 'A') || setweight(to_tsvector(CASE lower(split_part(\"core_search_index\".\"languageCode\", '-', 1)) WHEN 'da' THEN 'danish'::regconfig WHEN 'de' THEN 'german'::regconfig WHEN 'en' THEN 'english'::regconfig WHEN 'es' THEN 'spanish'::regconfig WHEN 'fi' THEN 'finnish'::regconfig WHEN 'fr' THEN 'french'::regconfig WHEN 'hu' THEN 'hungarian'::regconfig WHEN 'it' THEN 'italian'::regconfig WHEN 'nl' THEN 'dutch'::regconfig WHEN 'no' THEN 'norwegian'::regconfig WHEN 'pl' THEN 'polish'::regconfig WHEN 'pt' THEN 'portuguese'::regconfig WHEN 'ro' THEN 'romanian'::regconfig WHEN 'ru' THEN 'russian'::regconfig WHEN 'sv' THEN 'swedish'::regconfig WHEN 'tr' THEN 'turkish'::regconfig ELSE 'simple'::regconfig END, coalesce(\"core_search_index\".\"content\", '')), 'B')", + "type": "stored" + }, + "identity": null, + "name": "search_vector", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "containerType", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "containerId", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "url", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "true", + "generated": null, + "identity": null, + "name": "isPublic", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "isHidden", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "imageId", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "metadata", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "indexedAt", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemType", + "entityType": "columns", + "schema": "public", + "table": "core_search_popularity" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "core_search_popularity" + }, + { + "type": "double precision", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "logScore", + "entityType": "columns", + "schema": "public", + "table": "core_search_popularity" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "views", + "entityType": "columns", + "schema": "public", + "table": "core_search_popularity" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "clicks", + "entityType": "columns", + "schema": "public", + "table": "core_search_popularity" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "comments", + "entityType": "columns", + "schema": "public", + "table": "core_search_popularity" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_search_popularity" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "core_secrets" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "value", + "entityType": "columns", + "schema": "public", + "table": "core_secrets" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_secrets" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_sessions" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "token", + "entityType": "columns", + "schema": "public", + "table": "core_sessions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_sessions" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_sessions" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "expiresAt", + "entityType": "columns", + "schema": "public", + "table": "core_sessions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "deviceId", + "entityType": "columns", + "schema": "public", + "table": "core_sessions" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_sessions_known_devices" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "publicId", + "entityType": "columns", + "schema": "public", + "table": "core_sessions_known_devices" + }, + { + "type": "varchar(40)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "ipAddress", + "entityType": "columns", + "schema": "public", + "table": "core_sessions_known_devices" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userAgent", + "entityType": "columns", + "schema": "public", + "table": "core_sessions_known_devices" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "lastSeen", + "entityType": "columns", + "schema": "public", + "table": "core_sessions_known_devices" + }, + { + "type": "varchar(50)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "core_update_lock" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "owner", + "entityType": "columns", + "schema": "public", + "table": "core_update_lock" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "expiresAt", + "entityType": "columns", + "schema": "public", + "table": "core_update_lock" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "lastManualAt", + "entityType": "columns", + "schema": "public", + "table": "core_update_lock" + }, + { + "type": "varchar(214)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "componentId", + "entityType": "columns", + "schema": "public", + "table": "core_update_results" + }, + { + "type": "varchar(2048)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "sourceUrl", + "entityType": "columns", + "schema": "public", + "table": "core_update_results" + }, + { + "type": "varchar(20)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "channel", + "entityType": "columns", + "schema": "public", + "table": "core_update_results" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "versions", + "entityType": "columns", + "schema": "public", + "table": "core_update_results" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "hasUnknownVersion", + "entityType": "columns", + "schema": "public", + "table": "core_update_results" + }, + { + "type": "varchar(256)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "coreVersion", + "entityType": "columns", + "schema": "public", + "table": "core_update_results" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "result", + "entityType": "columns", + "schema": "public", + "table": "core_update_results" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "lastAttemptAt", + "entityType": "columns", + "schema": "public", + "table": "core_update_results" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "lastSuccessAt", + "entityType": "columns", + "schema": "public", + "table": "core_update_results" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "lastError", + "entityType": "columns", + "schema": "public", + "table": "core_update_results" + }, + { + "type": "varchar(2048)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "url", + "entityType": "columns", + "schema": "public", + "table": "core_update_sources" + }, + { + "type": "varchar(512)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "etag", + "entityType": "columns", + "schema": "public", + "table": "core_update_sources" + }, + { + "type": "varchar(128)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "lastModified", + "entityType": "columns", + "schema": "public", + "table": "core_update_sources" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "document", + "entityType": "columns", + "schema": "public", + "table": "core_update_sources" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "lastAttemptAt", + "entityType": "columns", + "schema": "public", + "table": "core_update_sources" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "lastSuccessAt", + "entityType": "columns", + "schema": "public", + "table": "core_update_sources" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "lastError", + "entityType": "columns", + "schema": "public", + "table": "core_update_sources" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "nameCode", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "email", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "varchar(128)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "firstName", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "varchar(128)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "lastName", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "phone", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "headline", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "showRealName", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "varchar", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "password", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "newsletter", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "varchar(6)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "avatarColor", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "emailVerified", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "roleId", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "birthday", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "timeZone", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "varchar(40)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "ipAddress", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'en'", + "generated": null, + "identity": null, + "name": "language", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "avatarId", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "coverId", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_users_confirm_emails" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_users_confirm_emails" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "token", + "entityType": "columns", + "schema": "public", + "table": "core_users_confirm_emails" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_users_confirm_emails" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "expiresAt", + "entityType": "columns", + "schema": "public", + "table": "core_users_confirm_emails" + }, + { + "type": "varchar(40)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "ipAddress", + "entityType": "columns", + "schema": "public", + "table": "core_users_confirm_emails" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_users_forgot_password" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_users_forgot_password" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "token", + "entityType": "columns", + "schema": "public", + "table": "core_users_forgot_password" + }, + { + "type": "varchar(40)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "ipAddress", + "entityType": "columns", + "schema": "public", + "table": "core_users_forgot_password" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_users_forgot_password" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "expiresAt", + "entityType": "columns", + "schema": "public", + "table": "core_users_forgot_password" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_users_secondary_roles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "roleId", + "entityType": "columns", + "schema": "public", + "table": "core_users_secondary_roles" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_users_secondary_roles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "providerId", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "providerAccountId", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "providerEmail", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "providerUsername", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "syncOnSignIn", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso" + }, + { + "type": "varchar(2048)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "avatarSourceUrl", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "avatarSha256", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "avatarFileId", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso_operations" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "tokenHash", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso_operations" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso_operations" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "providerId", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso_operations" + }, + { + "type": "varchar(16)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "intent", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso_operations" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 1, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "fields", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso_operations" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "providerAccountId", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso_operations" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "preview", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso_operations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso_operations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "expiresAt", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso_operations" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso_profile_sources" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "field", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso_profile_sources" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "providerId", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso_profile_sources" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso_profile_sources" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "blog_categories" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "blog_categories" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "blog_categories" + }, + { + "type": "varchar(50)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "color", + "entityType": "columns", + "schema": "public", + "table": "blog_categories" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "blog_categories_translations" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "languageId", + "entityType": "columns", + "schema": "public", + "table": "blog_categories_translations" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "version", + "entityType": "columns", + "schema": "public", + "table": "blog_categories_translations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "blog_categories_translations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "blog_categories_translations" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "blog_categories_translations" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "friendlyUrl", + "entityType": "columns", + "schema": "public", + "table": "blog_categories_translations" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "blog_posts" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "blog_posts" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "blog_posts" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "publishedAt", + "entityType": "columns", + "schema": "public", + "table": "blog_posts" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'draft'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "blog_posts" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "version", + "entityType": "columns", + "schema": "public", + "table": "blog_posts" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "hiddenAt", + "entityType": "columns", + "schema": "public", + "table": "blog_posts" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "hiddenBy", + "entityType": "columns", + "schema": "public", + "table": "blog_posts" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "coverImage", + "entityType": "columns", + "schema": "public", + "table": "blog_posts" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_author_id" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "relatedItemId", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_author_id" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "position", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_author_id" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_author_id" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_category_id" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "relatedItemId", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_category_id" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "position", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_category_id" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_category_id" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_translations" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "languageId", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_translations" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "version", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_translations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_translations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_translations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "publishedAt", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_translations" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'draft'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_translations" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "title", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_translations" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "friendlyUrl", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_translations" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "content", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_translations" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "coverImageAlt", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_translations" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "forum_comments" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "forum_comments" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "forum_comments" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "publishedAt", + "entityType": "columns", + "schema": "public", + "table": "forum_comments" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'draft'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "forum_comments" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "version", + "entityType": "columns", + "schema": "public", + "table": "forum_comments" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "hiddenAt", + "entityType": "columns", + "schema": "public", + "table": "forum_comments" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "hiddenBy", + "entityType": "columns", + "schema": "public", + "table": "forum_comments" + }, + { + "type": "varchar(160)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "slug", + "entityType": "columns", + "schema": "public", + "table": "forum_comments" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "body", + "entityType": "columns", + "schema": "public", + "table": "forum_comments" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "author", + "entityType": "columns", + "schema": "public", + "table": "forum_comments" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "topic", + "entityType": "columns", + "schema": "public", + "table": "forum_comments" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "parent", + "entityType": "columns", + "schema": "public", + "table": "forum_comments" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "forum_topics" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "forum_topics" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "forum_topics" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "publishedAt", + "entityType": "columns", + "schema": "public", + "table": "forum_topics" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'draft'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "forum_topics" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "version", + "entityType": "columns", + "schema": "public", + "table": "forum_topics" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "hiddenAt", + "entityType": "columns", + "schema": "public", + "table": "forum_topics" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "hiddenBy", + "entityType": "columns", + "schema": "public", + "table": "forum_topics" + }, + { + "type": "varchar(200)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "title", + "entityType": "columns", + "schema": "public", + "table": "forum_topics" + }, + { + "type": "varchar(160)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "slug", + "entityType": "columns", + "schema": "public", + "table": "forum_topics" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "body", + "entityType": "columns", + "schema": "public", + "table": "forum_topics" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "author", + "entityType": "columns", + "schema": "public", + "table": "forum_topics" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "forum", + "entityType": "columns", + "schema": "public", + "table": "forum_topics" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "newsroom_articles" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "newsroom_articles" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "newsroom_articles" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "publishedAt", + "entityType": "columns", + "schema": "public", + "table": "newsroom_articles" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'draft'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "newsroom_articles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "version", + "entityType": "columns", + "schema": "public", + "table": "newsroom_articles" + }, + { + "type": "varchar(200)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "title", + "entityType": "columns", + "schema": "public", + "table": "newsroom_articles" + }, + { + "type": "varchar(160)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "slug", + "entityType": "columns", + "schema": "public", + "table": "newsroom_articles" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "body", + "entityType": "columns", + "schema": "public", + "table": "newsroom_articles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "author", + "entityType": "columns", + "schema": "public", + "table": "newsroom_articles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "category", + "entityType": "columns", + "schema": "public", + "table": "newsroom_articles" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "newsroom_bulletins" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "newsroom_bulletins" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "newsroom_bulletins" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "publishedAt", + "entityType": "columns", + "schema": "public", + "table": "newsroom_bulletins" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'draft'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "newsroom_bulletins" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "version", + "entityType": "columns", + "schema": "public", + "table": "newsroom_bulletins" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "featured", + "entityType": "columns", + "schema": "public", + "table": "newsroom_bulletins" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "newsroom_bulletins_translations" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "languageId", + "entityType": "columns", + "schema": "public", + "table": "newsroom_bulletins_translations" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "version", + "entityType": "columns", + "schema": "public", + "table": "newsroom_bulletins_translations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "newsroom_bulletins_translations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "newsroom_bulletins_translations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "publishedAt", + "entityType": "columns", + "schema": "public", + "table": "newsroom_bulletins_translations" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'draft'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "newsroom_bulletins_translations" + }, + { + "type": "varchar(200)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "title", + "entityType": "columns", + "schema": "public", + "table": "newsroom_bulletins_translations" + }, + { + "type": "varchar(160)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "slug", + "entityType": "columns", + "schema": "public", + "table": "newsroom_bulletins_translations" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "body", + "entityType": "columns", + "schema": "public", + "table": "newsroom_bulletins_translations" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "newsroom_categories" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "newsroom_categories" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "newsroom_categories" + }, + { + "type": "varchar(80)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "newsroom_categories" + }, + { + "type": "varchar(160)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "slug", + "entityType": "columns", + "schema": "public", + "table": "newsroom_categories" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "newsroom_comments" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "newsroom_comments" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "newsroom_comments" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "publishedAt", + "entityType": "columns", + "schema": "public", + "table": "newsroom_comments" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'draft'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "newsroom_comments" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "hiddenAt", + "entityType": "columns", + "schema": "public", + "table": "newsroom_comments" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "hiddenBy", + "entityType": "columns", + "schema": "public", + "table": "newsroom_comments" + }, + { + "type": "varchar(160)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "slug", + "entityType": "columns", + "schema": "public", + "table": "newsroom_comments" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "body", + "entityType": "columns", + "schema": "public", + "table": "newsroom_comments" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "author", + "entityType": "columns", + "schema": "public", + "table": "newsroom_comments" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "article", + "entityType": "columns", + "schema": "public", + "table": "newsroom_comments" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "parent", + "entityType": "columns", + "schema": "public", + "table": "newsroom_comments" + }, + { + "type": "bigint", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": { + "type": "byDefault", + "name": "newsroom_events_id_seq", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "9223372036854775807", + "cache": 1, + "cycle": false + }, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "newsroom_events" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "newsroom_events" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "newsroom_events" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "publishedAt", + "entityType": "columns", + "schema": "public", + "table": "newsroom_events" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'draft'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "newsroom_events" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "version", + "entityType": "columns", + "schema": "public", + "table": "newsroom_events" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "hiddenAt", + "entityType": "columns", + "schema": "public", + "table": "newsroom_events" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "hiddenBy", + "entityType": "columns", + "schema": "public", + "table": "newsroom_events" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "primaryTag", + "entityType": "columns", + "schema": "public", + "table": "newsroom_events" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "category", + "entityType": "columns", + "schema": "public", + "table": "newsroom_events" + }, + { + "type": "bigint", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "newsroom_events_gallery" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "relatedItemId", + "entityType": "columns", + "schema": "public", + "table": "newsroom_events_gallery" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "position", + "entityType": "columns", + "schema": "public", + "table": "newsroom_events_gallery" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "newsroom_events_gallery" + }, + { + "type": "bigint", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "newsroom_events_related_events" + }, + { + "type": "bigint", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "relatedItemId", + "entityType": "columns", + "schema": "public", + "table": "newsroom_events_related_events" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "position", + "entityType": "columns", + "schema": "public", + "table": "newsroom_events_related_events" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "newsroom_events_related_events" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "newsroom_events_sessions" + }, + { + "type": "bigint", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "newsroom_events_sessions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "position", + "entityType": "columns", + "schema": "public", + "table": "newsroom_events_sessions" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "newsroom_events_sessions" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "newsroom_events_sessions" + }, + { + "type": "varchar(200)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "title", + "entityType": "columns", + "schema": "public", + "table": "newsroom_events_sessions" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "startsAt", + "entityType": "columns", + "schema": "public", + "table": "newsroom_events_sessions" + }, + { + "type": "bigint", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "newsroom_events_tags" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "relatedItemId", + "entityType": "columns", + "schema": "public", + "table": "newsroom_events_tags" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "position", + "entityType": "columns", + "schema": "public", + "table": "newsroom_events_tags" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "newsroom_events_tags" + }, + { + "type": "bigint", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "newsroom_events_translations" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "languageId", + "entityType": "columns", + "schema": "public", + "table": "newsroom_events_translations" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "version", + "entityType": "columns", + "schema": "public", + "table": "newsroom_events_translations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "newsroom_events_translations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "newsroom_events_translations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "publishedAt", + "entityType": "columns", + "schema": "public", + "table": "newsroom_events_translations" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'draft'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "newsroom_events_translations" + }, + { + "type": "varchar(200)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "title", + "entityType": "columns", + "schema": "public", + "table": "newsroom_events_translations" + }, + { + "type": "varchar(160)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "slug", + "entityType": "columns", + "schema": "public", + "table": "newsroom_events_translations" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "body", + "entityType": "columns", + "schema": "public", + "table": "newsroom_events_translations" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "newsroom_explainers" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "newsroom_explainers" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "newsroom_explainers" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "publishedAt", + "entityType": "columns", + "schema": "public", + "table": "newsroom_explainers" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'draft'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "newsroom_explainers" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "version", + "entityType": "columns", + "schema": "public", + "table": "newsroom_explainers" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "true", + "generated": null, + "identity": null, + "name": "syndicationIndexable", + "entityType": "columns", + "schema": "public", + "table": "newsroom_explainers" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "syndicationNoIndex", + "entityType": "columns", + "schema": "public", + "table": "newsroom_explainers" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "5", + "generated": null, + "identity": null, + "name": "syndicationPriority", + "entityType": "columns", + "schema": "public", + "table": "newsroom_explainers" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "newsroom_explainers_categories" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "relatedItemId", + "entityType": "columns", + "schema": "public", + "table": "newsroom_explainers_categories" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "position", + "entityType": "columns", + "schema": "public", + "table": "newsroom_explainers_categories" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "newsroom_explainers_categories" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "newsroom_explainers_faq" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "newsroom_explainers_faq" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "position", + "entityType": "columns", + "schema": "public", + "table": "newsroom_explainers_faq" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "newsroom_explainers_faq" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "newsroom_explainers_faq" + }, + { + "type": "varchar(200)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "question", + "entityType": "columns", + "schema": "public", + "table": "newsroom_explainers_faq" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "answer", + "entityType": "columns", + "schema": "public", + "table": "newsroom_explainers_faq" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "newsroom_explainers_related_explainers" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "relatedItemId", + "entityType": "columns", + "schema": "public", + "table": "newsroom_explainers_related_explainers" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "position", + "entityType": "columns", + "schema": "public", + "table": "newsroom_explainers_related_explainers" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "newsroom_explainers_related_explainers" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "newsroom_explainers_tags" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "relatedItemId", + "entityType": "columns", + "schema": "public", + "table": "newsroom_explainers_tags" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "position", + "entityType": "columns", + "schema": "public", + "table": "newsroom_explainers_tags" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "newsroom_explainers_tags" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "newsroom_explainers_translations" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "languageId", + "entityType": "columns", + "schema": "public", + "table": "newsroom_explainers_translations" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "version", + "entityType": "columns", + "schema": "public", + "table": "newsroom_explainers_translations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "newsroom_explainers_translations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "newsroom_explainers_translations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "publishedAt", + "entityType": "columns", + "schema": "public", + "table": "newsroom_explainers_translations" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'draft'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "newsroom_explainers_translations" + }, + { + "type": "varchar(200)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "title", + "entityType": "columns", + "schema": "public", + "table": "newsroom_explainers_translations" + }, + { + "type": "varchar(160)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "slug", + "entityType": "columns", + "schema": "public", + "table": "newsroom_explainers_translations" + }, + { + "type": "varchar(200)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "seoTitle", + "entityType": "columns", + "schema": "public", + "table": "newsroom_explainers_translations" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "seoDescription", + "entityType": "columns", + "schema": "public", + "table": "newsroom_explainers_translations" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "newsroom_letters" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "newsroom_letters" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "newsroom_letters" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "publishedAt", + "entityType": "columns", + "schema": "public", + "table": "newsroom_letters" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'draft'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "newsroom_letters" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "version", + "entityType": "columns", + "schema": "public", + "table": "newsroom_letters" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "hiddenAt", + "entityType": "columns", + "schema": "public", + "table": "newsroom_letters" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "hiddenBy", + "entityType": "columns", + "schema": "public", + "table": "newsroom_letters" + }, + { + "type": "varchar(200)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "title", + "entityType": "columns", + "schema": "public", + "table": "newsroom_letters" + }, + { + "type": "varchar(160)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "slug", + "entityType": "columns", + "schema": "public", + "table": "newsroom_letters" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "body", + "entityType": "columns", + "schema": "public", + "table": "newsroom_letters" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "author", + "entityType": "columns", + "schema": "public", + "table": "newsroom_letters" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "desk", + "entityType": "columns", + "schema": "public", + "table": "newsroom_letters" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "newsroom_pages" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "newsroom_pages" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "newsroom_pages" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "publishedAt", + "entityType": "columns", + "schema": "public", + "table": "newsroom_pages" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'draft'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "newsroom_pages" + }, + { + "type": "varchar(200)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "title", + "entityType": "columns", + "schema": "public", + "table": "newsroom_pages" + }, + { + "type": "varchar(160)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "slug", + "entityType": "columns", + "schema": "public", + "table": "newsroom_pages" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'[]'", + "generated": null, + "identity": null, + "name": "content", + "entityType": "columns", + "schema": "public", + "table": "newsroom_pages" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "newsroom_stories" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "newsroom_stories" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "newsroom_stories" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "publishedAt", + "entityType": "columns", + "schema": "public", + "table": "newsroom_stories" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'draft'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "newsroom_stories" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "version", + "entityType": "columns", + "schema": "public", + "table": "newsroom_stories" + }, + { + "type": "varchar(200)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "title", + "entityType": "columns", + "schema": "public", + "table": "newsroom_stories" + }, + { + "type": "varchar(160)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "slug", + "entityType": "columns", + "schema": "public", + "table": "newsroom_stories" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "code", + "entityType": "columns", + "schema": "public", + "table": "newsroom_stories" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "excerpt", + "entityType": "columns", + "schema": "public", + "table": "newsroom_stories" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "views", + "entityType": "columns", + "schema": "public", + "table": "newsroom_stories" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "featured", + "entityType": "columns", + "schema": "public", + "table": "newsroom_stories" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "noIndex", + "entityType": "columns", + "schema": "public", + "table": "newsroom_stories" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "author", + "entityType": "columns", + "schema": "public", + "table": "newsroom_stories" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "animation", + "entityType": "columns", + "schema": "public", + "table": "newsroom_stories" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "category", + "entityType": "columns", + "schema": "public", + "table": "newsroom_stories" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "newsroom_stories_gallery" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "relatedItemId", + "entityType": "columns", + "schema": "public", + "table": "newsroom_stories_gallery" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "position", + "entityType": "columns", + "schema": "public", + "table": "newsroom_stories_gallery" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "newsroom_stories_gallery" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "gen_random_uuid()", + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "newsroom_tags" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "newsroom_tags" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "newsroom_tags" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "publishedAt", + "entityType": "columns", + "schema": "public", + "table": "newsroom_tags" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'draft'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "newsroom_tags" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "version", + "entityType": "columns", + "schema": "public", + "table": "newsroom_tags" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "hiddenAt", + "entityType": "columns", + "schema": "public", + "table": "newsroom_tags" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "hiddenBy", + "entityType": "columns", + "schema": "public", + "table": "newsroom_tags" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "category", + "entityType": "columns", + "schema": "public", + "table": "newsroom_tags" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "newsroom_tags_related_tags" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "relatedItemId", + "entityType": "columns", + "schema": "public", + "table": "newsroom_tags_related_tags" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "position", + "entityType": "columns", + "schema": "public", + "table": "newsroom_tags_related_tags" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "newsroom_tags_related_tags" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "newsroom_tags_translations" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "languageId", + "entityType": "columns", + "schema": "public", + "table": "newsroom_tags_translations" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "version", + "entityType": "columns", + "schema": "public", + "table": "newsroom_tags_translations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "newsroom_tags_translations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "newsroom_tags_translations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "publishedAt", + "entityType": "columns", + "schema": "public", + "table": "newsroom_tags_translations" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'draft'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "newsroom_tags_translations" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "newsroom_tags_translations" + }, + { + "type": "varchar(160)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "slug", + "entityType": "columns", + "schema": "public", + "table": "newsroom_tags_translations" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "roleId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_admin_permissions_role_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_admin_permissions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_admin_permissions_user_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_admin_permissions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updatedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_admin_permissions_updated_at_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_admin_permissions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_admin_sessions_user_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_admin_sessions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "deviceId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_admin_sessions_device_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_admin_sessions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "expiresAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_admin_sessions_expires_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_admin_sessions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "scopeKey", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "periodStart", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_ai_budget_periods_scope_unique", + "entityType": "indexes", + "schema": "public", + "table": "core_ai_budget_periods" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "runId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_ai_calls_run_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_ai_calls" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "costSource", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "reconciledAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_ai_calls_reconcile_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_ai_calls" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "callId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_ai_cost_adjustments_call_unique", + "entityType": "indexes", + "schema": "public", + "table": "core_ai_cost_adjustments" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "runId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "budgetPeriodId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_ai_reservations_run_period_unique", + "entityType": "indexes", + "schema": "public", + "table": "core_ai_reservations" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_ai_reservations_status_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_ai_reservations" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_ai_runs_user_created_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_ai_runs" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_ai_runs_user_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_ai_runs" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_ai_runs_created_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_ai_runs" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "actionKey", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_ai_runs_action_created_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_ai_runs" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "leaseExpiresAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_ai_runs_status_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_ai_runs" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "actorType", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "idempotencyKey", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": "\"idempotencyKey\" IS NOT NULL", + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_ai_runs_idempotency_unique", + "entityType": "indexes", + "schema": "public", + "table": "core_ai_runs" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "contentTypeId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "locale", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "field", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_ai_translation_sources_unique", + "entityType": "indexes", + "schema": "public", + "table": "core_ai_translation_sources" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "definitionId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "slug", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_containers_definition_slug_key", + "entityType": "indexes", + "schema": "public", + "table": "core_content_containers" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "definitionId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "parentId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "position", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_containers_definition_parent_position_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_content_containers" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "parentId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_containers_parent_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_content_containers" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "contentTypeId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "field", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "language", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_documents_doc_unique", + "entityType": "indexes", + "schema": "public", + "table": "core_content_documents" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updatedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_documents_updated_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_content_documents" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updatedBy", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_documents_updated_by_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_content_documents" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "contentTypeId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "language", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_drafts_key_unique", + "entityType": "indexes", + "schema": "public", + "table": "core_content_drafts" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updatedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_drafts_updated_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_content_drafts" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updatedBy", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_drafts_updated_by_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_content_drafts" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "contentTypeId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "field", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "language", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_field_locks_key_unique", + "entityType": "indexes", + "schema": "public", + "table": "core_content_field_locks" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "expiresAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_field_locks_expires_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_content_field_locks" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_field_locks_user_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_content_field_locks" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "contentTypeId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": "\"status\" = 'pending'", + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_submissions_pending_key", + "entityType": "indexes", + "schema": "public", + "table": "core_content_submissions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "contentTypeId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "containerId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_submissions_queue_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_content_submissions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "contentTypeId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_submissions_item_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_content_submissions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "authorId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_submissions_author_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_content_submissions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "revisionId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "fileId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_file_refs_unique", + "entityType": "indexes", + "schema": "public", + "table": "core_content_file_refs" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "fileId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_file_refs_file_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_content_file_refs" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "contentTypeId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "version", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": "\"languageId\" IS NULL", + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_revisions_item_version_unique", + "entityType": "indexes", + "schema": "public", + "table": "core_content_revisions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "contentTypeId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "languageId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "version", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": "\"languageId\" IS NOT NULL", + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_revisions_translation_version_unique", + "entityType": "indexes", + "schema": "public", + "table": "core_content_revisions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "contentTypeId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "languageId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "version", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_revisions_language_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_content_revisions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "pluginId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_revisions_plugin_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_content_revisions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "actorUserId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_revisions_actor_user_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_content_revisions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "contentTypeId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "action", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": "status = 'pending'", + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_schedules_active_unique", + "entityType": "indexes", + "schema": "public", + "table": "core_content_schedules" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "scheduledFor", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_schedules_due_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_content_schedules" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "contentTypeId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_schedules_item_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_content_schedules" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "pluginId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_schedules_plugin_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_content_schedules" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdBy", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_schedules_created_by_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_content_schedules" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "contentTypeId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "slug", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": "\"languageId\" IS NULL", + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_slug_history_shared_unique", + "entityType": "indexes", + "schema": "public", + "table": "core_content_slug_history" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "contentTypeId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "languageId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "slug", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": "\"languageId\" IS NOT NULL", + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_slug_history_locale_unique", + "entityType": "indexes", + "schema": "public", + "table": "core_content_slug_history" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "contentTypeId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "languageId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_slug_history_item_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_content_slug_history" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "pluginId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_slug_history_plugin_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_content_slug_history" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "lastRun", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_cron_last_run_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_cron" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_admin_dashboard_user_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_admin_dashboard" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_files_user_created_at_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_files" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_files_created_at_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_files" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "fileId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "languageCode", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_files_alt_file_language_unique", + "entityType": "indexes", + "schema": "public", + "table": "core_files_alt" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "fileId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "fileFingerprint", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_files_alt_analysis_file_unique", + "entityType": "indexes", + "schema": "public", + "table": "core_files_alt_analysis" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": "status <> 'completed'", + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_files_alt_state_status_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_files_alt_state" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "name", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_languages_name_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_languages" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "languageCode", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_languages_words_lang_code_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_languages_words" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "tableName", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "pluginCode", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "variable", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_languages_words_lookup_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_languages_words" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_logs_created_at_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_logs" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_logs_user_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_logs" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "reportId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_moderation_report_events_report_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_moderation_report_events" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "position", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_moderation_report_reasons_position_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_moderation_report_reasons" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "pluginId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "targetType", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "targetId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_moderation_reports_target_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_moderation_reports" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_moderation_reports_status_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_moderation_reports" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_moderation_reports_created_at_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_moderation_reports" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "assigneeId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_moderation_reports_assignee_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_moderation_reports" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "reporterId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_moderation_reports_reporter_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_moderation_reports" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "scopeFamily", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "scopeId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_moderation_reports_scope_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_moderation_reports" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "roleId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_moderators_permissions_role_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_moderators_permissions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_moderators_permissions_user_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_moderators_permissions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updatedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_moderators_permissions_updated_at_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_moderators_permissions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "availableAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": "status = 'pending'", + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_notification_deliveries_claim_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_notification_deliveries_status_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_notification_deliveries_user_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "eventId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": "\"eventId\" IS NOT NULL", + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_notification_deliveries_event_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "pluginId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "type", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "idempotencyKey", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_notification_events_idempotency_unique", + "entityType": "indexes", + "schema": "public", + "table": "core_notification_events" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_notification_events_status_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_notification_events" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_notification_events_created_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_notification_events" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "notificationId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "seq", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_notification_receipts_notification_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_notification_receipts" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_notification_receipts_user_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_notification_receipts" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": "\"emailPending\" = true AND \"emailDeliveryId\" IS NULL", + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_notification_receipts_email_pending_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_notification_receipts" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "unreadCount", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_notification_user_state_unread_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_notification_user_state" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "pluginId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "type", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "groupKey", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "groupBucket", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_notifications_group_unique", + "entityType": "indexes", + "schema": "public", + "table": "core_notifications" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "lastActivityAt", + "isExpression": false, + "asc": false, + "nullsFirst": false, + "opclass": null + }, + { + "value": "id", + "isExpression": false, + "asc": false, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": "\"archivedAt\" IS NULL", + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_notifications_inbox_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_notifications" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "lastActivityAt", + "isExpression": false, + "asc": false, + "nullsFirst": false, + "opclass": null + }, + { + "value": "id", + "isExpression": false, + "asc": false, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": "\"archivedAt\" IS NULL AND \"readSeq\" < \"activitySeq\"", + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_notifications_unread_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_notifications" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "subjectType", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "subjectId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_notifications_subject_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_notifications" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "lastActivityAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_notifications_last_activity_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_notifications" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "latestEventId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_notifications_latest_event_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_notifications" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "expiresAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_users_passkey_challenges_expires_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_users_passkey_challenges" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_users_passkey_challenges_user_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_users_passkey_challenges" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_users_passkeys_user_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "webauthnUserId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_users_passkeys_webauthn_user_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "availableAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_queue_status_available_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_queue" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_queue_created_at_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_queue" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "completedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": "\"completedAt\" IS NOT NULL", + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_queue_completed_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_queue" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "pluginId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "dedupeKey", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": "\"dedupeKey\" IS NOT NULL AND status IN ('pending', 'processing')", + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_queue_dedupe_active_unique", + "entityType": "indexes", + "schema": "public", + "table": "core_queue" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updatedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_roles_updated_at_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_roles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "search_vector", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "gin", + "concurrently": false, + "name": "core_search_index_search_vector_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_search_index" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "to_tsvector('simple'::regconfig, \"title\")", + "isExpression": true, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "gin", + "concurrently": false, + "name": "core_search_index_title_vector_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_search_index" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_search_index_created_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_search_index" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "authorIds", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "gin", + "concurrently": false, + "name": "core_search_index_author_ids_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_search_index" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "itemType", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_search_index_item_type_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_search_index" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "languageCode", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_search_index_language_code_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_search_index" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "isPublic", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_search_index_is_public_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_search_index" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "imageId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_search_index_image_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_search_index" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "containerType", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "containerId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_search_index_container_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_search_index" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "logScore", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_search_popularity_log_score_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_search_popularity" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_sessions_user_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_sessions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "deviceId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_sessions_device_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_sessions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "expiresAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_sessions_expires_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_sessions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "ipAddress", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_sessions_known_devices_ip_address_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_sessions_known_devices" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "avatarId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_users_avatar_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_users" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "coverId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_users_cover_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_users" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_users_created_at_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_users" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "roleId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_users_role_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_users" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "roleId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_users_secondary_roles_role_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_users_secondary_roles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_users_sso_user_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_users_sso" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "providerId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_users_sso_operations_user_provider_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_users_sso_operations" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "expiresAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_users_sso_operations_expires_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_users_sso_operations" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_categories_created_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "blog_categories" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updatedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_categories_updated_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "blog_categories" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "languageId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_categories_translations_language_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "blog_categories_translations" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "languageId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "friendlyUrl", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_categories_translations_language_id_friendly_url_key", + "entityType": "indexes", + "schema": "public", + "table": "blog_categories_translations" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_posts_status_created_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "blog_posts" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "coverImage", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_posts_cover_image_idx", + "entityType": "indexes", + "schema": "public", + "table": "blog_posts" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_posts_created_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "blog_posts" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updatedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_posts_updated_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "blog_posts" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "publishedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_posts_status_published_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "blog_posts" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "hiddenBy", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_posts_hidden_by_idx", + "entityType": "indexes", + "schema": "public", + "table": "blog_posts" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "position", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_posts_author_id_position_key", + "entityType": "indexes", + "schema": "public", + "table": "blog_posts_author_id" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "relatedItemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_posts_author_id_related_item_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "blog_posts_author_id" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "position", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_posts_category_id_position_key", + "entityType": "indexes", + "schema": "public", + "table": "blog_posts_category_id" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "relatedItemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_posts_category_id_related_item_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "blog_posts_category_id" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "languageId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_posts_translations_language_id_status_idx", + "entityType": "indexes", + "schema": "public", + "table": "blog_posts_translations" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "languageId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "friendlyUrl", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_posts_translations_language_id_friendly_url_key", + "entityType": "indexes", + "schema": "public", + "table": "blog_posts_translations" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "topic", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "parent", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "forum_comments_topic_parent_created_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "forum_comments" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "slug", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "forum_comments_slug_key", + "entityType": "indexes", + "schema": "public", + "table": "forum_comments" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "author", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "forum_comments_author_idx", + "entityType": "indexes", + "schema": "public", + "table": "forum_comments" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "topic", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "forum_comments_topic_idx", + "entityType": "indexes", + "schema": "public", + "table": "forum_comments" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "parent", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "forum_comments_parent_idx", + "entityType": "indexes", + "schema": "public", + "table": "forum_comments" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "forum_comments_created_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "forum_comments" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updatedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "forum_comments_updated_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "forum_comments" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "publishedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "forum_comments_status_published_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "forum_comments" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "hiddenBy", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "forum_comments_hidden_by_idx", + "entityType": "indexes", + "schema": "public", + "table": "forum_comments" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "forum", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "publishedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "forum_topics_forum_published_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "forum_topics" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "slug", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "forum_topics_slug_key", + "entityType": "indexes", + "schema": "public", + "table": "forum_topics" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "author", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "forum_topics_author_idx", + "entityType": "indexes", + "schema": "public", + "table": "forum_topics" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "forum_topics_created_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "forum_topics" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updatedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "forum_topics_updated_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "forum_topics" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "publishedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "forum_topics_status_published_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "forum_topics" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "hiddenBy", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "forum_topics_hidden_by_idx", + "entityType": "indexes", + "schema": "public", + "table": "forum_topics" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "slug", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_articles_slug_key", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "author", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_articles_author_idx", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "category", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_articles_category_idx", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_articles_created_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updatedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_articles_updated_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "publishedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_articles_status_published_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_bulletins_created_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_bulletins" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updatedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_bulletins_updated_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_bulletins" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "publishedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_bulletins_status_published_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_bulletins" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "languageId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_bulletins_translations_language_id_status_idx", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_bulletins_translations" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "languageId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "slug", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_bulletins_translations_language_id_slug_key", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_bulletins_translations" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "slug", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_categories_slug_key", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_categories" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_categories_created_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_categories" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updatedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_categories_updated_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_categories" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "slug", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_comments_slug_key", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_comments" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "author", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_comments_author_idx", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_comments" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "article", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_comments_article_idx", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_comments" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "parent", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_comments_parent_idx", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_comments" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_comments_created_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_comments" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updatedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_comments_updated_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_comments" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "publishedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_comments_status_published_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_comments" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "hiddenBy", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_comments_hidden_by_idx", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_comments" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "primaryTag", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_events_primary_tag_idx", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_events" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "category", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_events_category_idx", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_events" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_events_created_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_events" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updatedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_events_updated_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_events" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "publishedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_events_status_published_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_events" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "hiddenBy", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_events_hidden_by_idx", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_events" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "position", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_events_gallery_position_key", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_events_gallery" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "relatedItemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_events_gallery_related_item_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_events_gallery" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "position", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_events_related_events_position_key", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_events_related_events" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "relatedItemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_events_related_events_related_item_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_events_related_events" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "position", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_events_sessions_position_key", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_events_sessions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "position", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_events_tags_position_key", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_events_tags" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "relatedItemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_events_tags_related_item_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_events_tags" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "languageId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_events_translations_language_id_status_idx", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_events_translations" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "languageId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "slug", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_events_translations_language_id_slug_key", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_events_translations" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "syndicationPriority", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_explainers_syndication_priority_idx", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_explainers" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_explainers_created_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_explainers" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updatedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_explainers_updated_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_explainers" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "publishedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_explainers_status_published_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_explainers" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "position", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_explainers_categories_position_key", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_explainers_categories" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "relatedItemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_explainers_categories_related_item_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_explainers_categories" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "position", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_explainers_faq_position_key", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_explainers_faq" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "position", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_explainers_related_explainers_position_key", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_explainers_related_explainers" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "relatedItemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_explainers_related_explainers_related_item_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_explainers_related_explainers" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "position", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_explainers_tags_position_key", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_explainers_tags" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "relatedItemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_explainers_tags_related_item_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_explainers_tags" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "languageId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_explainers_translations_language_id_status_idx", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_explainers_translations" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "languageId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "slug", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_explainers_translations_language_id_slug_key", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_explainers_translations" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "slug", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_letters_slug_key", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_letters" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "author", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_letters_author_idx", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_letters" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "desk", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "publishedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_letters_desk_published_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_letters" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_letters_created_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_letters" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updatedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_letters_updated_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_letters" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "publishedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_letters_status_published_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_letters" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "hiddenBy", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_letters_hidden_by_idx", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_letters" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "slug", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_pages_slug_key", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_pages" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_pages_created_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_pages" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updatedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_pages_updated_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_pages" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "publishedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_pages_status_published_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_pages" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_stories_status_created_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_stories" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "slug", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_stories_slug_key", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_stories" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "code", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_stories_code_key", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_stories" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "author", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_stories_author_idx", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_stories" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "animation", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_stories_animation_idx", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_stories" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "category", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_stories_category_idx", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_stories" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_stories_created_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_stories" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updatedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_stories_updated_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_stories" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "publishedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_stories_status_published_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_stories" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "position", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_stories_gallery_position_key", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_stories_gallery" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "relatedItemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_stories_gallery_related_item_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_stories_gallery" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "category", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_tags_category_idx", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_tags" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_tags_created_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_tags" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updatedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_tags_updated_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_tags" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "publishedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_tags_status_published_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_tags" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "hiddenBy", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_tags_hidden_by_idx", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_tags" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "position", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_tags_related_tags_position_key", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_tags_related_tags" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "relatedItemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_tags_related_tags_related_item_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_tags_related_tags" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "languageId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_tags_translations_language_id_status_idx", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_tags_translations" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "languageId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "slug", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_tags_translations_language_id_slug_key", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_tags_translations" + }, + { + "nameExplicit": false, + "columns": [ + "roleId" + ], + "schemaTo": "public", + "tableTo": "core_roles", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_admin_permissions_roleId_core_roles_id_fk", + "entityType": "fks", + "schema": "public", + "table": "core_admin_permissions" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_admin_permissions_userId_core_users_id_fk", + "entityType": "fks", + "schema": "public", + "table": "core_admin_permissions" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_admin_sessions_userId_core_users_id_fk", + "entityType": "fks", + "schema": "public", + "table": "core_admin_sessions" + }, + { + "nameExplicit": false, + "columns": [ + "deviceId" + ], + "schemaTo": "public", + "tableTo": "core_sessions_known_devices", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_admin_sessions_deviceId_core_sessions_known_devices_id_fk", + "entityType": "fks", + "schema": "public", + "table": "core_admin_sessions" + }, + { + "nameExplicit": false, + "columns": [ + "runId" + ], + "schemaTo": "public", + "tableTo": "core_ai_runs", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_ai_calls_runId_core_ai_runs_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_ai_calls" + }, + { + "nameExplicit": false, + "columns": [ + "callId" + ], + "schemaTo": "public", + "tableTo": "core_ai_calls", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_ai_cost_adjustments_callId_core_ai_calls_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_ai_cost_adjustments" + }, + { + "nameExplicit": false, + "columns": [ + "runId" + ], + "schemaTo": "public", + "tableTo": "core_ai_runs", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_ai_cost_adjustments_runId_core_ai_runs_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_ai_cost_adjustments" + }, + { + "nameExplicit": false, + "columns": [ + "runId" + ], + "schemaTo": "public", + "tableTo": "core_ai_runs", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_ai_reservations_runId_core_ai_runs_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_ai_reservations" + }, + { + "nameExplicit": false, + "columns": [ + "budgetPeriodId" + ], + "schemaTo": "public", + "tableTo": "core_ai_budget_periods", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_ai_reservations_FClYpPXZwvsm_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_ai_reservations" + }, + { + "nameExplicit": false, + "columns": [ + "roleId" + ], + "schemaTo": "public", + "tableTo": "core_roles", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_ai_role_permissions_roleId_core_roles_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_ai_role_permissions" + }, + { + "nameExplicit": false, + "columns": [ + "roleId" + ], + "schemaTo": "public", + "tableTo": "core_roles", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_ai_role_policies_roleId_core_roles_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_ai_role_policies" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "core_ai_runs_userId_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_ai_runs" + }, + { + "nameExplicit": false, + "columns": [ + "updatedById" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "core_ai_translation_sources_updatedById_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_ai_translation_sources" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_ai_user_overrides_userId_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_ai_user_overrides" + }, + { + "nameExplicit": false, + "columns": [ + "parentId" + ], + "schemaTo": "public", + "tableTo": "core_content_containers", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "RESTRICT", + "name": "core_content_containers_q6QIucJ4HJ7B_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_content_containers" + }, + { + "nameExplicit": false, + "columns": [ + "updatedBy" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "SET NULL", + "name": "core_content_documents_updatedBy_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_content_documents" + }, + { + "nameExplicit": false, + "columns": [ + "updatedBy" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "SET NULL", + "name": "core_content_drafts_updatedBy_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_content_drafts" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "core_content_field_locks_userId_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_content_field_locks" + }, + { + "nameExplicit": false, + "columns": [ + "authorId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "core_content_submissions_authorId_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_content_submissions" + }, + { + "nameExplicit": false, + "columns": [ + "reviewerId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "core_content_submissions_reviewerId_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_content_submissions" + }, + { + "nameExplicit": false, + "columns": [ + "revisionId" + ], + "schemaTo": "public", + "tableTo": "core_content_revisions", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "core_content_file_refs_5get6SfhBPHr_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_content_file_refs" + }, + { + "nameExplicit": false, + "columns": [ + "fileId" + ], + "schemaTo": "public", + "tableTo": "core_files", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "RESTRICT", + "name": "core_content_file_refs_fileId_core_files_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_content_file_refs" + }, + { + "nameExplicit": false, + "columns": [ + "actorUserId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "SET NULL", + "name": "core_content_revisions_actorUserId_core_users_id_fk", + "entityType": "fks", + "schema": "public", + "table": "core_content_revisions" + }, + { + "nameExplicit": false, + "columns": [ + "createdBy" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "SET NULL", + "name": "core_content_schedules_createdBy_core_users_id_fk", + "entityType": "fks", + "schema": "public", + "table": "core_content_schedules" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_admin_dashboard_userId_core_users_id_fk", + "entityType": "fks", + "schema": "public", + "table": "core_admin_dashboard" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "core_files_userId_core_users_id_fk", + "entityType": "fks", + "schema": "public", + "table": "core_files" + }, + { + "nameExplicit": false, + "columns": [ + "fileId" + ], + "schemaTo": "public", + "tableTo": "core_files", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_files_alt_fileId_core_files_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_files_alt" + }, + { + "nameExplicit": false, + "columns": [ + "languageCode" + ], + "schemaTo": "public", + "tableTo": "core_languages", + "columnsTo": [ + "code" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_files_alt_languageCode_core_languages_code_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_files_alt" + }, + { + "nameExplicit": false, + "columns": [ + "runId" + ], + "schemaTo": "public", + "tableTo": "core_ai_runs", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "core_files_alt_runId_core_ai_runs_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_files_alt" + }, + { + "nameExplicit": false, + "columns": [ + "updatedById" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "core_files_alt_updatedById_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_files_alt" + }, + { + "nameExplicit": false, + "columns": [ + "fileId" + ], + "schemaTo": "public", + "tableTo": "core_files", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_files_alt_analysis_fileId_core_files_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_files_alt_analysis" + }, + { + "nameExplicit": false, + "columns": [ + "runId" + ], + "schemaTo": "public", + "tableTo": "core_ai_runs", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "core_files_alt_analysis_runId_core_ai_runs_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_files_alt_analysis" + }, + { + "nameExplicit": false, + "columns": [ + "fileId" + ], + "schemaTo": "public", + "tableTo": "core_files", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_files_alt_state_fileId_core_files_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_files_alt_state" + }, + { + "nameExplicit": false, + "columns": [ + "languageCode" + ], + "schemaTo": "public", + "tableTo": "core_languages", + "columnsTo": [ + "code" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_languages_words_languageCode_core_languages_code_fk", + "entityType": "fks", + "schema": "public", + "table": "core_languages_words" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "SET NULL", + "name": "core_logs_userId_core_users_id_fk", + "entityType": "fks", + "schema": "public", + "table": "core_logs" + }, + { + "nameExplicit": false, + "columns": [ + "reportId" + ], + "schemaTo": "public", + "tableTo": "core_moderation_reports", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_moderation_report_events_4CqHOxfheaUm_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_moderation_report_events" + }, + { + "nameExplicit": false, + "columns": [ + "actorId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "core_moderation_report_events_actorId_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_moderation_report_events" + }, + { + "nameExplicit": false, + "columns": [ + "reporterId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "core_moderation_reports_reporterId_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_moderation_reports" + }, + { + "nameExplicit": false, + "columns": [ + "assigneeId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "core_moderation_reports_assigneeId_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_moderation_reports" + }, + { + "nameExplicit": false, + "columns": [ + "resolvedById" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "core_moderation_reports_resolvedById_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_moderation_reports" + }, + { + "nameExplicit": false, + "columns": [ + "roleId" + ], + "schemaTo": "public", + "tableTo": "core_roles", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_moderators_permissions_roleId_core_roles_id_fk", + "entityType": "fks", + "schema": "public", + "table": "core_moderators_permissions" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_moderators_permissions_userId_core_users_id_fk", + "entityType": "fks", + "schema": "public", + "table": "core_moderators_permissions" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_notification_deliveries_userId_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "nameExplicit": false, + "columns": [ + "eventId" + ], + "schemaTo": "public", + "tableTo": "core_notification_events", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "core_notification_deliveries_vcf37BytIa0M_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "nameExplicit": false, + "columns": [ + "actorId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "core_notification_events_actorId_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_notification_events" + }, + { + "nameExplicit": false, + "columns": [ + "eventId" + ], + "schemaTo": "public", + "tableTo": "core_notification_events", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_notification_receipts_UQ7ZEs0I6ijx_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_notification_receipts" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_notification_receipts_userId_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_notification_receipts" + }, + { + "nameExplicit": false, + "columns": [ + "notificationId" + ], + "schemaTo": "public", + "tableTo": "core_notifications", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_notification_receipts_Vxye2Cs6MgcL_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_notification_receipts" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_notification_user_state_userId_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_notification_user_state" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_notifications_userId_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_notifications" + }, + { + "nameExplicit": false, + "columns": [ + "latestEventId" + ], + "schemaTo": "public", + "tableTo": "core_notification_events", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "RESTRICT", + "name": "core_notifications_I8QoGbTal5Wt_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_notifications" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_users_passkey_challenges_userId_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_users_passkey_challenges" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_users_passkeys_userId_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "nameExplicit": false, + "columns": [ + "imageId" + ], + "schemaTo": "public", + "tableTo": "core_files", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "core_search_index_imageId_core_files_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_search_index" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_sessions_userId_core_users_id_fk", + "entityType": "fks", + "schema": "public", + "table": "core_sessions" + }, + { + "nameExplicit": false, + "columns": [ + "deviceId" + ], + "schemaTo": "public", + "tableTo": "core_sessions_known_devices", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_sessions_deviceId_core_sessions_known_devices_id_fk", + "entityType": "fks", + "schema": "public", + "table": "core_sessions" + }, + { + "nameExplicit": false, + "columns": [ + "roleId" + ], + "schemaTo": "public", + "tableTo": "core_roles", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "NO ACTION", + "name": "core_users_roleId_core_roles_id_fk", + "entityType": "fks", + "schema": "public", + "table": "core_users" + }, + { + "nameExplicit": false, + "columns": [ + "language" + ], + "schemaTo": "public", + "tableTo": "core_languages", + "columnsTo": [ + "code" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET DEFAULT", + "name": "core_users_language_core_languages_code_fk", + "entityType": "fks", + "schema": "public", + "table": "core_users" + }, + { + "nameExplicit": false, + "columns": [ + "avatarId" + ], + "schemaTo": "public", + "tableTo": "core_files", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "core_users_avatarId_core_files_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_users" + }, + { + "nameExplicit": false, + "columns": [ + "coverId" + ], + "schemaTo": "public", + "tableTo": "core_files", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "core_users_coverId_core_files_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_users" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_users_confirm_emails_userId_core_users_id_fk", + "entityType": "fks", + "schema": "public", + "table": "core_users_confirm_emails" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_users_forgot_password_userId_core_users_id_fk", + "entityType": "fks", + "schema": "public", + "table": "core_users_forgot_password" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_users_secondary_roles_userId_core_users_id_fk", + "entityType": "fks", + "schema": "public", + "table": "core_users_secondary_roles" + }, + { + "nameExplicit": false, + "columns": [ + "roleId" + ], + "schemaTo": "public", + "tableTo": "core_roles", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_users_secondary_roles_roleId_core_roles_id_fk", + "entityType": "fks", + "schema": "public", + "table": "core_users_secondary_roles" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_users_sso_userId_core_users_id_fk", + "entityType": "fks", + "schema": "public", + "table": "core_users_sso" + }, + { + "nameExplicit": false, + "columns": [ + "avatarFileId" + ], + "schemaTo": "public", + "tableTo": "core_files", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "core_users_sso_avatarFileId_core_files_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_users_sso" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_users_sso_operations_userId_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_users_sso_operations" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_users_sso_profile_sources_userId_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_users_sso_profile_sources" + }, + { + "nameExplicit": true, + "columns": [ + "userId", + "providerId" + ], + "schemaTo": "public", + "tableTo": "core_users_sso", + "columnsTo": [ + "userId", + "providerId" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_users_sso_profile_sources_connection_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_users_sso_profile_sources" + }, + { + "nameExplicit": false, + "columns": [ + "itemId" + ], + "schemaTo": "public", + "tableTo": "blog_categories", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "blog_categories_translations_itemId_blog_categories_id_fk", + "entityType": "fks", + "schema": "public", + "table": "blog_categories_translations" + }, + { + "nameExplicit": false, + "columns": [ + "languageId" + ], + "schemaTo": "public", + "tableTo": "core_languages", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "RESTRICT", + "name": "blog_categories_translations_languageId_core_languages_id_fk", + "entityType": "fks", + "schema": "public", + "table": "blog_categories_translations" + }, + { + "nameExplicit": false, + "columns": [ + "hiddenBy" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "SET NULL", + "name": "blog_posts_hiddenBy_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "blog_posts" + }, + { + "nameExplicit": false, + "columns": [ + "coverImage" + ], + "schemaTo": "public", + "tableTo": "core_files", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "RESTRICT", + "name": "blog_posts_coverImage_core_files_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "blog_posts" + }, + { + "nameExplicit": false, + "columns": [ + "itemId" + ], + "schemaTo": "public", + "tableTo": "blog_posts", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "blog_posts_author_id_itemId_blog_posts_id_fk", + "entityType": "fks", + "schema": "public", + "table": "blog_posts_author_id" + }, + { + "nameExplicit": false, + "columns": [ + "relatedItemId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "blog_posts_author_id_relatedItemId_core_users_id_fk", + "entityType": "fks", + "schema": "public", + "table": "blog_posts_author_id" + }, + { + "nameExplicit": false, + "columns": [ + "itemId" + ], + "schemaTo": "public", + "tableTo": "blog_posts", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "blog_posts_category_id_itemId_blog_posts_id_fk", + "entityType": "fks", + "schema": "public", + "table": "blog_posts_category_id" + }, + { + "nameExplicit": false, + "columns": [ + "relatedItemId" + ], + "schemaTo": "public", + "tableTo": "blog_categories", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "RESTRICT", + "name": "blog_posts_category_id_relatedItemId_blog_categories_id_fk", + "entityType": "fks", + "schema": "public", + "table": "blog_posts_category_id" + }, + { + "nameExplicit": false, + "columns": [ + "itemId" + ], + "schemaTo": "public", + "tableTo": "blog_posts", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "blog_posts_translations_itemId_blog_posts_id_fk", + "entityType": "fks", + "schema": "public", + "table": "blog_posts_translations" + }, + { + "nameExplicit": false, + "columns": [ + "languageId" + ], + "schemaTo": "public", + "tableTo": "core_languages", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "RESTRICT", + "name": "blog_posts_translations_languageId_core_languages_id_fk", + "entityType": "fks", + "schema": "public", + "table": "blog_posts_translations" + }, + { + "nameExplicit": false, + "columns": [ + "hiddenBy" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "SET NULL", + "name": "forum_replies_hiddenBy_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "forum_comments" + }, + { + "nameExplicit": false, + "columns": [ + "author" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "SET NULL", + "name": "forum_replies_author_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "forum_comments" + }, + { + "nameExplicit": false, + "columns": [ + "topic" + ], + "schemaTo": "public", + "tableTo": "forum_topics", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "forum_replies_topic_forum_topics_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "forum_comments" + }, + { + "nameExplicit": false, + "columns": [ + "parent" + ], + "schemaTo": "public", + "tableTo": "forum_comments", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "SET NULL", + "name": "forum_replies_parent_forum_replies_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "forum_comments" + }, + { + "nameExplicit": false, + "columns": [ + "hiddenBy" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "SET NULL", + "name": "forum_topics_hiddenBy_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "forum_topics" + }, + { + "nameExplicit": false, + "columns": [ + "author" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "SET NULL", + "name": "forum_topics_author_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "forum_topics" + }, + { + "nameExplicit": false, + "columns": [ + "forum" + ], + "schemaTo": "public", + "tableTo": "core_content_containers", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "RESTRICT", + "name": "forum_topics_forum_core_content_containers_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "forum_topics" + }, + { + "nameExplicit": false, + "columns": [ + "author" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "SET NULL", + "name": "newsroom_articles_author_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "newsroom_articles" + }, + { + "nameExplicit": false, + "columns": [ + "category" + ], + "schemaTo": "public", + "tableTo": "newsroom_categories", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "SET NULL", + "name": "newsroom_articles_category_newsroom_categories_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "newsroom_articles" + }, + { + "nameExplicit": false, + "columns": [ + "itemId" + ], + "schemaTo": "public", + "tableTo": "newsroom_bulletins", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "newsroom_bulletins_translations_kgMZwrpzwwU7_fkey", + "entityType": "fks", + "schema": "public", + "table": "newsroom_bulletins_translations" + }, + { + "nameExplicit": false, + "columns": [ + "languageId" + ], + "schemaTo": "public", + "tableTo": "core_languages", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "RESTRICT", + "name": "newsroom_bulletins_translations_rkkiMJQiOrEo_fkey", + "entityType": "fks", + "schema": "public", + "table": "newsroom_bulletins_translations" + }, + { + "nameExplicit": false, + "columns": [ + "hiddenBy" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "SET NULL", + "name": "newsroom_comments_hiddenBy_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "newsroom_comments" + }, + { + "nameExplicit": false, + "columns": [ + "author" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "SET NULL", + "name": "newsroom_comments_author_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "newsroom_comments" + }, + { + "nameExplicit": false, + "columns": [ + "article" + ], + "schemaTo": "public", + "tableTo": "newsroom_articles", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "newsroom_comments_article_newsroom_articles_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "newsroom_comments" + }, + { + "nameExplicit": false, + "columns": [ + "parent" + ], + "schemaTo": "public", + "tableTo": "newsroom_comments", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "SET NULL", + "name": "newsroom_comments_parent_newsroom_comments_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "newsroom_comments" + }, + { + "nameExplicit": false, + "columns": [ + "hiddenBy" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "SET NULL", + "name": "newsroom_events_hiddenBy_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "newsroom_events" + }, + { + "nameExplicit": false, + "columns": [ + "primaryTag" + ], + "schemaTo": "public", + "tableTo": "newsroom_tags", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "SET NULL", + "name": "newsroom_events_primaryTag_newsroom_tags_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "newsroom_events" + }, + { + "nameExplicit": false, + "columns": [ + "category" + ], + "schemaTo": "public", + "tableTo": "newsroom_categories", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "SET NULL", + "name": "newsroom_events_category_newsroom_categories_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "newsroom_events" + }, + { + "nameExplicit": false, + "columns": [ + "itemId" + ], + "schemaTo": "public", + "tableTo": "newsroom_events", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "newsroom_events_gallery_itemId_newsroom_events_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "newsroom_events_gallery" + }, + { + "nameExplicit": false, + "columns": [ + "relatedItemId" + ], + "schemaTo": "public", + "tableTo": "core_files", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "RESTRICT", + "name": "newsroom_events_gallery_relatedItemId_core_files_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "newsroom_events_gallery" + }, + { + "nameExplicit": false, + "columns": [ + "itemId" + ], + "schemaTo": "public", + "tableTo": "newsroom_events", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "newsroom_events_related_events_itemId_newsroom_events_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "newsroom_events_related_events" + }, + { + "nameExplicit": false, + "columns": [ + "relatedItemId" + ], + "schemaTo": "public", + "tableTo": "newsroom_events", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "newsroom_events_related_events_0xHNSLM3JAJj_fkey", + "entityType": "fks", + "schema": "public", + "table": "newsroom_events_related_events" + }, + { + "nameExplicit": false, + "columns": [ + "itemId" + ], + "schemaTo": "public", + "tableTo": "newsroom_events", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "newsroom_events_sessions_itemId_newsroom_events_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "newsroom_events_sessions" + }, + { + "nameExplicit": false, + "columns": [ + "itemId" + ], + "schemaTo": "public", + "tableTo": "newsroom_events", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "newsroom_events_tags_itemId_newsroom_events_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "newsroom_events_tags" + }, + { + "nameExplicit": false, + "columns": [ + "relatedItemId" + ], + "schemaTo": "public", + "tableTo": "newsroom_tags", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "RESTRICT", + "name": "newsroom_events_tags_relatedItemId_newsroom_tags_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "newsroom_events_tags" + }, + { + "nameExplicit": false, + "columns": [ + "itemId" + ], + "schemaTo": "public", + "tableTo": "newsroom_events", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "newsroom_events_translations_itemId_newsroom_events_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "newsroom_events_translations" + }, + { + "nameExplicit": false, + "columns": [ + "languageId" + ], + "schemaTo": "public", + "tableTo": "core_languages", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "RESTRICT", + "name": "newsroom_events_translations_languageId_core_languages_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "newsroom_events_translations" + }, + { + "nameExplicit": false, + "columns": [ + "itemId" + ], + "schemaTo": "public", + "tableTo": "newsroom_explainers", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "newsroom_explainers_categories_9dOmLv3k6xDX_fkey", + "entityType": "fks", + "schema": "public", + "table": "newsroom_explainers_categories" + }, + { + "nameExplicit": false, + "columns": [ + "relatedItemId" + ], + "schemaTo": "public", + "tableTo": "newsroom_categories", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "RESTRICT", + "name": "newsroom_explainers_categories_mnxKvproEUm5_fkey", + "entityType": "fks", + "schema": "public", + "table": "newsroom_explainers_categories" + }, + { + "nameExplicit": false, + "columns": [ + "itemId" + ], + "schemaTo": "public", + "tableTo": "newsroom_explainers", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "newsroom_explainers_faq_itemId_newsroom_explainers_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "newsroom_explainers_faq" + }, + { + "nameExplicit": false, + "columns": [ + "itemId" + ], + "schemaTo": "public", + "tableTo": "newsroom_explainers", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "newsroom_explainers_related_explainers_TFvIU3TogSGy_fkey", + "entityType": "fks", + "schema": "public", + "table": "newsroom_explainers_related_explainers" + }, + { + "nameExplicit": false, + "columns": [ + "relatedItemId" + ], + "schemaTo": "public", + "tableTo": "newsroom_explainers", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "newsroom_explainers_related_explainers_74JhaXF5PsDV_fkey", + "entityType": "fks", + "schema": "public", + "table": "newsroom_explainers_related_explainers" + }, + { + "nameExplicit": false, + "columns": [ + "itemId" + ], + "schemaTo": "public", + "tableTo": "newsroom_explainers", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "newsroom_explainers_tags_itemId_newsroom_explainers_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "newsroom_explainers_tags" + }, + { + "nameExplicit": false, + "columns": [ + "relatedItemId" + ], + "schemaTo": "public", + "tableTo": "newsroom_tags", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "RESTRICT", + "name": "newsroom_explainers_tags_relatedItemId_newsroom_tags_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "newsroom_explainers_tags" + }, + { + "nameExplicit": false, + "columns": [ + "itemId" + ], + "schemaTo": "public", + "tableTo": "newsroom_explainers", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "newsroom_explainers_translations_hcPB0jdTm8MV_fkey", + "entityType": "fks", + "schema": "public", + "table": "newsroom_explainers_translations" + }, + { + "nameExplicit": false, + "columns": [ + "languageId" + ], + "schemaTo": "public", + "tableTo": "core_languages", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "RESTRICT", + "name": "newsroom_explainers_translations_qqggtF6jhcsZ_fkey", + "entityType": "fks", + "schema": "public", + "table": "newsroom_explainers_translations" + }, + { + "nameExplicit": false, + "columns": [ + "hiddenBy" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "SET NULL", + "name": "newsroom_letters_hiddenBy_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "newsroom_letters" + }, + { + "nameExplicit": false, + "columns": [ + "author" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "SET NULL", + "name": "newsroom_letters_author_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "newsroom_letters" + }, + { + "nameExplicit": false, + "columns": [ + "desk" + ], + "schemaTo": "public", + "tableTo": "core_content_containers", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "RESTRICT", + "name": "newsroom_letters_desk_core_content_containers_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "newsroom_letters" + }, + { + "nameExplicit": false, + "columns": [ + "author" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "SET NULL", + "name": "newsroom_stories_author_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "newsroom_stories" + }, + { + "nameExplicit": false, + "columns": [ + "animation" + ], + "schemaTo": "public", + "tableTo": "core_files", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "RESTRICT", + "name": "newsroom_stories_animation_core_files_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "newsroom_stories" + }, + { + "nameExplicit": false, + "columns": [ + "category" + ], + "schemaTo": "public", + "tableTo": "newsroom_categories", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "RESTRICT", + "name": "newsroom_stories_category_newsroom_categories_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "newsroom_stories" + }, + { + "nameExplicit": false, + "columns": [ + "itemId" + ], + "schemaTo": "public", + "tableTo": "newsroom_stories", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "newsroom_stories_gallery_itemId_newsroom_stories_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "newsroom_stories_gallery" + }, + { + "nameExplicit": false, + "columns": [ + "relatedItemId" + ], + "schemaTo": "public", + "tableTo": "core_files", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "RESTRICT", + "name": "newsroom_stories_gallery_relatedItemId_core_files_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "newsroom_stories_gallery" + }, + { + "nameExplicit": false, + "columns": [ + "hiddenBy" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "SET NULL", + "name": "newsroom_tags_hiddenBy_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "newsroom_tags" + }, + { + "nameExplicit": false, + "columns": [ + "category" + ], + "schemaTo": "public", + "tableTo": "newsroom_categories", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "SET NULL", + "name": "newsroom_tags_category_newsroom_categories_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "newsroom_tags" + }, + { + "nameExplicit": false, + "columns": [ + "itemId" + ], + "schemaTo": "public", + "tableTo": "newsroom_tags", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "newsroom_tags_related_tags_itemId_newsroom_tags_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "newsroom_tags_related_tags" + }, + { + "nameExplicit": false, + "columns": [ + "relatedItemId" + ], + "schemaTo": "public", + "tableTo": "newsroom_tags", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "newsroom_tags_related_tags_relatedItemId_newsroom_tags_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "newsroom_tags_related_tags" + }, + { + "nameExplicit": false, + "columns": [ + "itemId" + ], + "schemaTo": "public", + "tableTo": "newsroom_tags", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "newsroom_tags_translations_itemId_newsroom_tags_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "newsroom_tags_translations" + }, + { + "nameExplicit": false, + "columns": [ + "languageId" + ], + "schemaTo": "public", + "tableTo": "core_languages", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "RESTRICT", + "name": "newsroom_tags_translations_languageId_core_languages_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "newsroom_tags_translations" + }, + { + "columns": [ + "roleId", + "permission" + ], + "nameExplicit": false, + "name": "core_ai_role_permissions_pkey", + "entityType": "pks", + "schema": "public", + "table": "core_ai_role_permissions" + }, + { + "columns": [ + "eventId", + "userId" + ], + "nameExplicit": false, + "name": "core_notification_receipts_pkey", + "entityType": "pks", + "schema": "public", + "table": "core_notification_receipts" + }, + { + "columns": [ + "itemType", + "itemId" + ], + "nameExplicit": false, + "name": "core_search_popularity_pkey", + "entityType": "pks", + "schema": "public", + "table": "core_search_popularity" + }, + { + "columns": [ + "userId", + "roleId" + ], + "nameExplicit": false, + "name": "core_users_secondary_roles_userId_roleId_pk", + "entityType": "pks", + "schema": "public", + "table": "core_users_secondary_roles" + }, + { + "columns": [ + "userId", + "field" + ], + "nameExplicit": false, + "name": "core_users_sso_profile_sources_pkey", + "entityType": "pks", + "schema": "public", + "table": "core_users_sso_profile_sources" + }, + { + "columns": [ + "itemId", + "languageId" + ], + "nameExplicit": true, + "name": "blog_categories_translations_item_id_language_id_pk", + "entityType": "pks", + "schema": "public", + "table": "blog_categories_translations" + }, + { + "columns": [ + "itemId", + "relatedItemId" + ], + "nameExplicit": true, + "name": "blog_posts_author_id_pk", + "entityType": "pks", + "schema": "public", + "table": "blog_posts_author_id" + }, + { + "columns": [ + "itemId", + "relatedItemId" + ], + "nameExplicit": true, + "name": "blog_posts_category_id_pk", + "entityType": "pks", + "schema": "public", + "table": "blog_posts_category_id" + }, + { + "columns": [ + "itemId", + "languageId" + ], + "nameExplicit": true, + "name": "blog_posts_translations_item_id_language_id_pk", + "entityType": "pks", + "schema": "public", + "table": "blog_posts_translations" + }, + { + "columns": [ + "itemId", + "languageId" + ], + "nameExplicit": true, + "name": "newsroom_bulletins_translations_item_id_language_id_pk", + "entityType": "pks", + "schema": "public", + "table": "newsroom_bulletins_translations" + }, + { + "columns": [ + "itemId", + "relatedItemId" + ], + "nameExplicit": true, + "name": "newsroom_events_gallery_pk", + "entityType": "pks", + "schema": "public", + "table": "newsroom_events_gallery" + }, + { + "columns": [ + "itemId", + "relatedItemId" + ], + "nameExplicit": true, + "name": "newsroom_events_related_events_pk", + "entityType": "pks", + "schema": "public", + "table": "newsroom_events_related_events" + }, + { + "columns": [ + "itemId", + "relatedItemId" + ], + "nameExplicit": true, + "name": "newsroom_events_tags_pk", + "entityType": "pks", + "schema": "public", + "table": "newsroom_events_tags" + }, + { + "columns": [ + "itemId", + "languageId" + ], + "nameExplicit": true, + "name": "newsroom_events_translations_item_id_language_id_pk", + "entityType": "pks", + "schema": "public", + "table": "newsroom_events_translations" + }, + { + "columns": [ + "itemId", + "relatedItemId" + ], + "nameExplicit": true, + "name": "newsroom_explainers_categories_pk", + "entityType": "pks", + "schema": "public", + "table": "newsroom_explainers_categories" + }, + { + "columns": [ + "itemId", + "relatedItemId" + ], + "nameExplicit": true, + "name": "newsroom_explainers_related_explainers_pk", + "entityType": "pks", + "schema": "public", + "table": "newsroom_explainers_related_explainers" + }, + { + "columns": [ + "itemId", + "relatedItemId" + ], + "nameExplicit": true, + "name": "newsroom_explainers_tags_pk", + "entityType": "pks", + "schema": "public", + "table": "newsroom_explainers_tags" + }, + { + "columns": [ + "itemId", + "languageId" + ], + "nameExplicit": true, + "name": "newsroom_explainers_translations_item_id_language_id_pk", + "entityType": "pks", + "schema": "public", + "table": "newsroom_explainers_translations" + }, + { + "columns": [ + "itemId", + "relatedItemId" + ], + "nameExplicit": true, + "name": "newsroom_stories_gallery_pk", + "entityType": "pks", + "schema": "public", + "table": "newsroom_stories_gallery" + }, + { + "columns": [ + "itemId", + "relatedItemId" + ], + "nameExplicit": true, + "name": "newsroom_tags_related_tags_pk", + "entityType": "pks", + "schema": "public", + "table": "newsroom_tags_related_tags" + }, + { + "columns": [ + "itemId", + "languageId" + ], + "nameExplicit": true, + "name": "newsroom_tags_translations_item_id_language_id_pk", + "entityType": "pks", + "schema": "public", + "table": "newsroom_tags_translations" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_admin_permissions_pkey", + "schema": "public", + "table": "core_admin_permissions", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_admin_sessions_pkey", + "schema": "public", + "table": "core_admin_sessions", + "entityType": "pks" + }, + { + "columns": [ + "actionKey" + ], + "nameExplicit": false, + "name": "core_ai_action_settings_pkey", + "schema": "public", + "table": "core_ai_action_settings", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_ai_budget_periods_pkey", + "schema": "public", + "table": "core_ai_budget_periods", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_ai_calls_pkey", + "schema": "public", + "table": "core_ai_calls", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_ai_cost_adjustments_pkey", + "schema": "public", + "table": "core_ai_cost_adjustments", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_ai_reservations_pkey", + "schema": "public", + "table": "core_ai_reservations", + "entityType": "pks" + }, + { + "columns": [ + "roleId" + ], + "nameExplicit": false, + "name": "core_ai_role_policies_pkey", + "schema": "public", + "table": "core_ai_role_policies", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_ai_runs_pkey", + "schema": "public", + "table": "core_ai_runs", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_ai_settings_pkey", + "schema": "public", + "table": "core_ai_settings", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_ai_translation_sources_pkey", + "schema": "public", + "table": "core_ai_translation_sources", + "entityType": "pks" + }, + { + "columns": [ + "userId" + ], + "nameExplicit": false, + "name": "core_ai_user_overrides_pkey", + "schema": "public", + "table": "core_ai_user_overrides", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_content_containers_pkey", + "schema": "public", + "table": "core_content_containers", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_content_documents_pkey", + "schema": "public", + "table": "core_content_documents", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_content_drafts_pkey", + "schema": "public", + "table": "core_content_drafts", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_content_field_locks_pkey", + "schema": "public", + "table": "core_content_field_locks", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_content_submissions_pkey", + "schema": "public", + "table": "core_content_submissions", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_content_file_refs_pkey", + "schema": "public", + "table": "core_content_file_refs", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_content_revisions_pkey", + "schema": "public", + "table": "core_content_revisions", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_content_schedules_pkey", + "schema": "public", + "table": "core_content_schedules", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_content_slug_history_pkey", + "schema": "public", + "table": "core_content_slug_history", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_cron_pkey", + "schema": "public", + "table": "core_cron", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_admin_dashboard_pkey", + "schema": "public", + "table": "core_admin_dashboard", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_files_pkey", + "schema": "public", + "table": "core_files", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_files_alt_pkey", + "schema": "public", + "table": "core_files_alt", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_files_alt_analysis_pkey", + "schema": "public", + "table": "core_files_alt_analysis", + "entityType": "pks" + }, + { + "columns": [ + "fileId" + ], + "nameExplicit": false, + "name": "core_files_alt_state_pkey", + "schema": "public", + "table": "core_files_alt_state", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_languages_pkey", + "schema": "public", + "table": "core_languages", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_languages_words_pkey", + "schema": "public", + "table": "core_languages_words", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_logs_pkey", + "schema": "public", + "table": "core_logs", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_moderation_report_events_pkey", + "schema": "public", + "table": "core_moderation_report_events", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_moderation_report_reasons_pkey", + "schema": "public", + "table": "core_moderation_report_reasons", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_moderation_reports_pkey", + "schema": "public", + "table": "core_moderation_reports", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_moderators_permissions_pkey", + "schema": "public", + "table": "core_moderators_permissions", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_notification_deliveries_pkey", + "schema": "public", + "table": "core_notification_deliveries", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_notification_events_pkey", + "schema": "public", + "table": "core_notification_events", + "entityType": "pks" + }, + { + "columns": [ + "key" + ], + "nameExplicit": false, + "name": "core_notification_settings_pkey", + "schema": "public", + "table": "core_notification_settings", + "entityType": "pks" + }, + { + "columns": [ + "userId" + ], + "nameExplicit": false, + "name": "core_notification_user_state_pkey", + "schema": "public", + "table": "core_notification_user_state", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_notifications_pkey", + "schema": "public", + "table": "core_notifications", + "entityType": "pks" + }, + { + "columns": [ + "pageId" + ], + "nameExplicit": false, + "name": "core_page_layouts_pkey", + "schema": "public", + "table": "core_page_layouts", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_users_passkey_challenges_pkey", + "schema": "public", + "table": "core_users_passkey_challenges", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_users_passkeys_pkey", + "schema": "public", + "table": "core_users_passkeys", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_queue_pkey", + "schema": "public", + "table": "core_queue", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_roles_pkey", + "schema": "public", + "table": "core_roles", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_search_index_pkey", + "schema": "public", + "table": "core_search_index", + "entityType": "pks" + }, + { + "columns": [ + "name" + ], + "nameExplicit": false, + "name": "core_secrets_pkey", + "schema": "public", + "table": "core_secrets", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_sessions_pkey", + "schema": "public", + "table": "core_sessions", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_sessions_known_devices_pkey", + "schema": "public", + "table": "core_sessions_known_devices", + "entityType": "pks" + }, + { + "columns": [ + "name" + ], + "nameExplicit": false, + "name": "core_update_lock_pkey", + "schema": "public", + "table": "core_update_lock", + "entityType": "pks" + }, + { + "columns": [ + "componentId" + ], + "nameExplicit": false, + "name": "core_update_results_pkey", + "schema": "public", + "table": "core_update_results", + "entityType": "pks" + }, + { + "columns": [ + "url" + ], + "nameExplicit": false, + "name": "core_update_sources_pkey", + "schema": "public", + "table": "core_update_sources", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_users_pkey", + "schema": "public", + "table": "core_users", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_users_confirm_emails_pkey", + "schema": "public", + "table": "core_users_confirm_emails", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_users_forgot_password_pkey", + "schema": "public", + "table": "core_users_forgot_password", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_users_sso_operations_pkey", + "schema": "public", + "table": "core_users_sso_operations", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "blog_categories_pkey", + "schema": "public", + "table": "blog_categories", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "blog_posts_pkey", + "schema": "public", + "table": "blog_posts", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "forum_replies_pkey", + "schema": "public", + "table": "forum_comments", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "forum_topics_pkey", + "schema": "public", + "table": "forum_topics", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "newsroom_articles_pkey", + "schema": "public", + "table": "newsroom_articles", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "newsroom_bulletins_pkey", + "schema": "public", + "table": "newsroom_bulletins", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "newsroom_categories_pkey", + "schema": "public", + "table": "newsroom_categories", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "newsroom_comments_pkey", + "schema": "public", + "table": "newsroom_comments", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "newsroom_events_pkey", + "schema": "public", + "table": "newsroom_events", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "newsroom_events_sessions_pkey", + "schema": "public", + "table": "newsroom_events_sessions", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "newsroom_explainers_pkey", + "schema": "public", + "table": "newsroom_explainers", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "newsroom_explainers_faq_pkey", + "schema": "public", + "table": "newsroom_explainers_faq", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "newsroom_letters_pkey", + "schema": "public", + "table": "newsroom_letters", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "newsroom_pages_pkey", + "schema": "public", + "table": "newsroom_pages", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "newsroom_stories_pkey", + "schema": "public", + "table": "newsroom_stories", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "newsroom_tags_pkey", + "schema": "public", + "table": "newsroom_tags", + "entityType": "pks" + }, + { + "nameExplicit": true, + "columns": [ + "itemType", + "itemId", + "languageCode" + ], + "nullsNotDistinct": false, + "name": "core_search_index_item_unique", + "entityType": "uniques", + "schema": "public", + "table": "core_search_index" + }, + { + "nameExplicit": true, + "columns": [ + "providerId", + "providerAccountId" + ], + "nullsNotDistinct": false, + "name": "core_users_sso_provider_account_key", + "entityType": "uniques", + "schema": "public", + "table": "core_users_sso" + }, + { + "nameExplicit": true, + "columns": [ + "userId", + "providerId" + ], + "nullsNotDistinct": false, + "name": "core_users_sso_user_provider_key", + "entityType": "uniques", + "schema": "public", + "table": "core_users_sso" + }, + { + "nameExplicit": false, + "columns": [ + "token" + ], + "nullsNotDistinct": false, + "name": "core_admin_sessions_token_unique", + "schema": "public", + "table": "core_admin_sessions", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "nullsNotDistinct": false, + "name": "core_admin_dashboard_userId_unique", + "schema": "public", + "table": "core_admin_dashboard", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "key" + ], + "nullsNotDistinct": false, + "name": "core_files_key_unique", + "schema": "public", + "table": "core_files", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "code" + ], + "nullsNotDistinct": false, + "name": "core_languages_code_unique", + "schema": "public", + "table": "core_languages", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "code" + ], + "nullsNotDistinct": false, + "name": "core_moderation_report_reasons_code_key", + "schema": "public", + "table": "core_moderation_report_reasons", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "openKey" + ], + "nullsNotDistinct": false, + "name": "core_moderation_reports_openKey_key", + "schema": "public", + "table": "core_moderation_reports", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "idempotencyKey" + ], + "nullsNotDistinct": false, + "name": "core_notification_deliveries_idempotencyKey_key", + "schema": "public", + "table": "core_notification_deliveries", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "tokenHash" + ], + "nullsNotDistinct": false, + "name": "core_users_passkey_challenges_tokenHash_key", + "schema": "public", + "table": "core_users_passkey_challenges", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "credentialId" + ], + "nullsNotDistinct": false, + "name": "core_users_passkeys_credentialId_key", + "schema": "public", + "table": "core_users_passkeys", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "token" + ], + "nullsNotDistinct": false, + "name": "core_sessions_token_unique", + "schema": "public", + "table": "core_sessions", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "publicId" + ], + "nullsNotDistinct": false, + "name": "core_sessions_known_devices_publicId_unique", + "schema": "public", + "table": "core_sessions_known_devices", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "nameCode" + ], + "nullsNotDistinct": false, + "name": "core_users_nameCode_unique", + "schema": "public", + "table": "core_users", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "name" + ], + "nullsNotDistinct": false, + "name": "core_users_name_unique", + "schema": "public", + "table": "core_users", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "email" + ], + "nullsNotDistinct": false, + "name": "core_users_email_unique", + "schema": "public", + "table": "core_users", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "token" + ], + "nullsNotDistinct": false, + "name": "core_users_confirm_emails_token_unique", + "schema": "public", + "table": "core_users_confirm_emails", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "nullsNotDistinct": false, + "name": "core_users_forgot_password_userId_unique", + "schema": "public", + "table": "core_users_forgot_password", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "token" + ], + "nullsNotDistinct": false, + "name": "core_users_forgot_password_token_unique", + "schema": "public", + "table": "core_users_forgot_password", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "tokenHash" + ], + "nullsNotDistinct": false, + "name": "core_users_sso_operations_tokenHash_key", + "schema": "public", + "table": "core_users_sso_operations", + "entityType": "uniques" + }, + { + "value": "\"intent\" IN ('link', 'import', 'sync', 'preview')", + "name": "core_users_sso_operations_intent_check", + "entityType": "checks", + "schema": "public", + "table": "core_users_sso_operations" + }, + { + "value": "\"field\" IN ('avatar', 'firstName', 'lastName')", + "name": "core_users_sso_profile_sources_field_check", + "entityType": "checks", + "schema": "public", + "table": "core_users_sso_profile_sources" + } + ], + "renames": [] +} \ No newline at end of file diff --git a/apps/api/package.json b/apps/api/package.json index 7f09552ebc..af3c45e459 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -45,8 +45,8 @@ "@types/ws": "^8.18.2", "@vitnode/blog": "workspace:*", "@vitnode/config": "workspace:*", - "@vitnode/example": "workspace:*", "@vitnode/forum": "workspace:*", + "newsroom": "workspace:*", "@vitnode/nodemailer": "workspace:*", "dotenv": "^18.0.6", "oxlint": "^1.87.0", diff --git a/apps/api/src/vitnode.api.config.ts b/apps/api/src/vitnode.api.config.ts index a76a8d4fcf..a33d6bb10c 100644 --- a/apps/api/src/vitnode.api.config.ts +++ b/apps/api/src/vitnode.api.config.ts @@ -6,7 +6,6 @@ import { GoogleSSOApiPlugin } from "@vitnode/core/api/adapters/sso/google"; import { coreRelations } from "@vitnode/core/database/relations"; // import { LocalStorageAdapter } from "@vitnode/core/api/adapters/storage/local"; import { buildApiConfig } from "@vitnode/core/vitnode.config"; -import { exampleApiPlugin } from "@vitnode/example/config.api"; import { forumApiPlugin } from "@vitnode/forum/config.api"; import { NodeCronAdapter } from "@vitnode/node-cron"; import { NodemailerEmailAdapter } from "@vitnode/nodemailer"; @@ -14,6 +13,7 @@ import { NodemailerEmailAdapter } from "@vitnode/nodemailer"; import { SupabaseStorageAdapter } from "@vitnode/supabase-storage"; import { config } from "dotenv"; import { drizzle } from "drizzle-orm/postgres-js"; +import { newsroomApiPlugin } from "newsroom/config.api"; import { i18n } from "./i18n.js"; @@ -25,7 +25,7 @@ export const POSTGRES_URL = process.env.POSTGRES_URL ?? "postgresql://root:root@localhost:5432/vitnode"; export const vitNodeApiConfig = buildApiConfig({ - plugins: [blogApiPlugin(), exampleApiPlugin(), forumApiPlugin()], + plugins: [blogApiPlugin(), forumApiPlugin(), newsroomApiPlugin()], ai: { models: [ { diff --git a/apps/web/content/docs/content-engine/admincp-article-list.webp b/apps/web/content/docs/content-engine/admincp-article-list.webp index e913bb33fd..c9900371b9 100644 Binary files a/apps/web/content/docs/content-engine/admincp-article-list.webp and b/apps/web/content/docs/content-engine/admincp-article-list.webp differ diff --git a/apps/web/content/docs/content-engine/admincp-create-dialog.webp b/apps/web/content/docs/content-engine/admincp-create-dialog.webp index 83579b9588..868e0b82a3 100644 Binary files a/apps/web/content/docs/content-engine/admincp-create-dialog.webp and b/apps/web/content/docs/content-engine/admincp-create-dialog.webp differ diff --git a/apps/web/content/docs/content-engine/admincp.mdx b/apps/web/content/docs/content-engine/admincp.mdx index 896e60581a..93169a884f 100644 --- a/apps/web/content/docs/content-engine/admincp.mdx +++ b/apps/web/content/docs/content-engine/admincp.mdx @@ -12,15 +12,15 @@ import rowActions from "./admincp-row-actions.webp"; Every content type registered in the plugin's `src/admin/content.tsx` gets an AdminCP list and a create and edit form without any UI code. Most changes are options in the definition's `admin` block. For anything the options cannot express, you swap one cell, one field or the whole form layout for your own component. -This page uses the example plugin's `example.article`. Its screen lives at `/admin/content/example/articles`. +This page uses the newsroom plugin's `newsroom.story`. Its screen lives at `/admin/content/newsroom/stories`. ## Choose list columns, search and sorting `admin.list` drives the list: -```ts title="plugins/example/src/content/article.ts" +```ts title="plugins/newsroom/src/content/story.ts" admin: { - path: "example/articles", + path: "newsroom/stories", titleField: "title", list: { columns: [ @@ -46,7 +46,7 @@ admin: { withoutBackground className="*:max-w-full" src={articleList} - alt="The Articles list in the AdminCP with Status, Title, Slug, Reference code, Category, Author, Animation, Published at and Updated columns and a search box" + alt="The newsroom Stories list in the AdminCP with Status, Title, Slug, Reference code, Category, Author, Animation, Published at and Updated columns, a search box and a Status filter" /> | Option | What it does | @@ -70,7 +70,7 @@ Every mistake here, such as an unknown field or a gallery in `columns`, throws a | `titleField` | First shared text or textarea field | Names a record in toasts, dialogs and relation pickers. `null` means the type has no title. | | `colorField` | none | A shared field with a color, shown as a swatch next to the title. The blog's categories use it. | | `navigation` | `true` | Set it to `false` to keep the screen but drop its sidebar link. | -| `permissionModule` | The id without the plugin part | Staff permission module name, for example `article` for `example.article`. | +| `permissionModule` | The id without the plugin part | Staff permission module name, for example `story` for `newsroom.story`. | ## Bulk actions @@ -111,12 +111,12 @@ By default the form shows every field except `field.blocks()`, one after another withoutBackground className="*:max-w-full" src={createDialog} - alt="The Create Article dialog with Title, Slug, Reference code, Excerpt and Views fields" + alt="The Create Story dialog with Title, Slug, Reference code, Excerpt and Views fields" /> -To pick and order fields, set `admin.form.fields`. To group them into titled cards, use `admin.form.sections` instead: +To pick and order fields, set `admin.form.fields`. To group them into titled cards, use `admin.form.sections` instead. For the newsroom's stories, that could look like this: -```ts title="plugins/example/src/content/article.ts" +```ts title="Example: story form sections" admin: { form: { sections: [ @@ -134,13 +134,13 @@ admin: { The heading and an optional description come from the plugin's locale file, at `content..form.`. Without a message, the heading is the humanized name ("Main"). -```json title="plugins/example/src/locales/en.json" +```json title="Example: section headings in en.json" { - "@vitnode/example": { + "newsroom": { "content": { - "article": { + "story": { "form": { - "main": { "title": "Article", "desc": "What readers see first." }, + "main": { "title": "Story", "desc": "What readers see first." }, "media": { "title": "Media" } } } @@ -235,7 +235,7 @@ Add `skeleton` when the component is bigger than its field kind suggests, so the For a layout the options cannot express, such as a main column with a sidebar, pass `forms.layout` to `contentTypeAdmin`. A custom layout is used only in page mode, so set `create.mode` and `edit.mode` to `"page"` first. -```tsx title="plugins/example/src/views/admin/article/form-layout.tsx" +```tsx title="Example: a story form layout" import type { ContentFormLayoutProps } from "@vitnode/core/lib/plugin"; import { @@ -252,7 +252,7 @@ import { const SIDEBAR_FIELDS = ["category", "author", "featured", "noIndex"]; -export const ArticleFormLayout = ({ mode }: ContentFormLayoutProps) => ( +export const StoryFormLayout = ({ mode }: ContentFormLayoutProps) => ( <> @@ -276,10 +276,11 @@ export const ArticleFormLayout = ({ mode }: ContentFormLayoutProps) => ( ); ``` -```tsx title="plugins/example/src/admin/content.tsx" +```tsx title="Example: register the layout in src/admin/content.tsx" contentTypeAdmin({ - ...exampleArticleNav, - forms: { layout: ArticleFormLayout }, // [!code ++] + definition: storyContentType, + icon: , + forms: { layout: StoryFormLayout }, // [!code ++] }), ``` @@ -398,6 +399,6 @@ A few things that keep it smooth: ## Check the result -Save the definition and open **AdminCP → Example → Articles**. `pnpm dev` reloads both the screens and the API, which validates sorting and form input against the same definition. +Save the definition and open **AdminCP → Newsroom → Stories**. `pnpm dev` reloads both the screens and the API, which validates sorting and form input against the same definition. Next, give the screens the permissions they need in [Production checklist](/docs/content-engine/production), or read every option in the [reference](/docs/content-engine/reference). diff --git a/apps/web/content/docs/content-engine/approval-notices-rejected.webp b/apps/web/content/docs/content-engine/approval-notices-rejected.webp new file mode 100644 index 0000000000..72ead32dd0 Binary files /dev/null and b/apps/web/content/docs/content-engine/approval-notices-rejected.webp differ diff --git a/apps/web/content/docs/content-engine/approval-notices.mdx b/apps/web/content/docs/content-engine/approval-notices.mdx new file mode 100644 index 0000000000..af60e43da7 --- /dev/null +++ b/apps/web/content/docs/content-engine/approval-notices.mdx @@ -0,0 +1,285 @@ +--- +title: Approval notices +description: Extend a VitNode Content Engine edit page so authors see pending and rejected submissions. Open the form on the proposed values, show a notice from the approval field, and pick the right message after a save that waits for a moderator. +icon: Hourglass +implementation: + objective: Show authors on an edit page whether their change waits for a moderator or was declined. + decisions: + - Which edit page? Offer edit pages of content types filed in a container whose definition has `policies`; without one, nothing ever waits and this guide does not apply. + constraints: + - Read `approval` from `GET /{id}/edit` and from every `PATCH /{id}` answer; never infer it from `status` alone. + - Open a pending edit on `approval.values`, parsed with Zod, and fall back to `values` for every field it lacks. + - Derive notices from `approval` only; never from a URL parameter or from `status` alone. + related: + - title: Approvals + href: /docs/content-engine/approvals + - title: Publishing rules + href: /docs/content-engine/container-policies + - title: Edit form + href: /docs/content-engine/public-write-edit +--- + +import { ImgDocs } from "@/components/fumadocs/img"; +import pending from "./public-write-edit-pending.webp"; +import rejected from "./approval-notices-rejected.webp"; + +When a container's [publishing rules](/docs/content-engine/container-policies) hold edits for a moderator, a successful save does not mean the change is live. This guide adds what an author needs on the edit page: the form opens on their pending proposal, a notice says what is waiting or why it was declined, and the toast after a save tells the truth. + +The example is the Forum plugin's edit page, `plugins/forum/src/pages/topic-edit-page.tsx`. The code below is excerpts from that file. + +## Before you begin + +Approval only exists for records filed in a container whose definition has `policies`. Check that your content type has all three: + +- **A container with policies.** The forum's `forumContainers` in `plugins/forum/src/content/forums.ts` sets `policies: { defaults: { ... } }`. +- **A record filed in it.** `forum.topic` has `forum: field.container({ definition: forumContainers, required: true })`. A comment can inherit its topic's forum with `field.relation({ target: () => topicContentType, container: true })`. +- **A rule that waits.** In the AdminCP, open `/admin/containers/forum/forums`, choose **Publishing** in a forum's row menu, set **Edits to published posts** to **Wait for approval** for topics and choose **Save rules**. + +You also need an edit page that loads `GET /{id}/edit` and saves with `useContentPublicWrite`, like the [Edit form](/docs/content-engine/public-write-edit), and a member who is not a moderator in that forum. The excerpts use two names from such a page: + +- **`EditableTopic`** is the record type the loader returns, `Extract["record"]`, declared like `EditableArticle` in [Load the editable values](/docs/content-engine/public-write-edit#load-the-editable-values). +- **`TopicFormValues`** is the values type of the shared form, exported from `plugins/forum/src/pages/topic-form.tsx` like `ArticleFormValues` from the article form. + +The newsroom tutorial's articles are not filed in a container, so `approval` is always `null` for them. A content type without a container keeps its drafts as plain drafts, even with `create.status: "draft"`; see [Drafts and approval](/docs/content-engine/approvals#drafts-and-approval). + +## Three kinds of values + +With approval on, an edit page handles three different things. Keep them apart, or the author will think a change is live when it is not: + +| What | Where it comes from | Readers see it | +| --- | --- | --- | +| **Live values** | `record.values` from `GET /{id}/edit` and `PATCH /{id}` | Yes | +| **Proposed values** | `record.approval.values`, when `approval.status` is `"pending"` and `approval.kind` is `"edit"` | No, until a moderator approves | +| **A save that waits** | `PATCH /{id}` answers `200` with `approval.status: "pending"`; `values` is still the live record | No | + + + + + +### Open the form on the proposal + +When the author reopens a page whose edit is pending, the form should show what they sent, not the live text. `approval.values` is untyped, so parse the fields the form knows and fall back to the live value for the rest: + +```tsx title="plugins/forum/src/pages/topic-edit-page.tsx (excerpt)" +import { zodRichTextDocument } from "@vitnode/core/content"; +import { z } from "zod"; + +import type { TopicFormValues } from "./topic-form"; + +const zodProposedValues = z + .object({ + body: zodRichTextDocument.optional(), + title: z.string().optional(), + }) + .catch({}); + +const formValuesOf = (record: EditableTopic): TopicFormValues => { + const { approval } = record; + const proposed = + approval?.status === "pending" && approval.kind === "edit" + ? zodProposedValues.parse(approval.values ?? {}) + : {}; + + return { + body: proposed.body ?? record.values.body, + title: proposed.title ?? record.values.title, + }; +}; +``` + +Use `formValuesOf(record)` wherever the basic edit form used `record.values` for its defaults. + + + + + +### Show a notice from the server's answer + +`approval` is `null` when nothing waits. Otherwise it is a pending submission, or a rejected one nothing has replaced yet. The notice reads that object and nothing else: + +```tsx title="plugins/forum/src/pages/topic-edit-page.tsx (excerpt)" +import type { ContentApprovalState } from "@vitnode/core/content"; + +import { + Alert, + AlertDescription, + AlertTitle, +} from "@vitnode/core/components/ui/alert"; +import { HrefLink } from "@vitnode/core/tanstack/link"; +import { HourglassIcon, XCircleIcon } from "lucide-react"; +import { useTranslations } from "use-intl"; + +import { FORUM_PENDING_HREF } from "./forum-links"; + +/** Reads only the server's `approval`: neither the URL nor a draft status adds a notice. */ +const ApprovalNotice = ({ + approval, +}: { + /** Only what the notice reads, so the edit route's JSON answer fits as is. */ + approval: null | Pick; +}) => { + const t = useTranslations("@vitnode/forum.topics.approval"); + + if (approval?.status === "rejected") { + return ( + } variant="destructive"> + {t("rejected.title")} + + {approval.reason + ? t("rejected.reason", { reason: approval.reason }) + : t("rejected.desc")} + + + ); + } + + if (approval?.status === "pending" && approval.kind === "edit") { + return ( + } variant="info"> + {t("pending_edit.title")} + {t("pending_edit.desc")} + + ); + } + + if (approval?.status === "pending" && approval.kind === "create") { + return ( + } variant="info"> + {t("pending_create.title")} + + {t("pending_create.desc")}{" "} + + {t("pending_create.link")} + + + + ); + } + + return null; +}; +``` + +- **Rejected** shows the moderator's reason when there is one. The author can edit and save again, which opens a new submission. +- **Pending edit** needs `status: "pending"` and `kind: "edit"`, and says readers still see the published version. +- **Pending create** needs `status: "pending"` and `kind: "create"`, which means a new topic waits for its first approval. It links to the author's list of posts awaiting approval, `/forum/me/pending`. +- **Nothing else.** `approval: null` renders no notice, even for a draft. A draft is not a submission, so the notice never looks at `status`, and it never reads the URL. + +The approval state comes from the server. The route's loader awaits `GET /{id}/edit` before the page renders, and its answer carries `approval`. Every save replaces it with the one `PATCH /{id}` returns. + + + + + +### Keep the answer's state after a save + +`PATCH /{id}` answers with the record's `approval`, `slug` and `status`. Keep them in state, so the notice and the **View topic** link follow every save, and pick the toast from `approval`: + +```tsx title="plugins/forum/src/pages/topic-edit-page.tsx (excerpt)" +import { useQueryClient } from "@tanstack/react-query"; +import { useState } from "react"; +import { toast } from "sonner"; + +import { invalidateForumTopics } from "./forum-queries"; + +// Inside the form component, next to `useContentPublicWrite`: +const queryClient = useQueryClient(); +const [current, setCurrent] = useState({ + approval: record.approval, + slug: record.slug, + status: record.status, +}); +const [defaults, setDefaults] = useState(() => formValuesOf(record)); + +// In onSubmit, after `const updated = await write.submit(...)`: +if (!updated) return; + +setCurrent({ + approval: updated.approval, + slug: updated.slug, + status: updated.status, +}); +setDefaults(values); +form.reset(values); +await invalidateForumTopics(queryClient); + +if (updated.approval?.status === "pending") { + toast.info(t("edit.pending.title"), { description: t("edit.pending.desc") }); +} else { + toast.success(t("edit.success.title"), { + description: t("edit.success.desc", { title: values.title }), + }); +} +``` + +Render `` above `{write.conflict}` and the form. `invalidateForumTopics` refreshes the forum's cached topic lists; in your plugin, invalidate your own list's query key, as the [Edit form](/docs/content-engine/public-write-edit#update-the-form-and-the-cached-lists) does. The server checks a proposal against the record's `version` like any edit, so the conflict alert keeps working. + + + + + +### Add the labels + +The forum keeps the notice labels under `topics.approval` and the toast under `topics.edit` in `plugins/forum/src/locales/en.json`: + +```json title="plugins/forum/src/locales/en.json (excerpt)" +{ + "@vitnode/forum": { + "topics": { + "approval": { + "rejected": { + "title": "A moderator declined this", + "desc": "Your submission was not approved. You can edit it and send it again.", + "reason": "Reason: {reason}" + }, + "pending_edit": { + "title": "Your changes are waiting for approval", + "desc": "Readers still see the published version. You are looking at your proposed changes." + }, + "pending_create": { + "title": "Waiting for a moderator", + "desc": "Your topic goes live as soon as a moderator approves it. You can still polish it here.", + "link": "See all your posts awaiting approval." + } + }, + "edit": { + "pending": { + "title": "Sent for approval", + "desc": "A moderator will review your changes. Readers see the published version until then." + }, + "success": { "title": "Topic saved", "desc": "\"{title}\" is up to date." } + } + } + } +} +``` + + + + + +## Check the result + +Sign in as a member who wrote a published topic in the forum that waits for edits, and open `/forum/topics/{id}/edit`. + +1. Change the title and click **Save changes**. **Sent for approval** appears, the notice says your changes wait, and the form keeps your new title. The topic's public page still shows the old title. +2. Reload the edit page. The form opens on your proposal, not on the live title. + + + +3. As a moderator with `can_approve` in that forum, reject the submission with a reason in the [ModCP](/docs/dev/moderation/review-approvals). Reload the author's edit page. The notice shows the reason, and the form shows the live text again. + + + +4. Save another change and approve it instead. After a reload the notice is gone, and both the form and the public page show the new title. +5. Open a topic with nothing pending, then one that staff unpublished in the AdminCP, which is a draft with `approval: null`. Neither shows a notice, even with `?submitted=1` added to the URL. Older Forum links carried that flag, and the edit page now ignores it. diff --git a/apps/web/content/docs/content-engine/approvals.mdx b/apps/web/content/docs/content-engine/approvals.mdx index a2c0aa2c9f..226e2ddaae 100644 --- a/apps/web/content/docs/content-engine/approvals.mdx +++ b/apps/web/content/docs/content-engine/approvals.mdx @@ -25,6 +25,19 @@ The server decides from the effective policy of the record's container. The clie A proposal is validated like a normal edit before it is stored, so a moderator never reviews something that could not be saved. +## Drafts and approval + +A draft and a pending submission are different states. Check both `status` and `approval` in every write response: + +| State | `status` | `approval` | Readers see it | +| --- | --- | --- | --- | +| **Published** | `"published"` | `null` | Yes | +| **Draft, nothing submitted** | `"draft"` | `null` | No, and no moderator is asked | +| **Pending approval** | `"draft"` for a new record, `"published"` for a pending edit | `{ status: "pending", ... }` | No, until a moderator approves | +| **Rejected** | unchanged | `{ status: "rejected", reason, ... }` | Only what was already published | + +A submission only opens for a record filed in a container whose definition has `policies`, either through its own `field.container()` or a `field.relation({ container: true })` to a record that has one. In that case `publicWrite.create.status: "draft"` is the fallback for `publish: "approve"`. Without a container, `create.status: "draft"` stores a plain draft with `approval: null`. It stays private until staff publish it in the AdminCP, and nobody is told to review it. + Some people skip the queue. A moderator holding `can_approve` in the container publishes and edits their own posts immediately, and so does a root role. A moderator editing through `can_edit` always applies the edit at once, even to someone else's record. Those permissions are [scoped to containers](/docs/dev/moderation/scoped-permissions#container-scoped-permissions). AdminCP staff work outside this flow: their edits apply directly, as always. @@ -92,40 +105,10 @@ Each item has `kind`, `status`, `title`, `contentTypeId`, `itemId`, the containe Readers never see a pending record, so the author needs to hear what happened. Three places cover it, all from the `approval` object: -1. **After posting.** When `record.approval?.status === "pending"`, send the author to the record's edit page instead of its public page, which answers `404` until approval. See [Public create form](/docs/content-engine/public-write-frontend#when-a-post-waits-for-a-moderator). -2. **On the edit page.** `GET /{id}/edit` returns the pending or rejected submission. Show a notice above the form. The Forum plugin's `topic-edit-page.tsx` does it like this: - -```tsx title="plugins/forum/src/pages/topic-edit-page.tsx" -const ApprovalNotice = ({ approval, status }: { approval: ContentApprovalState | null; status: string }) => { - const t = useTranslations("@vitnode/forum.topics.approval"); - - if (approval?.status === "rejected") { - return ( - - {t("rejected.title")} - - {approval.reason ? t("rejected.reason", { reason: approval.reason }) : t("rejected.desc")} - - - ); - } - - if (approval?.status === "pending" && approval.kind === "edit") { - return {t("pending_edit.title")}; - } - - if (status === "draft" && approval?.status === "pending") { - return {t("pending_create.title")}; - } - - return null; -}; -``` - +1. **After posting.** When `record.approval?.status === "pending"`, send the author to the record's edit page instead of its public page, which answers `404` until approval. See [Create form](/docs/content-engine/public-write-frontend#write-the-page). +2. **On the edit page.** `GET /{id}/edit` returns the pending or rejected submission. Open the form on the proposal and show a notice above it. [Approval notices](/docs/content-engine/approval-notices) builds this on the Forum plugin's edit page. 3. **In a "my submissions" list.** `GET /moderation/approvals/mine`, shown above, lists everything the author sent. The forum renders it at `/forum/me/pending`. -For a pending edit, open the form on `approval.values`, the proposal, instead of the live text, so the author keeps editing what they sent. - ## What a decision does A moderator approves or rejects from the ModCP or through the [approval routes](/docs/dev/moderation/reference#approval-routes). @@ -154,7 +137,7 @@ A listener on `content.approval.rejected` is a good place to tell the author why ## Check the result -In the AdminCP, set a forum's policy to **Approve** for new topics, as in [Publishing policies](/docs/content-engine/container-policies). Sign in as a member and start a topic there. You land on the topic's edit page with the waiting notice, and the topic stays out of the forum until a moderator approves it in the [ModCP](/docs/dev/moderation/review-approvals). +In the AdminCP, set a forum's policy to **Approve** for new topics, as in [Publishing rules](/docs/content-engine/container-policies). Sign in as a member and start a topic there. You land on the topic's edit page with the waiting notice, and the topic stays out of the forum until a moderator approves it in the [ModCP](/docs/dev/moderation/review-approvals). +export const latestStoriesQuery = () => queryOptions({ - queryKey: ['@vitnode/example', 'articles', 'latest'], + queryKey: ['newsroom', 'stories', 'latest'], queryFn: async () => { const response = await fetcher({ - plugin: '@vitnode/example', + plugin: 'newsroom', method: 'get', - module: 'content/articles', + module: 'content/stories', path: '/', args: { query: { first: 5 } }, }) if (response.status !== 200) { - throw new Error(`Loading articles answered ${response.status}.`) + throw new Error(`Loading stories answered ${response.status}.`) } const { edges } = await response.json() @@ -66,7 +66,7 @@ export const latestArticlesQuery = () => export const route = defineRoute({ load: async ({ context }) => { await context.queryClient.query({ - ...latestArticlesQuery(), + ...latestStoriesQuery(), staleTime: 60_000, }) }, @@ -75,7 +75,7 @@ export const route = defineRoute({ Each server render starts with an empty query client, so a full page load is always fresh. The `staleTime` only decides how long a visitor's open tab reuses the list during client-side navigation. `staleTime: "static"` keeps it until the tab reloads. -Publishing in the AdminCP refreshes the AdminCP's own list, item and search queries. It cannot reach a visitor's browser, so pick a `staleTime` you are happy to wait out. For your own write routes, call `queryClient.invalidateQueries({ queryKey: ["@vitnode/example", "articles"] })` after the write. +Publishing in the AdminCP refreshes the AdminCP's own list, item and search queries. It cannot reach a visitor's browser, so pick a `staleTime` you are happy to wait out. For your own write routes, call `queryClient.invalidateQueries({ queryKey: ["newsroom", "stories"] })` after the write. Public routes also send an `x-vitnode-cache-tags` header naming the record they read, such as `content:blog.post:item:3`, or the content type for a list. The page cache stores a page with these tags and invalidates it after every write, hard for a hide, delete, unpublish or slug change. [How cached pages stay fresh](/docs/dev/cache/pages-invalidation#content-engine-invalidation) explains the rules. @@ -83,24 +83,24 @@ Public routes also send an `x-vitnode-cache-tags` header naming the record they When a front end caches public reads under tags, it needs to know which tags a write affects. `@vitnode/core/content` exports pure string builders for that. They read nothing and need no server: -| Builder | Example for `example.article` | -| --------------------------------------------------- | ------------------------------------------------------- | -| `contentPublicListTag(typeId, locale?)` | `content:example.article:list` | -| `contentPublicItemTag(typeId, id, locale?)` | `content:example.article:item:3` | -| `contentPublicSlugTag(typeId, slug, locale?)` | `content:example.article:slug:hello-content-engine` | -| `contentDeliveryTag(typeId, id, locale?)` | `content:example.article:delivery:3` | -| `contentDeliveryRedirectTag(typeId, slug, locale?)` | `content:example.article:redirect:hello-content-engine` | -| `contentDeliverySitemapTag(typeId, locale?)` | `content:example.article:sitemap` | +| Builder | Example for `newsroom.story` | +| --------------------------------------------------- | ------------------------------------------------------ | +| `contentPublicListTag(typeId, locale?)` | `content:newsroom.story:list` | +| `contentPublicItemTag(typeId, id, locale?)` | `content:newsroom.story:item:3` | +| `contentPublicSlugTag(typeId, slug, locale?)` | `content:newsroom.story:slug:hello-content-engine` | +| `contentDeliveryTag(typeId, id, locale?)` | `content:newsroom.story:delivery:3` | +| `contentDeliveryRedirectTag(typeId, slug, locale?)` | `content:newsroom.story:redirect:hello-content-engine` | +| `contentDeliverySitemapTag(typeId, locale?)` | `content:newsroom.story:sitemap` | -A locale goes after the kind, so a localized type's Polish list is `content:example.localized-article:list:pl`. An id goes in as text, so a [uuid or bigint](/docs/content-engine/id-strategy) record's item tag carries its exact UUID or decimal string. +A locale goes after the kind, so a localized type's Polish list is `content:newsroom.bulletin:list:pl`. An id goes in as text, so a [uuid or bigint](/docs/content-engine/id-strategy) record's item tag carries its exact UUID or decimal string. -`contentInvalidationTags` turns a description of one write into the exact tags to expire. Publishing article `3` returns all six tags above: +`contentInvalidationTags` turns a description of one write into the exact tags to expire. Publishing story `3` returns all six tags above: ```ts title="revalidate.ts" import { contentInvalidationTags } from '@vitnode/core/content' const tags = contentInvalidationTags({ - contentTypeId: 'example.article', + contentTypeId: 'newsroom.story', id: 3, slugs: ['hello-content-engine'], wasPublic: false, @@ -127,7 +127,7 @@ VitNode then sends a `POST` to `{origin}/api/vitnode/content/revalidate` in thes | Change | Notifies origins | | ------------------------------------------------------------------------------------------- | ---------------------------------------------- | -| A [scheduled](/docs/content-engine/editorial#schedule-publishing) publish or unpublish runs | Yes | +| A [scheduled](/docs/content-engine/editorial-scheduling) publish or unpublish runs | Yes | | A record is [hidden or unhidden](/docs/content-engine/content-visibility) | Yes, when that changes what the public can see | | A publish, unpublish, edit or delete from the AdminCP or the API | No | diff --git a/apps/web/content/docs/content-engine/container-frontend.mdx b/apps/web/content/docs/content-engine/container-frontend.mdx index b3aa2b97f4..a29055bac2 100644 --- a/apps/web/content/docs/content-engine/container-frontend.mdx +++ b/apps/web/content/docs/content-engine/container-frontend.mdx @@ -1,16 +1,16 @@ --- -title: Containers on the frontend -description: Read Content Engine containers with the typed fetcher, render a nested board tree, list the records of a container and its subcontainers with includeDescendants, and let members create content inside a container through publicWrite. +title: Container pages +description: Read Content Engine containers with the typed fetcher, render a nested desk tree, list the records of a container and its subcontainers with includeDescendants, and let members create content inside a container through publicWrite. icon: AppWindow --- -This guide builds the member-facing side of the example plugin's boards: a `/boards` page with the nested board tree, a `/boards/:slug` page that lists the discussions of a board and every board below it, and a board picker on the "Start a discussion" form. +This guide builds the member-facing side of the newsroom plugin's desks: a `/desks` page with the nested desk tree, a `/desks/:slug` page that lists the letters to the editor sent to a desk and every desk below it, and a desk picker on the "Write a letter" form. -The finished pages are in `plugins/example/src/pages/`, in the `board-*` and `discussion-*` files. The code below is the same, with some styling trimmed. +The finished pages are in `plugins/newsroom/src/pages/`, in the `desk-*` and `letter-*` files. The code below is the same, with some styling trimmed. ## Before you begin -You need a container collection with `publicApi`, and a content type whose `field.container()` is public, filterable and writable on create, as set up in [Containers](/docs/content-engine/containers#add-a-container-collection). The example's collection has `publicApi.path: "boards"`, so the fetcher's module is `content/boards`. +You need a container collection with `publicApi`, and a content type whose `field.container()` is public, filterable and writable on create, as set up in [Containers](/docs/content-engine/containers#add-a-container-collection). The newsroom's collection has `publicApi.path: "desks"`, so the fetcher's module is `content/desks`. The collection's public routes are read-only and need no session: @@ -27,43 +27,43 @@ Each item is `{ id, parentId, name, slug, position, depth, metadata }`, where `m ### Add the routes -Register the two board pages in `src/routes.ts`, and give the create page a `search` parser so it can read `?board=`. A `pendingComponent` is drawn while the loader runs, so import it directly rather than through `lazy()`: +Register the two desk pages in `src/routes.ts`, and give the create page a `search` parser so it can read `?desk=`. A `pendingComponent` is the fallback while a page suspends, not a navigation screen. Navigations keep the current page and show the top progress bar. It can render before the page's chunk arrives, so import it directly rather than through `lazy()`: -```ts title="plugins/example/src/routes.ts" +```ts title="plugins/newsroom/src/routes.ts" // [!code ++:4] import { FeedPendingSkeleton, RoutePendingSkeleton, } from "@vitnode/core/tanstack/pending"; -import { boardSearch } from "./pages/board-search"; // [!code ++] -import { discussionNewSearch } from "./pages/discussion-search"; // [!code ++] +import { deskSearch } from "./pages/desk-search"; // [!code ++] +import { letterNewSearch } from "./pages/letter-search"; // [!code ++] export const routes = definePluginRoutes([ // [!code ++:13] - page("/boards", { - component: lazy(() => import("./pages/boards-page")), - messages: ["@vitnode/example.boards"], + page("/desks", { + component: lazy(() => import("./pages/desks-page")), + messages: ["newsroom.desks"], pendingComponent: RoutePendingSkeleton, }), - page("/boards/:slug", { - component: lazy(() => import("./pages/board-page")), - messages: ["@vitnode/example.boards"], + page("/desks/:slug", { + component: lazy(() => import("./pages/desk-page")), + messages: ["newsroom.desks"], pendingComponent: FeedPendingSkeleton, - search: boardSearch, + search: deskSearch, }), - page("/discussions/new", { - component: lazy(() => import("./pages/discussion-new-page")), - messages: ["@vitnode/example.discussions"], + page("/letters/new", { + component: lazy(() => import("./pages/letter-new-page")), + messages: ["newsroom.letters"], requires: "authenticated", - search: discussionNewSearch, // [!code ++] + search: letterNewSearch, // [!code ++] }), ]); ``` -`boardSearch` keeps a valid `cursor` for paging through discussions, and `discussionNewSearch` keeps a positive `board` id. Both are small functions in `board-search.ts` and `discussion-search.ts`. The strings live under `@vitnode/example.boards` in `src/locales/en.json`. +`deskSearch` keeps a valid `cursor` for paging through letters, and `letterNewSearch` keeps a positive `desk` id. Both are small functions in `desk-search.ts` and `letter-search.ts`. The strings live under `newsroom.desks` in `src/locales/en.json`. @@ -71,41 +71,51 @@ export const routes = definePluginRoutes([ ### Load the tree -Create a helper that reads every board. The list comes back in tree order, flat, so one request loads the whole tree: +Create a helper that reads every desk. The list comes back in tree order, flat, so one request loads the whole tree: -```ts title="plugins/example/src/pages/boards-data.ts" +```ts title="plugins/newsroom/src/pages/desks-data.ts" import { fetcher } from "@vitnode/core/tanstack/fetcher"; import { manifest } from "@/manifest.gen"; -export const fetchBoards = async () => { +export const fetchDesks = async () => { const res = await fetcher({ plugin: manifest.id, method: "get", - module: "content/boards", + module: "content/desks", path: "/", args: { query: {} }, }); - if (res.status !== 200) throw new Error("Loading the boards failed."); + if (res.status !== 200) throw new Error("Loading the desks failed."); const { items } = await res.json(); return items; }; -export type Board = Awaited>[number]; +export type Desk = Awaited>[number]; const HEX_COLOR = /^#(?:[0-9a-f]{3}|[0-9a-f]{6})$/i; -export const boardColor = (board: Pick): null | string => - typeof board.metadata.color === "string" && - HEX_COLOR.test(board.metadata.color) - ? board.metadata.color +export const deskColor = (desk: Pick): null | string => + typeof desk.metadata.color === "string" && HEX_COLOR.test(desk.metadata.color) + ? desk.metadata.color : null; ``` -`Board` is inferred from the API, so `board.metadata.color` is typed as `string | null` from the definition's fields, and `board.metadata.locked` is a compile error because `locked` is not public. `boardColor` accepts only a hex value before it goes into a `style` attribute, because an admin typed it. +`Desk` is inferred from the API, so `desk.metadata.color` is typed as `string | null` from the definition's fields, and `desk.metadata.locked` is a compile error because `locked` is not public. `deskColor` accepts only a hex value before it goes into a `style` attribute, because an admin typed it. + +The pages read the list through one TanStack Query entry, so the desks page, the letter form and a desk page share it: + +```ts title="plugins/newsroom/src/pages/desk-queries.ts (excerpt)" +export const desksQueryOptions = () => ({ + meta: LOADER_DATA_QUERY_META, + queryFn: fetchDesks, + queryKey: deskQueryKeys.desks(), + staleTime: RECORD_STALE_TIME, +}); +``` @@ -115,16 +125,18 @@ export const boardColor = (board: Pick): null | string => `contentContainerTree` from `@vitnode/core/content` nests a tree-ordered list: each node gets a `children` array. Render it recursively as nested lists inside a labelled `nav`, so screen readers announce the structure: -```tsx title="plugins/example/src/pages/board-tree.tsx" +```tsx title="plugins/newsroom/src/pages/desk-tree.tsx" import type { ContentContainerTreeNode } from "@vitnode/core/content"; -import type { Board } from "./boards-data"; +import { HrefLink } from "@vitnode/core/tanstack/link"; -import { boardHref } from "./board-search"; -import { boardColor } from "./boards-data"; +import type { Desk } from "./desks-data"; -export const BoardColorDot = ({ board }: { board: Board }) => { - const color = boardColor(board); +import { deskHref } from "./desk-search"; +import { deskColor } from "./desks-data"; + +export const DeskColorDot = ({ desk }: { desk: Desk }) => { + const color = deskColor(desk); return ( { ); }; -const BoardTreeItems = ({ +const DeskTreeItems = ({ nodes, }: { - nodes: ContentContainerTreeNode[]; + nodes: ContentContainerTreeNode[]; }) => ( ); -export const BoardTree = ({ +export const DeskTree = ({ label, nodes, }: { label: string; - nodes: ContentContainerTreeNode[]; + nodes: ContentContainerTreeNode[]; }) => ( ); ``` @@ -187,143 +199,176 @@ export const BoardTree = ({ -### Add the boards page +### Add the desks page -The route's `load` fetches and nests the tree, and the page shows it, or an empty state when no board exists yet: +The route's `load` reads the cached desks and nests them, and the page shows the tree, or an empty state when no desk exists yet. The same `load` also warms the latest letters list that the page shows under the tree: -```tsx title="plugins/example/src/pages/boards-page.tsx" +```tsx title="plugins/newsroom/src/pages/desks-page.tsx (excerpt)" import { contentContainerTree } from "@vitnode/core/content"; import { - definePluginRoute, - type PluginRoutePageProps, -} from "@vitnode/core/routing"; + defineRoute, + type RouteLoadContext, +} from "@vitnode/core/tanstack/plugin-routes"; import { useTranslations } from "use-intl"; -import { BoardTree } from "./board-tree"; -import { fetchBoards } from "./boards-data"; +import { desksQueryOptions } from "./desk-queries"; +import { DeskTree } from "./desk-tree"; +import { LatestLetters } from "./latest-letters"; +import { lettersQueryOptions } from "./letter-list"; -const loadBoards = async () => ({ - tree: contentContainerTree(await fetchBoards()), -}); +const loadDesks = async ( + queryClient: RouteLoadContext["queryClient"], + locale: string, +) => { + const [desks] = await Promise.all([ + queryClient.query(desksQueryOptions()), + // No `LOADER_DATA_QUERY_META`: the entry is dehydrated, so + // `LatestLetters` finds it in the browser and renders on the server. + queryClient + .query(lettersQueryOptions({ locale, search: {} })) + .catch(() => null), + ]); + + return { tree: contentContainerTree(desks) }; +}; -type BoardsPageData = Awaited>; +type DesksPageData = Awaited>; -export const route = definePluginRoute({ - load: loadBoards, +export const route = defineRoute({ + load: async ({ context }) => + await loadDesks(context.queryClient, context.locale), head: ({ t }) => ({ - description: t("@vitnode/example.boards.desc"), - title: t("@vitnode/example.boards.title"), + description: t("newsroom.desks.desc"), + title: t("newsroom.desks.title"), }), + + breadcrumb: DesksBreadcrumb, }); -const BoardsPage = ({ loaderData }: PluginRoutePageProps) => { - const t = useTranslations("@vitnode/example.boards"); +const DesksPage = ({ loaderData }: PluginRoutePageProps) => { + const t = useTranslations("newsroom.desks"); return ( -
-

- {t("title")} -

+
+ {/* The header: title, description and a "Write a letter" button */} {loaderData.tree.length === 0 ? ( -

- {t("empty.desc")} -

+ {/* "No desks yet" */} ) : ( - + )} + +
); }; -export default BoardsPage; +export default DesksPage; ``` -The example's page also has a breadcrumb, a **New discussion** button and an `Empty` state with an icon. +The full page also has the breadcrumb component, a **Write a letter** button and an `Empty` state with an icon. -### Add the board page +### Add the desk page -`/boards/:slug` makes two requests. The detail route returns the board, its ancestors for the breadcrumb and its child boards. The discussions list is filtered by the board, and `includeDescendants: "true"` widens the filter to every board below it, so **Frontend** also lists what was posted in **React**: +`/desks/:slug` makes two requests. The detail route returns the desk, its ancestors for the breadcrumb and its child desks. The letters list is filtered by the desk, and `includeDescendants: "true"` widens the filter to every desk below it, so **Politics** also lists what was sent to **Elections**: -```tsx title="plugins/example/src/pages/board-page.tsx" +```tsx title="plugins/newsroom/src/pages/desk-page.tsx (excerpt)" import { contentPageItem } from "@vitnode/core/tanstack/content"; import { fetcher } from "@vitnode/core/tanstack/fetcher"; import { z } from "zod"; import { manifest } from "@/manifest.gen"; -import type { BoardSearch } from "./board-search"; +import type { DeskSearch } from "./desk-search"; + +import { cachedDeskId, queryDesk } from "./desk-queries"; -const zodDiscussionRow = z.object({ +const PAGE_SIZE = 20; + +const zodLetterRow = z.object({ id: z.number(), publishedAt: z.string().nullable(), slug: z.string().nullable(), title: z.string(), }); -const loadBoard = async (idOrSlug: string, { cursor }: BoardSearch) => { - const detail = await fetcher({ - plugin: manifest.id, - method: "get", - module: "content/boards", - path: "/{idOrSlug}", - args: { params: { idOrSlug: encodeURIComponent(idOrSlug) } }, - }); - - if (detail.status !== 200 && detail.status !== 404) { - throw new Error("Loading the board failed."); - } - - // A 404 becomes the route's not-found page. - const { ancestors, children, container } = contentPageItem( - detail.status === 200 ? await detail.json() : null, - ); - +const fetchLetters = async (deskId: number, cursor?: string) => { const filters = { - board: String(container.id), + desk: String(deskId), includeDescendants: "true", }; const list = await fetcher({ plugin: manifest.id, method: "get", - module: "content/discussions", + module: "content/letters", path: "/", args: { - query: { ...filters, first: "20", ...(cursor ? { cursor } : {}) }, + query: { + ...filters, + first: String(PAGE_SIZE), + ...(cursor ? { cursor } : {}), + }, }, }); - if (list.status !== 200) throw new Error("Loading the discussions failed."); + if (list.status !== 200) throw new Error("Loading the letters failed."); const { edges, pageInfo } = await list.json(); + return { + letters: z.array(zodLetterRow).parse(edges), + nextCursor: pageInfo.hasNextPage ? pageInfo.endCursor : null, + }; +}; + +const loadDesk = async ( + queryClient: RouteLoadContext["queryClient"], + idOrSlug: string, + { cursor }: DeskSearch, +) => { + // The list needs the desk's id. When the desks index already cached it, + // both requests go out together instead of one after the other. + const knownId = cachedDeskId(queryClient, idOrSlug); + const [desk, early] = await Promise.all([ + queryDesk(queryClient, idOrSlug), + // A stale id (the desk was moved or deleted since) is not this page's + // error: the desk read below decides, and the list is fetched again. + knownId === null ? null : fetchLetters(knownId, cursor).catch(() => null), + ]); + const { ancestors, children, container } = contentPageItem(desk); + const page = + early && knownId === container.id + ? early + : await fetchLetters(container.id, cursor); + return { ancestors, children, container, - discussions: z.array(zodDiscussionRow).parse(edges), + ...page, isFirstPage: cursor === undefined, - nextCursor: pageInfo.hasNextPage ? pageInfo.endCursor : null, }; }; ``` -- The filter parameter is named after the field, `board`, and works only because `board` is in `publicApi.filterableFields`. Without `includeDescendants`, it matches that one board. +- `queryDesk` in `desk-queries.ts` calls `GET content/desks/{idOrSlug}` and returns `null` for a `404`. `contentPageItem(null)` turns that into the route's not-found page. +- The filter parameter is named after the field, `desk`, and works only because `desk` is in `publicApi.filterableFields`. Without `includeDescendants`, it matches that one desk. - Filter parameters are not part of the inferred query type yet, so they are spread in from a small object instead of written as literal keys. -- An unknown board id in the filter answers `404`, not an empty list, so a stale link never looks like an empty board. -- `nextCursor` goes into `?cursor=` for an **Older discussions** link. Pass it back unchanged. +- An unknown desk id in the filter answers `404`, not an empty list, so a stale link never looks like an empty desk. +- `nextCursor` goes into `?cursor=` for an **Older letters** link. Pass it back unchanged. Declare the trail as a breadcrumb group, one crumb per ancestor, and give the route a `notFound` component for the 404: -```tsx title="plugins/example/src/pages/board-page.tsx" -export const route = definePluginRoute({ - load: async ({ params, search }) => await loadBoard(params.slug, search), +```tsx title="plugins/newsroom/src/pages/desk-page.tsx (excerpt)" +export const route = defineRoute({ + load: async ({ context, params, search }) => + await loadDesk(context.queryClient, params.slug, search), head: ({ loaderData }) => ({ description: loaderData?.container.metadata.description ?? undefined, @@ -334,15 +379,17 @@ export const route = definePluginRoute({ group: ({ loaderData }) => ( <> - - + }> + {loaderData.ancestors.map(ancestor => ( - + } + > {ancestor.name} @@ -356,117 +403,127 @@ export const route = definePluginRoute({ ), }, - notFound: BoardNotFound, + notFound: DeskNotFound, }); ``` -The page renders the children with the same `BoardTree`, the discussions as a list of links, and a **New discussion** link to `/discussions/new?board={container.id}`. See the full component in `board-page.tsx`. +The page renders the children with the same `DeskTree`, the letters as a list of links, and a **Write a letter** link to `/letters/new?desk={container.id}`. See the full component in `desk-page.tsx`. -### Let members pick a board +### Let members pick a desk -Load the same tree for the create page and turn it into select options, indented by depth so the select reads like the tree: +Load the same cached desks for the create page and turn them into select options, indented by depth so the select reads like the tree: -```tsx title="plugins/example/src/pages/discussion-new-page.tsx" -const loadBoardOptions = async () => - (await fetchBoards()).map(board => ({ - label: `${"— ".repeat(board.depth - 1)}${board.name}`, - value: String(board.id), +```tsx title="plugins/newsroom/src/pages/letter-new-page.tsx (excerpt)" +const loadDeskOptions = async (queryClient: RouteLoadContext["queryClient"]) => + (await queryClient.query(desksQueryOptions())).map(desk => ({ + label: `${"— ".repeat(desk.depth - 1)}${desk.name}`, + value: String(desk.id), })); -export const route = definePluginRoute({ - load: loadBoardOptions, +export const route = defineRoute({ + load: async ({ context }) => await loadDeskOptions(context.queryClient), }); ``` -Add the board to the form schema. A select works with strings, so the ids become a `z.enum`, preselected from `?board=` when it names a real board: +Add the desk to the form schema. A select works with strings, so the ids become a `z.enum`, preselected from `?desk=` when it names a real desk: -```tsx title="plugins/example/src/pages/discussion-form.tsx" -const createDiscussionCreateFormSchema = ( +```tsx title="plugins/newsroom/src/pages/letter-form.tsx (excerpt)" +const createLetterCreateFormSchema = ( t: ( - key: "board.required" | "body.required" | "title.max" | "title.min", + key: "body.required" | "desk.required" | "title.max" | "title.min", ) => string, ids: [string, ...string[]], selected: string | undefined, ) => { - const board = z.enum(ids, { error: t("board.required") }); + const desk = z.enum(ids, { error: t("desk.required") }); - return createDiscussionFormSchema(t, { + return createLetterFormSchema(t, { body: emptyRichTextDocument(), title: "", }).extend({ - board: + desk: selected !== undefined && ids.includes(selected) - ? board.default(selected) - : board, + ? desk.default(selected) + : desk, }); }; ``` Render it with `AutoFormSelect` before the title and body fields: -```tsx title="plugins/example/src/pages/discussion-form.tsx" +```tsx title="plugins/newsroom/src/pages/letter-form.tsx (excerpt)" { - id: "board", + id: "desk", component: props => ( ), }, ``` -On submit, send the board as a number inside `values`, like every other writable field: +On submit, send the desk as a number inside `values`, like every other writable field. `LETTER_FIELDS` in `letter-form.tsx` is `["title", "body", "desk"]`: -```tsx title="plugins/example/src/pages/discussion-new-page.tsx" +```tsx title="plugins/newsroom/src/pages/letter-new-page.tsx (excerpt)" const write = useContentPublicWrite({ - fields: ["title", "body", "board"], + fields: LETTER_FIELDS, uniqueField: "title", }); -const onSubmit: AutoFormOnSubmit = async ( - { board, ...values }, +const onSubmit: AutoFormOnSubmit = async ( + { desk, ...values }, form, ) => { - const record = await write.submit(form, async () => - await fetcher({ - plugin: manifest.id, - method: "post", - module: "content/discussions", - path: "/", - args: { body: { values: { ...values, board: Number(board) } } }, - }), + const record = await write.submit( + form, + async () => + await fetcher({ + plugin: manifest.id, + method: "post", + module: "content/letters", + path: "/", + args: { body: { values: { ...values, desk: Number(desk) } } }, + }), + ); + if (!record) return; + + setIsLeaving(true); + await invalidateLetterLists(queryClient); + await navigate( + record.slug === null + ? letterEditHref(record.id) + : letterHref(record.slug, { created: true }), ); - // Redirect to the new discussion, as in Public create form. }; ``` -The server never trusts the id. It reads the board back inside the write's transaction and checks that it belongs to `example.boards`. A board that was deleted while the form was open, or one from another collection, answers `400` with an issue on the `board` field: +The server never trusts the id. It reads the desk back inside the write's transaction and checks that it belongs to `newsroom.desks`. A desk that was deleted while the form was open, or one from another collection, answers `400` with an issue on the `desk` field: ```json { "code": "CONTENT_INVALID_INPUT", - "contentTypeId": "example.discussion", + "contentTypeId": "newsroom.letter", "issues": [ { "code": "unavailable", - "field": "board", + "field": "desk", "message": "The selected container does not exist." } ] } ``` -Because `"board"` is in `fields`, [`useContentPublicWrite`](/docs/content-engine/public-write-frontend) shows that issue under the board select. +Because `"desk"` is in `fields`, [`useContentPublicWrite`](/docs/content-engine/public-write-frontend) shows that issue under the desk select. -The edit form stays as it was. A container field cannot be in `publicWrite.update.fields`, so moving a discussion to another board is a staff edit in the AdminCP. +The edit form stays as it was. A container field cannot be in `publicWrite.update.fields`, so moving a letter to another desk is a staff edit in the AdminCP. @@ -474,19 +531,19 @@ The edit form stays as it was. A container field cannot be in `publicWrite.updat ## Check the result -1. Create **Frontend** with **React** inside it in the AdminCP, then open `/boards`. Both boards show, **React** nested under **Frontend**. -2. Open **React** and click **New discussion**. The form opens with **— React** preselected. Post it. -3. Open `/boards/frontend`. The discussion is listed there too, because of `includeDescendants`, and the breadcrumb ends with **Boards** and **Frontend**. -4. Open `/boards/nope`. The not-found page shows. +1. Create **Politics** with **Elections** inside it in the AdminCP, then open `/desks`. Both desks show, **Elections** nested under **Politics**. +2. Open **Elections** and click **Write a letter**. The form opens with **— Elections** preselected. Send it. +3. Open `/desks/politics`. The letter is listed there too, because of `includeDescendants`, and the breadcrumb ends with **Desks** and **Politics**. +4. Open `/desks/nope`. The not-found page shows. ## Errors to handle -| Request | Answer | What the page does | -| ------------------------------------------------------------------- | ----------------------- | ------------------------------------------------------ | -| `GET content/boards/{idOrSlug}` for an unknown board | `404` | `contentPageItem(null)` renders the route's `notFound` | -| `GET content/boards?parent={id}` for an unknown parent | `404` | Treat it like a missing board | -| `GET content/discussions?board={id}` for an unknown board | `404` | Treat it like a missing board | -| `POST content/discussions` with a missing, deleted or foreign board | `400`, issue on `board` | Show the message under the select | -| `POST content/discussions` with no `board` at all | `400`, issue on `board` | The form's own `required` check catches it first | +| Request | Answer | What the page does | +| --------------------------------------------------------------- | ---------------------- | ------------------------------------------------------ | +| `GET content/desks/{idOrSlug}` for an unknown desk | `404` | `contentPageItem(null)` renders the route's `notFound` | +| `GET content/desks?parent={id}` for an unknown parent | `404` | Treat it like a missing desk | +| `GET content/letters?desk={id}` for an unknown desk | `404` | Treat it like a missing desk | +| `POST content/letters` with a missing, deleted or foreign desk | `400`, issue on `desk` | Show the message under the select | +| `POST content/letters` with no `desk` at all | `400`, issue on `desk` | The form's own `required` check catches it first | The [public write status codes](/docs/content-engine/public-write-api#status-codes) cover sign-in, permission and rate-limit answers, which work the same with or without containers. diff --git a/apps/web/content/docs/content-engine/container-hierarchy.mdx b/apps/web/content/docs/content-engine/container-hierarchy.mdx index 96a42ce2bf..03224bcc4e 100644 --- a/apps/web/content/docs/content-engine/container-hierarchy.mdx +++ b/apps/web/content/docs/content-engine/container-hierarchy.mdx @@ -1,18 +1,18 @@ --- -title: Container hierarchy +title: Container tree description: How Content Engine containers nest, order, move and delete, how concurrent edits stay consistent, and how to read and change a container tree from your own API routes with createContentContainerService. icon: ListTree --- -A container collection is a tree. This page explains the rules every change to that tree follows, whether it comes from the AdminCP or from your own code, and shows how to use the container service on the server. It uses the `example.boards` collection from [Containers](/docs/content-engine/containers). +A container collection is a tree. This page explains the rules every change to that tree follows, whether it comes from the AdminCP or from your own code, and shows how to use the container service on the server. It uses the newsroom plugin's `newsroom.desks` collection from [Containers](/docs/content-engine/containers). ## Depth -`depth` is 1 for a root, 2 for its children, and so on. `hierarchy.maxDepth` caps it and counts roots, so the example's `maxDepth: 3` allows **Frontend → React → Hooks** and nothing below **Hooks**. +`depth` is 1 for a root, 2 for its children, and so on. `hierarchy.maxDepth` caps it and counts roots, so the newsroom's `maxDepth: 3` allows **Politics → Elections → Local** and nothing below **Local**. - The default is 5, the ceiling is 10. - `hierarchy: { enabled: false }` keeps every container at the root: a flat, ordered list. Any `parentId` is refused. -- A move checks the whole subtree it carries. Moving a board that has children under a deep parent is refused if the deepest child would cross the limit. +- A move checks the whole subtree it carries. Moving a desk that has children under a deep parent is refused if the deepest child would cross the limit. ## Ordering @@ -48,15 +48,15 @@ Deleting never cascades. A container is deleted only when it is empty: - `hasChildren` (409): it still has child containers. Move or delete them first. - `hasContent` (409): a record of any content type that uses this collection still points at it. Move or delete those records first. -The database enforces the same rules. Both `parentId` and every `field.container()` column are foreign keys with `ON DELETE RESTRICT`, so a delete can never orphan a board or a discussion, even if another request slips in between the check and the delete. The remaining siblings are renumbered to close the gap. +The database enforces the same rules. Both `parentId` and every `field.container()` column are foreign keys with `ON DELETE RESTRICT`, so a delete can never orphan a desk or a letter, even if another request slips in between the check and the delete. The remaining siblings are renumbered to close the gap. ## Slugs -A slug addresses one container of one collection, so `frontend` can exist once in `example.boards` and once in another collection. A slug you pass is normalized (lowercase ASCII, dashes) and must be free, or the write fails with `slugTaken`. A missing slug is derived from the name, with `-2`, `-3` and so on added until it is free. A slug that normalizes to nothing, such as `"!!!"`, fails with `slugEmpty`. +A slug addresses one container of one collection, so `politics` can exist once in `newsroom.desks` and once in another collection. A slug you pass is normalized (lowercase ASCII, dashes) and must be free, or the write fails with `slugTaken`. A missing slug is derived from the name, with `-2`, `-3` and so on added until it is free. A slug that normalizes to nothing, such as `"!!!"`, fails with `slugEmpty`. ## Concurrent edits -Two admins dragging boards at the same moment cannot corrupt the tree. Every write to a collection: +Two admins dragging desks at the same moment cannot corrupt the tree. Every write to a collection: 1. Takes a transaction-scoped Postgres advisory lock for that collection, so writes to one collection run one after another. Different collections never wait for each other. 2. Reads the whole tree inside the transaction, so it plans against what the previous write committed. @@ -66,48 +66,48 @@ A collection holds at most 5,000 containers (`limitReached`, 409). The whole tre ## Use the service on the server -`createContentContainerService` is the one way to read and change a collection. The AdminCP routes and the public routes both use it, so your code gets the same rules, cache expiry and events. Call it inside any Hono handler with the request context: +`createContentContainerService` is the one way to read and change a collection. The AdminCP routes and the public routes both use it, so your code gets the same rules, cache expiry and events. Call it inside any Hono handler with the request context. Here is a route you could add to the newsroom plugin. It files a desk under an **Archive** desk and locks it: -```ts title="plugins/example/src/api/modules/boards/archive.route.ts" +```ts title="src/api/modules/desks/archive.route.ts" import { createContentContainerService } from "@vitnode/core/content/server"; import { HTTPException } from "hono/http-exception"; import { z } from "zod"; import { buildRoute } from "@/api/builders"; -import { boardContainers } from "@/content/boards"; +import { deskContainers } from "@/content/desks"; import { manifest } from "@/manifest.gen"; -export const archiveBoardRoute = buildRoute({ - adminStaffPermission: { module: "example_boards", permission: "can_edit" }, +export const archiveDeskRoute = buildRoute({ + adminStaffPermission: { module: "newsroom_desks", permission: "can_edit" }, route: { method: "post", path: "/{id}/archive", - description: "Move a board under the Archive board and lock it", + description: "Move a desk under the Archive desk and lock it", request: { params: z.object({ id: z.coerce.number().int().positive() }) }, responses: { - 200: { description: "Board archived" }, - 404: { description: "Board or Archive not found" }, + 200: { description: "Desk archived" }, + 404: { description: "Desk or Archive not found" }, }, }, handler: async c => { const { id } = c.req.valid("param"); - const boards = createContentContainerService(c, { - definition: boardContainers, + const desks = createContentContainerService(c, { + definition: deskContainers, pluginId: manifest.id, }); - const archive = await boards.findBySlug("archive"); + const archive = await desks.findBySlug("archive"); if (!archive) { throw new HTTPException(404, { - message: "Create an Archive board first.", + message: "Create an Archive desk first.", }); } - const siblings = await boards.children(archive.id); - await boards.move(id, { parentId: archive.id, position: siblings.length }); - await boards.update(id, { metadata: { locked: true } }); + const siblings = await desks.children(archive.id); + await desks.move(id, { parentId: archive.id, position: siblings.length }); + await desks.update(id, { metadata: { locked: true } }); - return c.json({ message: "Board archived." }, 200); + return c.json({ message: "Desk archived." }, 200); }, }); ``` @@ -128,7 +128,7 @@ Refused writes throw a `ContentContainerError` with `code`, `status` (400, 404 o | `delete(id)` | `Promise` | Deletes an empty container, as described in [Safe deletion](#safe-deletion) | | `toPublic(record)` | `PublicRecord` | Drops every metadata field not in `publicApi.fields` | -A record is `{ id, parentId, name, slug, position, depth, metadata, createdAt, updatedAt }`, with `metadata` typed from the definition's fields. `ContentContainerRecord` and `ContentContainerPublicRecord` from `@vitnode/core/content` name those types. +A record is `{ id, parentId, name, slug, position, depth, metadata, createdAt, updatedAt }`, with `metadata` typed from the definition's fields. `ContentContainerRecord` and `ContentContainerPublicRecord` from `@vitnode/core/content` name those types. The tree helpers the service uses are pure and browser-safe, exported from `@vitnode/core/content`: `contentContainerTree` nests a list, `orderContentContainers` sorts rows into tree order with their depth, and `contentContainerAncestors` and `contentContainerSubtreeIds` walk it. @@ -143,21 +143,21 @@ Each successful write emits one event after its transaction commits. A write tha | `content.container.moved` | `{ definitionId, pluginId, containerId, parentId, fromParentId, position }` | | `content.container.deleted` | `{ definitionId, pluginId, containerId, parentId }` | -The names are shared by every collection, so filter on `definitionId`: +Every collection shares the same names, so filter on `definitionId`. This listener logs desk moves: -```ts title="plugins/example/src/api/lib/events.ts" +```ts title="src/api/lib/desk-listeners.ts" import { buildEventListener } from "@vitnode/core/api/lib/events"; -export const boardMovedListener = buildEventListener({ +export const deskMovedListener = buildEventListener({ event: "content.container.moved", - name: "log-board-moves", + name: "log-desk-moves", handler: async (c, payload) => { - if (payload.definitionId !== "example.boards") return; + if (payload.definitionId !== "newsroom.desks") return; await c .get("log") .debug( - `Board ${payload.containerId} moved from ${payload.fromParentId ?? "the root"} to ${payload.parentId ?? "the root"}`, + `Desk ${payload.containerId} moved from ${payload.fromParentId ?? "the root"} to ${payload.parentId ?? "the root"}`, ); }, }); @@ -165,6 +165,6 @@ export const boardMovedListener = buildEventListener({ `changedFields` names `name`, `slug` and the metadata fields whose value actually changed, or `policies` when the container's [publishing policies](/docs/content-engine/container-policies) were saved. A reorder among siblings is a `moved` event with the same `fromParentId` and `parentId`. Register listeners as described in [Events](/docs/dev/events). [Built-in events](/docs/dev/events/built-in-events#content-container-events) has the field-by-field payloads. -Every write also expires the [page cache](/docs/content-engine/caching) entries that read the collection, including pages that list content filtered by one of its containers, so a renamed board shows its new name without a manual purge. +Every write also expires the [page cache](/docs/content-engine/caching) entries that read the collection, including pages that list content filtered by one of its containers, so a renamed desk shows its new name without a manual purge. -Next, build pages on top of the tree in [Containers on the frontend](/docs/content-engine/container-frontend). +Next, build pages on top of the tree in [Container pages](/docs/content-engine/container-frontend). diff --git a/apps/web/content/docs/content-engine/container-policies-api.mdx b/apps/web/content/docs/content-engine/container-policies-api.mdx index 2ca27af3b0..ffde5b3504 100644 --- a/apps/web/content/docs/content-engine/container-policies-api.mdx +++ b/apps/web/content/docs/content-engine/container-policies-api.mdx @@ -1,10 +1,10 @@ --- -title: Publishing policies reference +title: Publishing rules API description: Reference for Content Engine container publishing policies - the defineContentContainer policies option, the five policy keys and their fallbacks, the resolution order, the GET and PUT policies routes, the policy report, errors and server helpers. icon: FileJson --- -Exact names, values and behavior of container publishing policies. For a guided setup, follow [Publishing policies](/docs/content-engine/container-policies). Examples use the Forum plugin's `forum.forums` collection. +Exact names, values and behavior of container publishing policies. For a guided setup, follow [Publishing rules](/docs/content-engine/container-policies). Examples use the Forum plugin's `forum.forums` collection. ## `policies` option diff --git a/apps/web/content/docs/content-engine/container-policies.mdx b/apps/web/content/docs/content-engine/container-policies.mdx index 4fb30ac857..1ba8fcc407 100644 --- a/apps/web/content/docs/content-engine/container-policies.mdx +++ b/apps/web/content/docs/content-engine/container-policies.mdx @@ -1,5 +1,5 @@ --- -title: Publishing policies +title: Publishing rules description: Set per-container publishing rules in the VitNode Content Engine. Decide who may post in a forum, whether new posts and edits wait for approval, and how long authors may edit, with defineContentContainer policies and the AdminCP Publishing sheet. icon: ShieldCheck implementation: diff --git a/apps/web/content/docs/content-engine/containers.mdx b/apps/web/content/docs/content-engine/containers.mdx index 8e93a4d415..4acd0e9a89 100644 --- a/apps/web/content/docs/content-engine/containers.mdx +++ b/apps/web/content/docs/content-engine/containers.mdx @@ -15,11 +15,11 @@ implementation: - Register the definition with the admin and public content modules and in the plugin's `adminNav` and `adminContent` containers, as the guide shows. - A required container on a table that already has rows needs the generated migration edited to create and assign a default before `SET NOT NULL`. related: - - title: Containers on the frontend + - title: Container pages href: /docs/content-engine/container-frontend - - title: Publishing policies + - title: Publishing rules href: /docs/content-engine/container-policies - - title: Container hierarchy + - title: Container tree href: /docs/content-engine/container-hierarchy --- @@ -27,7 +27,7 @@ import { Tab, Tabs } from "fumadocs-ui/components/tabs"; A container is a place a record lives in: one forum board, one blog section, one knowledge-base category. You declare a **collection** of containers once in code, admins build its tree in the AdminCP, and each record of a content type points at exactly one container through `field.container()`. -This guide follows the example plugin: a `example.boards` collection, and discussions (`example.discussion`) filed into it. +This guide follows the newsroom plugin, with a `newsroom.desks` collection and letters to the editor (`newsroom.letter`) filed into it. ## Containers or a content type? @@ -41,13 +41,13 @@ A category could also be a content type with a [relation](/docs/content-engine/r | Fields | A few scalars (`text`, `textarea`, `number`, `boolean`, `enum`) | Every field kind | | Drafts, revisions | No. A container exists or it does not | Optional `publication`, `editorial` and friends | | Linked from records | `field.container()`, at most one per content type | `field.relation()`, as many as you need | -| Public filter | `?board=4`, and `includeDescendants=true` for a whole subtree | `?category=4`, exact match only | +| Public filter | `?desk=4`, and `includeDescendants=true` for a whole subtree | `?category=4`, exact match only | A record belongs to one container. For anything else it points at, such as tags, keep using relations. ## Add a container collection -You need a content type to file into containers, like `example.discussion` from [Public write](/docs/content-engine/public-write#enable-public-writing). +You need a content type to file into containers, like `newsroom.letter` from [Member writing](/docs/content-engine/public-write#add-an-owner-versions-and-the-write-options). @@ -55,13 +55,13 @@ You need a content type to file into containers, like `example.discussion` from ### Define the collection -Create `src/content/boards.ts`. The definition describes the collection; each board is a row an admin creates later: +Create `src/content/desks.ts`. The definition describes the collection, and each desk is a row an admin creates later: -```ts title="plugins/example/src/content/boards.ts" +```ts title="plugins/newsroom/src/content/desks.ts" import { defineContentContainer, field } from "@vitnode/core/content"; -export const boardContainers = defineContentContainer({ - id: "example.boards", +export const deskContainers = defineContentContainer({ + id: "newsroom.desks", fields: { description: field.textarea({ maxLength: 500, nullable: true }), @@ -74,11 +74,11 @@ export const boardContainers = defineContentContainer({ ordering: { mode: "manual" }, publicApi: { - path: "boards", + path: "desks", fields: ["description", "color", "icon"], }, - admin: { path: "example/boards" }, + admin: { path: "newsroom/desks" }, }); ``` @@ -92,9 +92,9 @@ Every container already has `id`, `parentId`, `name`, `slug`, `position` and `de | `ordering` | `{ mode?: "manual" \| "alphabetical" }` | No | `{ mode: "manual" }` | How siblings are sorted. See [Hierarchy and ordering](/docs/content-engine/container-hierarchy) | | `publicApi` | `{ path: string; fields?: string[] }` | No | off | Read-only public routes at `/api/{pluginId}/content/{path}` | | `admin` | `{ path?; permissionModule?; navigation? }` | No | see below | The AdminCP screen and its staff permissions | -| `policies` | `{ defaults?; contentTypes? }` \| `false` | No | off | Per-container publishing rules. See [Publishing policies](/docs/content-engine/container-policies) | +| `policies` | `{ defaults?; contentTypes? }` \| `false` | No | off | Per-container publishing rules. See [Publishing rules](/docs/content-engine/container-policies) | -`admin.path` defaults to the id with dots turned into slashes (`example/boards`), so the screen opens at `/admin/containers/example/boards`. `admin.permissionModule` defaults to the snake-cased id (`example_boards`), and `admin.navigation: false` hides the sidebar entry. +`admin.path` defaults to the id with dots turned into slashes (`newsroom/desks`), so the screen opens at `/admin/containers/newsroom/desks`. `admin.permissionModule` defaults to the snake-cased id (`newsroom_desks`), and `admin.navigation: false` hides the sidebar entry. @@ -104,38 +104,38 @@ Every container already has `id`, `parentId`, `name`, `slug`, `position` and `de Add one `field.container()` to the content type. List it in `publicApi.fields` to return it, in `filterableFields` to filter by it, and in `publicWrite.create.fields` to let members pick it: -```ts title="plugins/example/src/content/discussion.ts" -import { boardContainers } from "@/content/boards"; // [!code ++] +```ts title="plugins/newsroom/src/content/letter.ts (excerpt)" +import { deskContainers } from "@/content/desks"; // [!code ++] -export const discussionContentType = defineContentType({ - id: "example.discussion", - tableName: "example_discussions", +export const letterContentType = defineContentType({ + id: "newsroom.letter", + tableName: "newsroom_letters", fields: { title: field.text({ required: true, minLength: 3, maxLength: 200 }), slug: field.slug({ source: "title" }), body: field.richText({ required: true }), author: field.user(), - board: field.container({ definition: boardContainers, required: true }), // [!code ++] + desk: field.container({ definition: deskContainers, required: true }), // [!code ++] }, publicApi: { - path: "discussions", - fields: ["id", "title", "slug", "body", "board", "publishedAt"], // [!code highlight] - filterableFields: ["board"], // [!code ++] + path: "letters", + fields: ["id", "title", "slug", "body", "desk", "publishedAt"], // [!code highlight] + filterableFields: ["desk"], // [!code ++] }, publicWrite: { ownership: { field: "author" }, - create: { fields: ["title", "body", "board"], status: "published" }, // [!code highlight] + create: { fields: ["title", "body", "desk"], status: "published" }, // [!code highlight] update: { fields: ["title", "body"] }, }, }); ``` -The column is an `integer` foreign key to `core_content_containers.id` with `ON DELETE RESTRICT`, plus an index on `(board, publishedAt)`. Every write checks that the id belongs to `example.boards`, not just that it exists. All options are in [Fields](/docs/content-engine/fields#container). +The column is an `integer` foreign key to `core_content_containers.id` with `ON DELETE RESTRICT`, plus an index on `(desk, publishedAt)`. Every write checks that the id belongs to `newsroom.desks`, not just that it exists. All options are in [Fields](/docs/content-engine/fields#container). -A content type can also live in the container of a record it points at, such as a reply in its topic's board, with a required `field.relation({ target, container: true })` instead of a `field.container()`. See [Threaded replies](/docs/content-engine/threads). +A content type can also live in the container of a record it points at, such as a reply in its topic's forum, with a required `field.relation({ target, container: true })` instead of a `field.container()`. See [Comments](/docs/content-engine/threads). @@ -143,21 +143,21 @@ A content type can also live in the container of a record it points at, such as ### Register the collection on the API -Pass the definition to both content modules. The admin module adds the staff routes and the `example_boards` permissions; the public module adds the read routes: +Pass the definition to both content modules. The admin module adds the staff routes and the `newsroom_desks` permissions, and the public module adds the read routes. The newsroom registers more content types than shown here: -```ts title="plugins/example/src/api/modules/admin/admin.module.ts" -import { boardContainers } from "@/content/boards"; // [!code ++] +```ts title="plugins/newsroom/src/api/modules/admin/admin.module.ts (excerpt)" +import { deskContainers } from "@/content/desks"; // [!code ++] buildContentAdminModule({ - containers: [boardContainers], // [!code ++] - contentTypes: [articleContent, discussionContent], + containers: [deskContainers], // [!code ++] + contentTypes: [storyContent, letterContent], }), ``` -```ts title="plugins/example/src/config.api.ts" +```ts title="plugins/newsroom/src/config.api.ts (excerpt)" buildContentPublicModule({ - containers: [boardContainers], // [!code ++] - contentTypes: [articleContent, discussionContent], + containers: [deskContainers], // [!code ++] + contentTypes: [storyContent, letterContent], }), ``` @@ -171,30 +171,25 @@ A content type whose `field.container()` names a collection no plugin registers Add the collection to `containers` in both AdminCP files, next to your content types. `icon` is optional and defaults to a folder icon: -```tsx title="plugins/example/src/admin/nav.tsx" -import { FolderTreeIcon } from "lucide-react"; // [!code ++] +```tsx title="plugins/newsroom/src/admin/nav.tsx (excerpt)" +import { FolderTreeIcon, MailIcon, NotebookPenIcon } from "lucide-react"; // [!code highlight] -import { boardContainers } from "@/content/boards"; // [!code ++] - -// [!code ++:4] -export const exampleBoardsNav = { - definition: boardContainers, - icon: , -}; +import { deskContainers } from "@/content/desks"; // [!code ++] export const adminNav = { pluginId: manifest.id, - containers: [exampleBoardsNav], // [!code ++] - contentTypes: [exampleArticleNav, exampleDiscussionNav], + containers: [{ definition: deskContainers, icon: }], // [!code ++] + contentTypes: [ + { definition: storyContentType, icon: }, + { definition: letterContentType, icon: }, + ], } satisfies AdminNavPluginSource; ``` -```tsx title="plugins/example/src/admin/content.tsx" -export const adminContent = { - pluginId: manifest.id, - containers: [exampleBoardsNav], // [!code ++] - contentTypes: [exampleArticleNav, exampleDiscussionNav], -} satisfies ContentFrontendPluginSource; +The newsroom's AdminCP sources hold nothing server-only, so its `admin/content.tsx` reuses the same object: + +```tsx title="plugins/newsroom/src/admin/content.tsx" +export { adminNav as adminContent } from "./nav"; ``` [Plugin files](/docs/content-engine/plugin-files) explains why the two files are separate. @@ -207,14 +202,14 @@ export const adminContent = { The AdminCP reads the collection's names from `{pluginId}.containers.{entity}`, where the entity is the id without its plugin prefix. The permission labels follow the permission module: -```json title="plugins/example/src/locales/en.json" +```json title="plugins/newsroom/src/locales/en.json (excerpt)" { - "@vitnode/example": { + "newsroom": { "containers": { - "boards": { - "label": "{count, plural, one {Board} other {Boards}}", - "title": "Boards", - "desc": "The board tree members file their discussions into.", + "desks": { + "label": "{count, plural, one {Desk} other {Desks}}", + "title": "Desks", + "desc": "The desks readers send their letters to. Drag to reorder or nest desks.", "fields": { "description": "Description", "color": "Color", @@ -224,18 +219,18 @@ The AdminCP reads the collection's names from `{pluginId}.containers.{entity}`, } }, "content": { - "discussion": { "fields": { "board": "Board" } } + "letter": { "fields": { "desk": "Desk" } } } }, - "@vitnode/example:example_boards": "Boards", - "@vitnode/example:example_boards:can_view": "View boards", - "@vitnode/example:example_boards:can_create": "Create boards", - "@vitnode/example:example_boards:can_edit": "Edit and move boards", - "@vitnode/example:example_boards:can_delete": "Delete boards" + "newsroom:newsroom_desks": "Desks", + "newsroom:newsroom_desks:can_view": "View desks", + "newsroom:newsroom_desks:can_create": "Create desks", + "newsroom:newsroom_desks:can_edit": "Edit and move desks", + "newsroom:newsroom_desks:can_delete": "Delete desks" } ``` -Without labels, the screen falls back to a name derived from the id, such as "Boards". +Without labels, the screen falls back to a name derived from the id, such as "Desks". @@ -261,13 +256,13 @@ npm run db:migrate -The collection itself needs no table: every definition shares `core_content_containers`. The migration only adds the `board` column to `example_discussions`. +The collection itself needs no table, because every definition shares `core_content_containers`. The migration only adds the `desk` column to `newsroom_letters`. A required container field becomes a `NOT NULL` column. If the table already has rows, edit the generated migration to create a container and assign it - before the `SET NOT NULL`, as the example's migration does with a "General" - board. [Database](/docs/content-engine/database) covers editing migrations. + before the `SET NOT NULL`. [Migrations](/docs/content-engine/database) covers + editing migrations. @@ -276,23 +271,23 @@ The collection itself needs no table: every definition shares `core_content_cont ## Manage containers in the AdminCP -Open **AdminCP → Example → Boards** (`/admin/containers/example/boards`). Staff need the collection's `can_view` permission to see it, and `can_create`, `can_edit` or `can_delete` for each action. +Open **AdminCP → Newsroom → Desks** (`/admin/containers/newsroom/desks`). Staff need the collection's `can_view` permission to see it, and `can_create`, `can_edit` or `can_delete` for each action. -- **The tree** shows every board with its children nested below it. Drag a board to reorder it among its siblings or to drop it inside another board. +- **The tree** shows every desk with its children nested below it. Drag a desk to reorder it among its siblings or to drop it inside another desk. - **Create and edit** open a sheet with the name, the slug and the collection's own fields. A container created without a slug gets one derived from its name. - **The move menu** on each row does the same as dragging from the keyboard: **Move up**, **Move down**, **Move to top level** and **Move inside…**. -- **Delete** works only on an empty board. A board with child boards or with discussions is refused with a message that says which one, and nothing changes. +- **Delete** works only on an empty desk. A desk with child desks or with letters is refused with a message that says which one, and nothing changes. -In a content type's generated form, a container field becomes a **picker** that lists the collection's tree, so staff can file a discussion into a board or move it to another one. +In a content type's generated form, a container field becomes a **picker** that lists the collection's tree, so staff can file a letter into a desk or move it to another one. -A drag that breaks a rule, such as nesting a board deeper than `maxDepth` or dropping it inside its own child, is refused the same way. [Hierarchy and ordering](/docs/content-engine/container-hierarchy) lists every rule. +A drag that breaks a rule, such as nesting a desk deeper than `maxDepth` or dropping it inside its own child, is refused the same way. [Hierarchy and ordering](/docs/content-engine/container-hierarchy) lists every rule. ## Check the result -Create a "Frontend" board with a "React" board inside it, then read the public tree: +Create a "Politics" desk with an "Elections" desk inside it, then read the public tree: ```bash -curl http://localhost:3000/api/@vitnode/example/content/boards +curl http://localhost:3000/api/newsroom/content/desks ``` ```json @@ -301,12 +296,12 @@ curl http://localhost:3000/api/@vitnode/example/content/boards { "id": 1, "parentId": null, - "name": "Frontend", - "slug": "frontend", + "name": "Politics", + "slug": "politics", "position": 0, "depth": 1, "metadata": { - "description": "Everything in the browser", + "description": "Parliament, parties and policy", "color": "#2563eb", "icon": null } @@ -314,8 +309,8 @@ curl http://localhost:3000/api/@vitnode/example/content/boards { "id": 2, "parentId": 1, - "name": "React", - "slug": "react", + "name": "Elections", + "slug": "elections", "position": 0, "depth": 2, "metadata": { "description": null, "color": null, "icon": null } @@ -324,7 +319,7 @@ curl http://localhost:3000/api/@vitnode/example/content/boards } ``` -Items come in tree order: each parent, then its subtree. `metadata` holds only `publicApi.fields`, and a value nobody set is `null`. [Containers on the frontend](/docs/content-engine/container-frontend) turns this into pages. +Items come in tree order: each parent, then its subtree. `metadata` holds only `publicApi.fields`, and a value nobody set is `null`. [Container pages](/docs/content-engine/container-frontend) turns this into pages. ## Definition errors @@ -344,10 +339,10 @@ Items come in tree order: each parent, then its subtree. `metadata` holds only ` | `field.container()` pointing at an unregistered collection | `Field "x" uses the container collection "y", which no plugin registers.` | | A container field in `publicWrite.update.fields` | `publicWrite.update.fields includes the container field "x".` | -## What's next +## Related - [Hierarchy and ordering](/docs/content-engine/container-hierarchy): depth, moves, safe deletion and the server-side service. -- [Containers on the frontend](/docs/content-engine/container-frontend): a board tree, a board page and a create form with a board picker. -- [Publishing policies](/docs/content-engine/container-policies): who may post in each board, whether posts wait for [approval](/docs/content-engine/approvals), and how long authors may edit. +- [Container pages](/docs/content-engine/container-frontend): a desk tree, a desk page and a create form with a desk picker. +- [Publishing rules](/docs/content-engine/container-policies): who may post in each desk, whether posts wait for [approval](/docs/content-engine/approvals), and how long authors may edit. -Read permissions per container, such as boards hidden from guests, are not available yet. Today every reader sees every container in the public tree. +Read permissions per container, such as desks hidden from guests, are not available yet. Today every reader sees every container in the public tree. diff --git a/apps/web/content/docs/content-engine/content-visibility-hidden-notice.webp b/apps/web/content/docs/content-engine/content-visibility-hidden-notice.webp index 183fb05916..62ae669475 100644 Binary files a/apps/web/content/docs/content-engine/content-visibility-hidden-notice.webp and b/apps/web/content/docs/content-engine/content-visibility-hidden-notice.webp differ diff --git a/apps/web/content/docs/content-engine/content-visibility.mdx b/apps/web/content/docs/content-engine/content-visibility.mdx index 117e24aa5d..4bf4504907 100644 --- a/apps/web/content/docs/content-engine/content-visibility.mdx +++ b/apps/web/content/docs/content-engine/content-visibility.mdx @@ -1,5 +1,5 @@ --- -title: Hide content +title: Visibility description: Take a published VitNode Content Engine record off the public site, search and sitemap without unpublishing it, then bring it back with the same URL and publish date. icon: EyeOff --- @@ -123,7 +123,7 @@ A record is public only when it is published, its `publishedAt` is not in the fu - The public API list leaves it out for everybody but the [people who can still see it](#who-can-still-see-it), and its detail route answers `404`, in every language, including a language that falls back to the default one. - Its [page](/docs/content-engine/public-pages) answers `404`, and so do its old slugs instead of redirecting. - The [sitemap](/docs/content-engine/seo#sitemap) and other pages' `hreflang` alternates leave it out, and [site search](/docs/content-engine/search) marks its document private. -- Signed [preview links](/docs/content-engine/editorial#preview-links) still work, and the AdminCP still shows it. +- Signed [preview links](/docs/content-engine/editorial-previews) still work, and the AdminCP still shows it. ## Who can still see it @@ -175,19 +175,21 @@ The people who still see a hidden record should know readers don't. Tell them on - **A page with [delivery](/docs/content-engine/public-pages)** reads `metadata.hidden` from `contentDeliveryPage`. The blog shows an eye-off icon before the title and a notice, and drops the Report button. - **A page without delivery** reads the `X-Content-Hidden` header of `GET /{slug}`, which is `"true"` only for a hidden record: -```tsx title="plugins/example/src/pages/discussion-page.tsx" +```tsx title="plugins/newsroom/src/pages/letter-page.tsx (excerpt)" import { CONTENT_HIDDEN_HEADER } from "@vitnode/core/content"; const detail = await fetcher({ plugin: manifest.id, method: "get", - module: "content/discussions", + module: "content/letters", path: "/{slug}", args: { params: { slug: encodeURIComponent(slug) } }, }); +// Only the author and moderators get a hidden letter; readers get 404. const hidden = detail.headers.get(CONTENT_HIDDEN_HEADER) === "true"; -{hidden ? ( +// The loader returns `hidden` with the letter, and the page reads it: +{item.hidden ? ( } variant="warning"> {t("hidden.title")} {t("hidden.desc")} @@ -195,7 +197,7 @@ const hidden = detail.headers.get(CONTENT_HIDDEN_HEADER) === "true"; ) : null} ``` -[`useContentPermissions`](/docs/content-engine/reports#ask-who-may-report-what) already answers `canReport: false` for a hidden record, and `canEdit: false` for its author, so the page needs no extra checks for its buttons. A moderator can still edit it. +[`useContentPermissions`](/docs/content-engine/public-permissions) already answers `canReport: false` for a hidden record, and `canEdit: false` for its author, so the page needs no extra checks for its buttons. A moderator can still edit it. ## Delete, hide, unpublish or reject @@ -227,11 +229,11 @@ curl -o /dev/null -w "%{http_code}\n" "http://localhost:3000/api/@vitnode/blog/c Both answer `404`, like a draft, and the article drops out of the blog list and the sitemap. In the AdminCP it is still **Published**. Unhide it, and the same requests answer `200` with the original `publishedAt`. Open `/blog` and the page while signed in as one of its authors, and the list shows it with the eye-off icon and the page loads with the hidden note. -The example plugin's discussions show the same note through the header. Ben Ortiz opens his own hidden discussion, and only he and the moderators can: +The newsroom plugin's letters to the editor show the same note through the header. Ada Lovelace opens their own hidden letter, and only they and the moderators can: diff --git a/apps/web/content/docs/content-engine/create-a-content-type-articles-list.webp b/apps/web/content/docs/content-engine/create-a-content-type-articles-list.webp new file mode 100644 index 0000000000..e64e7d5ef5 Binary files /dev/null and b/apps/web/content/docs/content-engine/create-a-content-type-articles-list.webp differ diff --git a/apps/web/content/docs/content-engine/create-a-content-type-categories-list.webp b/apps/web/content/docs/content-engine/create-a-content-type-categories-list.webp deleted file mode 100644 index f81ded4874..0000000000 Binary files a/apps/web/content/docs/content-engine/create-a-content-type-categories-list.webp and /dev/null differ diff --git a/apps/web/content/docs/content-engine/create-a-content-type-create-dialog.webp b/apps/web/content/docs/content-engine/create-a-content-type-create-dialog.webp index 7afec238a9..038a9cef64 100644 Binary files a/apps/web/content/docs/content-engine/create-a-content-type-create-dialog.webp and b/apps/web/content/docs/content-engine/create-a-content-type-create-dialog.webp differ diff --git a/apps/web/content/docs/content-engine/create-a-content-type-staff-permissions.webp b/apps/web/content/docs/content-engine/create-a-content-type-staff-permissions.webp deleted file mode 100644 index 4c7c710eab..0000000000 Binary files a/apps/web/content/docs/content-engine/create-a-content-type-staff-permissions.webp and /dev/null differ diff --git a/apps/web/content/docs/content-engine/create-a-content-type.mdx b/apps/web/content/docs/content-engine/create-a-content-type.mdx index 01f1bd3d18..a866b852a3 100644 --- a/apps/web/content/docs/content-engine/create-a-content-type.mdx +++ b/apps/web/content/docs/content-engine/create-a-content-type.mdx @@ -1,69 +1,84 @@ --- title: First content type -description: Build the example plugin's category content type with the VitNode Content Engine. One TypeScript definition gives you a migrated table, an AdminCP screen, staff API routes and staff permissions. +description: Build your first VitNode Content Engine content type, a news article with a title, slug and body, and create and edit articles in the AdminCP. icon: FileCode --- import { Tab, Tabs } from "fumadocs-ui/components/tabs"; + import { ImgDocs } from "@/components/fumadocs/img"; import createDialog from "./create-a-content-type-create-dialog.webp"; -import categoriesList from "./create-a-content-type-categories-list.webp"; -import staffPermissions from "./create-a-content-type-staff-permissions.webp"; +import articlesList from "./create-a-content-type-articles-list.webp"; -This tutorial builds `example.category`, the content type behind **AdminCP → Example → Categories** in VitNode's example plugin. You write one definition, wire it into four small files, run one migration, and get a Postgres table, a list with a create form, staff-only API routes and four staff permissions. Categories have one field, so you spend your time on the wiring instead of naming things. +This tutorial starts a small newsroom with one **article** content type, which has a title, a slug and a body. By the end, staff create and edit articles in the AdminCP, and you have the table, the staff API and the staff permissions without writing any of them by hand. -Every snippet is copied from `plugins/example`, with unrelated content types trimmed out. Use your own plugin id wherever you see `@vitnode/example`. +The next tutorials keep building the same article: [Drafts & publishing](/docs/content-engine/publication), [Public API](/docs/content-engine/public-api), [Content list](/docs/content-engine/public-lists) and [Content page](/docs/content-engine/public-pages). ## Before you begin -You need a plugin that your app already loads, with `src/config.api.ts`, `src/config.tsx` and `src/api/builders.ts`, which binds the API and Content Engine builders to your plugin, so none of them needs a plugin id: +You need a plugin your app already loads. This tutorial uses one named `newsroom`, made with [Create a plugin](/docs/dev/plugins/create), so its plugin id is `newsroom` and its files live in `plugins/newsroom/`. Use your own plugin's name wherever you see `newsroom`. You also need a running PostgreSQL database and an AdminCP account. + + + + + +### Bind the Content Engine builders + +Edit `src/api/builders.ts`. The generated file binds the API builders. Add the Content Engine ones next to them, so no file in your plugin has to spell out its plugin id: -```ts title="plugins/example/src/api/builders.ts" +```ts title="plugins/newsroom/src/api/builders.ts" import { bindApiPlugin } from "@vitnode/core/api/lib/bind-api-plugin"; -import { bindContentPlugin } from "@vitnode/core/content/server"; +import { bindContentPlugin } from "@vitnode/core/content/server"; // [!code ++] import { manifest } from "@/manifest.gen"; export const { buildModule, buildRoute } = bindApiPlugin(manifest); +// [!code ++:2] export const { buildContentAdminModule, buildContentPublicModule } = bindContentPlugin(manifest); ``` -`src/manifest.gen.ts` is written by `vitnode build` and `vitnode dev` from your `package.json`, and `manifest.id` is the plugin id. [Create a plugin](/docs/dev/plugins/create) generates all of these. You also need a running PostgreSQL database and an AdminCP account. +`vitnode dev` and `vitnode build` write `src/manifest.gen.ts` from your `package.json`, and `manifest.id` is the plugin id. - + -### Declare the content type +### Define the article -Create `src/content/category.ts`: +Create `src/content/article.ts`. This is the complete file: -```ts title="plugins/example/src/content/category.ts" +```ts title="plugins/newsroom/src/content/article.ts" import { defineContentType, field } from "@vitnode/core/content"; -export const categoryContentType = defineContentType({ - id: "example.category", - tableName: "example_categories", +export const articleContentType = defineContentType({ + id: "newsroom.article", + tableName: "newsroom_articles", + fields: { - name: field.text({ required: true, minLength: 1, maxLength: 100 }), + title: field.text({ required: true, minLength: 3, maxLength: 200 }), + slug: field.slug({ source: "title" }), + body: field.richText({ required: true }), }, + admin: { - path: "example/categories", + path: "newsroom/articles", + titleField: "title", list: { - columns: ["name", "createdAt"], - orderableFields: ["name"], + columns: ["title", "slug", "updatedAt"], + orderableFields: ["title"], }, }, }); ``` -- `id` is `plugin.entity`, lowercase and dot separated. The part after the first dot (`category`) names the translations and the permissions. -- `tableName` is the Postgres table, in snake_case. -- `admin.path` sets the AdminCP URL, `/admin/content/example/categories`. -- `list.columns` may name your fields and the system columns `id`, `createdAt` and `updatedAt`. `orderableFields` makes the **Name** header sortable. +- `id` is `plugin.entity`. The part after the dot, `article`, names the translations and the permissions. +- `tableName` is the Postgres table. +- The engine fills `slug` from the title when you leave it empty, and keeps it when the title changes later, so links keep working. +- `body` is [rich text](/docs/content-engine/rich-text). The AdminCP shows an editor, and the database stores a JSON document. +- `admin.path` puts the screen at `/admin/content/newsroom/articles`. `list.columns` may name your fields and the system columns `id`, `createdAt` and `updatedAt`. -You get a few things without asking: the list searches its text columns, the first text field becomes the record's title, and create and edit open in a dialog. Every field helper is listed in [Fields](/docs/content-engine/fields). +[Fields](/docs/content-engine/fields) lists every field helper. @@ -71,56 +86,53 @@ You get a few things without asking: the list searches its text columns, the fir ### Create the model -Create `src/database/categories.ts`. `createContentModel` turns the definition into a Drizzle table, Zod schemas and a service: +Create `src/database/articles.ts`. `createContentModel` turns the definition into a Drizzle table, validation schemas and a service: -```ts title="plugins/example/src/database/categories.ts" +```ts title="plugins/newsroom/src/database/articles.ts" import { createContentModel } from "@vitnode/core/content/server"; -import { categoryContentType } from "@/content/category"; +import { articleContentType } from "@/content/article"; -export const categoryContent = createContentModel(categoryContentType); +export const articleContent = createContentModel(articleContentType); -export const example_categories = categoryContent.table; +export const newsroom_articles = articleContent.table; ``` -Export the table. Drizzle Kit finds tables by reading the exports of your plugin's built `dist/src/database/*.js` files, so a table nobody exports never reaches a migration. +Export the table. Migrations find tables through the exports of your plugin's built `src/database/*.js` files, so a table nobody exports never gets created. -### Add the staff API routes +### Register the staff API routes -Create an `admin` module and pass the model to `buildContentAdminModule`: +Create `src/api/modules/admin/admin.module.ts`: -```ts title="plugins/example/src/api/modules/admin/admin.module.ts" +```ts title="plugins/newsroom/src/api/modules/admin/admin.module.ts" import { buildContentAdminModule, buildModule } from "@/api/builders"; -import { categoryContent } from "@/database/categories"; +import { articleContent } from "@/database/articles"; export const adminModule = buildModule({ name: "admin", routes: [], - modules: [ - buildContentAdminModule({ - contentTypes: [categoryContent], - }), - ], + modules: [buildContentAdminModule({ contentTypes: [articleContent] })], }); ``` -Then add the module to your API plugin. This is an excerpt of `src/config.api.ts`: +Then add it to the plugin's API. This is an excerpt of `src/config.api.ts`: -```ts title="plugins/example/src/config.api.ts" -import { adminModule } from "@/api/modules/admin/admin.module"; // [!code ++] +```ts title="plugins/newsroom/src/config.api.ts" +import { adminModule } from "./api/modules/admin/admin.module"; // [!code ++] +import { helloModule } from "./api/modules/hello/hello.module"; -export const exampleApiPlugin = () => +export const newsroomApiPlugin = () => buildApiPlugin({ manifest, - modules: [adminModule], // [!code highlight] + modules: [helloModule, adminModule], // [!code highlight] }); ``` -The routes live under `/api/@vitnode/example/admin/content/category` and only answer staff signed in to the AdminCP. Keep the module named `admin`, because the API checks the AdminCP session on paths that contain `/admin/`. +The routes live under `/api/newsroom/admin/content/article` and answer only staff signed in to the AdminCP. Keep the module named `admin`, because the API checks the AdminCP session on paths that contain `/admin/`. @@ -128,63 +140,69 @@ The routes live under `/api/@vitnode/example/admin/content/category` and only an ### Add the AdminCP screen -The AdminCP needs two things from your plugin: a sidebar link and the screen behind it. They live in separate files on purpose. The sidebar loads on every AdminCP page, while the screen's forms load only when someone opens it. - Create `src/admin/nav.tsx` with the sidebar link: -```tsx title="plugins/example/src/admin/nav.tsx" -import { FolderIcon } from "lucide-react"; +```tsx title="plugins/newsroom/src/admin/nav.tsx" +import { NewspaperIcon } from "lucide-react"; -import { categoryContentType } from "@/content/category"; +import { articleContentType } from "@/content/article"; import { manifest } from "@/manifest.gen"; export const adminNav = { pluginId: manifest.id, - contentTypes: [{ definition: categoryContentType, icon: }], + contentTypes: [{ definition: articleContentType, icon: }], }; ``` Create `src/admin/content.tsx` and reuse the same list for the screen: -```tsx title="plugins/example/src/admin/content.tsx" +```tsx title="plugins/newsroom/src/admin/content.tsx" export { adminNav as adminContent } from "./nav"; ``` -That one line is enough for the generated list and forms. Later, this is the file where you swap in [custom cells, fields or form layouts](/docs/content-engine/admincp). VitNode finds both files by name, so keep the paths and the `adminNav` and `adminContent` exports. +VitNode finds both files by their names and exports. They are separate because the sidebar loads on every AdminCP page, while the screen and its forms load only when someone opens it. [AdminCP](/docs/content-engine/admincp) shows how to customize the list and the form later. -### Name everything +### Add the labels -Labels come from your plugin's `src/locales/en.json`. Content type strings live under `content.category`, and each staff permission gets a top-level key: +Edit `src/locales/en.json`. The `home` keys came with the generated plugin. Add everything else: -```json title="plugins/example/src/locales/en.json" +```json title="plugins/newsroom/src/locales/en.json" { - "@vitnode/example": { - "title": "Example", + "newsroom": { + "home": { + "api": "Your plugin's API answered:", + "desc": "This page ships inside the plugin and is served by the app that installed it.", + "title": "Hello from your plugin" + }, + "title": "Newsroom", "content": { - "category": { - "label": "{count, plural, one {Category} other {Categories}}", - "title": "Categories", - "desc": "Group articles together.", + "article": { + "label": "{count, plural, one {Article} other {Articles}}", + "title": "Articles", + "desc": "News and updates for your community.", "fields": { - "name": "Name", - "createdAt": "Created" + "title": "Title", + "slug": "Slug", + "body": "Body", + "updatedAt": "Updated" } } } }, - "@vitnode/example:category": "Categories", - "@vitnode/example:category:can_view": "View categories", - "@vitnode/example:category:can_create": "Create categories", - "@vitnode/example:category:can_edit": "Edit categories", - "@vitnode/example:category:can_delete": "Delete categories" + "newsroom:article": "Articles", + "newsroom:article:can_view": "View articles", + "newsroom:article:can_create": "Create articles", + "newsroom:article:can_edit": "Edit articles", + "newsroom:article:can_delete": "Delete articles" } ``` -`label` is an ICU plural, used in buttons and messages such as **Create Category**. `title` and `desc` head the list, and the plugin's own `title` names the sidebar group. A missing key falls back to a readable version of the field name, and a missing permission label shows its raw id. +- `title` names the sidebar group. Under `content.article`, buttons such as **Create Article** use `label`, and `title` and `desc` head the list. +- The `newsroom:article:*` keys label the four staff permissions the Content Engine generated. A missing key shows the raw permission id. @@ -192,54 +210,57 @@ Labels come from your plugin's `src/locales/en.json`. Content type strings live ### Build and migrate -Run this from the workspace root: +Run these from the workspace root. `db:migrate` reads your plugin's built files, so build first: - + ```bash tab="bun" +bun run build:plugins bun run db:migrate ``` ```bash tab="pnpm" +pnpm build:plugins pnpm db:migrate ``` ```bash tab="npm" +npm run build:plugins npm run db:migrate ``` -`db:migrate` reads the built plugin, so always build first. It writes a migration to `apps/api/migrations/` and applies it. This is the part of the example plugin's migration that creates categories: +While `pnpm dev` runs, it rebuilds the plugin on every save, so `db:migrate` alone is enough. The command writes a migration to `apps/api/migrations/` and applies it: -```sql title="apps/api/migrations/20260802212938_add_example_content/migration.sql (excerpt)" -CREATE TABLE "example_categories" ( - "id" serial PRIMARY KEY NOT NULL, +```sql title="apps/api/migrations/_/migration.sql" +CREATE TABLE "newsroom_articles" ( + "id" serial PRIMARY KEY, "createdAt" timestamp DEFAULT now() NOT NULL, "updatedAt" timestamp DEFAULT now() NOT NULL, - "name" varchar(100) NOT NULL + "title" varchar(200) NOT NULL, + "slug" varchar(160) NOT NULL, + "body" jsonb NOT NULL ); -ALTER TABLE "example_categories" ENABLE ROW LEVEL SECURITY; -CREATE INDEX "example_categories_created_at_idx" ON "example_categories" USING btree ("createdAt"); -CREATE INDEX "example_categories_updated_at_idx" ON "example_categories" USING btree ("updatedAt"); +CREATE UNIQUE INDEX "newsroom_articles_slug_key" ON "newsroom_articles" ("slug"); ``` -You declared one field. The engine added `id`, the two timestamps and their indexes. [Database](/docs/content-engine/database) lists everything it generates. +You declared three fields, and the engine added the `id` and the timestamps. Commit the migration with your code. [Migrations](/docs/content-engine/database) covers what else it generates. -### Create a few categories +### Create an article -Open **AdminCP → Example → Categories** and click **Create Category**. The dialog is generated from your fields, so it has a single **Name** input with the 1 to 100 character limit from the definition. +Start the app with `pnpm dev` and open **AdminCP → Newsroom → Articles**. Click **Create Article**, enter the title `Welcome to the newsroom`, leave **Slug** empty and write a sentence in **Body**: -Click **Create**. A toast says "Category has been created." and the list refreshes. Add a couple more so there is something to sort. +Click **Create**. A toast confirms it and the list shows the new row with the slug `welcome-to-the-newsroom`. Create a second article, `Release notes for October`. The next tutorials use both. @@ -247,24 +268,15 @@ Click **Create**. A toast says "Category has been created." and the list refresh ## Check the result -The list shows your categories with the **Name** and **Created** columns from `list.columns`. Click **Name** to sort, or type in the search box to filter by name. - -Now open **AdminCP → Staff → Administrators**, edit an administrator, choose **Restricted** and select **Example**. The **Categories** group holds the four permissions the Content Engine generated from your definition, labelled by your locale keys. Create, edit and delete each require view. Every generated route checks them, so an administrator without **Delete categories** gets a `403` from the delete route and no delete button in the list. - - - -The `advanced_article`, `localized_article` and `page` groups next to it have no labels in the example plugin. That is what a missing permission key looks like. - -## Next +- The list shows both articles with the columns from `list.columns`. Click **Title** to sort, or type in the search box. +- Click the pencil on a row, change the body and save. The edit opens the same generated form. +- In **AdminCP → Staff → Administrators**, a restricted administrator now has a **Newsroom → Articles** group with your four permission labels. Every generated route checks them. -Categories are staff-only. To publish content to visitors, add drafts and a read-only API in [Public API](/docs/content-engine/public-api). +Next, keep unfinished articles private with [Drafts & publishing](/docs/content-engine/publication). diff --git a/apps/web/content/docs/content-engine/database.mdx b/apps/web/content/docs/content-engine/database.mdx index b261df37b3..7c77b23170 100644 --- a/apps/web/content/docs/content-engine/database.mdx +++ b/apps/web/content/docs/content-engine/database.mdx @@ -1,5 +1,5 @@ --- -title: Database +title: Migrations description: The Postgres tables, columns and indexes the VitNode Content Engine generates for a content type, and how to generate, apply and evolve their migrations. icon: Database --- @@ -38,31 +38,32 @@ Commit the generated migration together with the content type. Production never Drizzle Kit finds tables by reading the exports of `node_modules//dist/src/database/*.js` for each plugin. A table without an export is left out of the migration, without a warning. A content type with relations, translations and repeatable rows has several tables: -```ts title="plugins/example/src/database/advanced-articles.ts" +```ts title="plugins/newsroom/src/database/explainers.ts" import { createContentModel } from "@vitnode/core/content/server"; -import { advancedArticleContentType } from "@/content/advanced-article"; +import { explainerContentType } from "@/content/explainer"; -import { example_categories } from "./categories"; +import { newsroom_categories } from "./categories"; +import { newsroom_tags } from "./tags"; -export const advancedArticleContent = createContentModel( - advancedArticleContentType, - { - references: { - categories: () => example_categories.id, - }, +export const explainerContent = createContentModel(explainerContentType, { + references: { + categories: () => newsroom_categories.id, + tags: () => newsroom_tags.id, }, -); - -export const example_advanced_articles = advancedArticleContent.table; -export const example_advanced_articles_translations = - advancedArticleContent.translationTable; -export const example_advanced_articles_categories = - advancedArticleContent.advancedTables.junctions.categories; -export const example_advanced_articles_related_articles = - advancedArticleContent.advancedTables.junctions.relatedArticles; -export const example_advanced_articles_faq = - advancedArticleContent.advancedTables.repeatables.faq; +}); + +export const newsroom_explainers = explainerContent.table; +export const newsroom_explainers_translations = + explainerContent.translationTable; +export const newsroom_explainers_categories = + explainerContent.advancedTables.junctions.categories; +export const newsroom_explainers_tags = + explainerContent.advancedTables.junctions.tags; +export const newsroom_explainers_related_explainers = + explainerContent.advancedTables.junctions.relatedExplainers; +export const newsroom_explainers_faq = + explainerContent.advancedTables.repeatables.faq; ``` | Model member | Exists when | @@ -107,7 +108,7 @@ The primary key is `(itemId, languageId)`, so a record has at most one translati ### Junction and child tables -Field names become snake_case in table names, so `relatedArticles` on `example_advanced_articles` becomes `example_advanced_articles_related_articles`. +Field names become snake_case in table names, so `relatedExplainers` on `newsroom_explainers` becomes `newsroom_explainers_related_explainers`. - A **to-many field** gets `{tableName}_{field}` with `itemId`, `relatedItemId`, `position` and `createdAt`. The primary key is `(itemId, relatedItemId)`, so a record links to each target once. - A **repeatable field** gets `{tableName}_{field}` with `id`, `itemId`, `position`, `createdAt`, `updatedAt` and one column per subfield. @@ -132,7 +133,7 @@ Core tables that hold records of every content type store the id as text: `core_ ALTER TABLE "core_content_revisions" ALTER COLUMN "itemId" SET DATA TYPE varchar(64) USING "itemId"::varchar(64); ``` -Custom SQL that joins these tables to a content table needs a cast, such as `"core_content_revisions"."itemId" = "example_articles"."id"::text`. +Custom SQL that joins these tables to a content table needs a cast, such as `"core_content_revisions"."itemId" = "newsroom_stories"."id"::text`. ## Generated indexes @@ -145,9 +146,9 @@ The engine creates these indexes without being asked: - On a translations table: `(languageId, status)` with `publication` or `(languageId)` without it, plus a unique `(languageId, slug)` for each localized slug. - On a junction table: `relatedItemId`, and a unique `(itemId, position)`. On a repeatable table: a unique `(itemId, position)`. -Generated names follow `{table}_{columns}_idx`, or `_key` for unique ones, such as `example_categories_created_at_idx`. Add your own with the [`indexes`](/docs/content-engine/reference) option. An index on the same columns as a generated one replaces it instead of adding a second one: +Generated names follow `{table}_{columns}_idx`, or `_key` for unique ones, such as `newsroom_categories_created_at_idx`. Add your own with the [`indexes`](/docs/content-engine/reference) option. An index on the same columns as a generated one replaces it instead of adding a second one: -```ts title="plugins/example/src/content/article.ts" +```ts title="plugins/newsroom/src/content/story.ts (excerpt)" indexes: [{ on: ["status", "createdAt"] }], ``` diff --git a/apps/web/content/docs/content-engine/duplicate-content.mdx b/apps/web/content/docs/content-engine/duplicate-content.mdx index b8e9eef69f..6f5f9eba36 100644 --- a/apps/web/content/docs/content-engine/duplicate-content.mdx +++ b/apps/web/content/docs/content-engine/duplicate-content.mdx @@ -1,5 +1,5 @@ --- -title: Duplicate content +title: Duplication description: Let editors copy a VitNode Content Engine record into a new draft, with its translations, relations and repeatable rows, from the AdminCP or from your own code. icon: Copy --- diff --git a/apps/web/content/docs/content-engine/editorial-history.webp b/apps/web/content/docs/content-engine/editorial-history.webp index 3c0fe13cde..9afc31cb82 100644 Binary files a/apps/web/content/docs/content-engine/editorial-history.webp and b/apps/web/content/docs/content-engine/editorial-history.webp differ diff --git a/apps/web/content/docs/content-engine/editorial-preview-link.webp b/apps/web/content/docs/content-engine/editorial-preview-link.webp index bc90b1662e..d030d482ec 100644 Binary files a/apps/web/content/docs/content-engine/editorial-preview-link.webp and b/apps/web/content/docs/content-engine/editorial-preview-link.webp differ diff --git a/apps/web/content/docs/content-engine/editorial-previews.mdx b/apps/web/content/docs/content-engine/editorial-previews.mdx new file mode 100644 index 0000000000..637650d5f4 --- /dev/null +++ b/apps/web/content/docs/content-engine/editorial-previews.mdx @@ -0,0 +1,60 @@ +--- +title: Previews +description: Share a VitNode Content Engine draft with someone who has no AdminCP account through a signed, expiring preview link, and load the draft in your page. +icon: Eye +--- + +import { ImgDocs } from "@/components/fumadocs/img"; +import previewLink from "./editorial-preview-link.webp"; + +A preview link lets a reviewer read a draft without signing in. It needs [`editorial`](/docs/content-engine/editorial) and a [public API](/docs/content-engine/public-api), and a page that knows how to load the draft. The examples use `newsroom.story` from the newsroom plugin. + +## Turn on previews + +Add `preview` to the `editorial` block. `expiresInMinutes` defaults to 15 and accepts 1 to 1440: + +```ts title="plugins/newsroom/src/content/story.ts (excerpt)" +editorial: { + revisions: { retention: 20 }, + preview: { expiresInMinutes: 30 }, // [!code ++] + scheduling: true, +}, +``` + +`preview` changes no columns. Leaving out `publicApi` is a type error, and the definition throws when it loads. + +## Create and open a link + +In the AdminCP, open **More actions** and choose **Preview**. Creating a link needs `can_view`. + + + +The link is HMAC-signed, expires after `expiresInMinutes` and cannot be revoked early, so treat it like a password. It is pinned to the last saved version, so it shows what **Save** committed, not unsaved [live editing](/docs/content-engine/live-editing) drafts. + +The link points to the first of these that exists: + +1. `preview.pathTemplate`, such as `/preview/stories/{token}`, with exactly one `{token}`. +2. The record's own page with `?preview=`, when the type has [delivery](/docs/content-engine/public-pages). +3. The public API route `GET /api/newsroom/content/stories/preview/{token}`. + +Every page still has to load the draft from that API route. A page built from [Content page](/docs/content-engine/public-pages) does not read `?preview=` on its own, so a story's preview link opens a 404 until you check for it in your loader: + +```ts title="Read a preview in a page loader" +const preview = await fetcher({ + plugin: "newsroom", + method: "get", + module: "content/stories", + path: "/preview/{token}", + args: { params: { token: encodeURIComponent(token) } }, +}); +``` + +The route returns the record's public fields, and the same `404` for a forged, expired or unknown token. It sends `Cache-Control: private, no-store` and `X-Robots-Tag: noindex, nofollow`, so the draft stays out of caches and search results. + +VitNode builds links from `VITNODE_WEB_URL` and `VITNODE_API_URL`. When either is not an absolute URL, the API logs a warning at startup and the **Preview** button answers `503`. + diff --git a/apps/web/content/docs/content-engine/editorial-schedule.webp b/apps/web/content/docs/content-engine/editorial-schedule.webp index 4c12a1d087..d0c86fa9e0 100644 Binary files a/apps/web/content/docs/content-engine/editorial-schedule.webp and b/apps/web/content/docs/content-engine/editorial-schedule.webp differ diff --git a/apps/web/content/docs/content-engine/editorial-scheduling.mdx b/apps/web/content/docs/content-engine/editorial-scheduling.mdx new file mode 100644 index 0000000000..877c382c4e --- /dev/null +++ b/apps/web/content/docs/content-engine/editorial-scheduling.mdx @@ -0,0 +1,46 @@ +--- +title: Scheduling +description: Publish or unpublish a VitNode Content Engine record at a chosen time with editorial scheduling, the AdminCP Schedule dialog and the process-queue cron job. +icon: CalendarClock +--- + +import { ImgDocs } from "@/components/fumadocs/img"; +import schedule from "./editorial-schedule.webp"; + +Scheduling publishes or unpublishes a record at a chosen time. It needs [`editorial`](/docs/content-engine/editorial) and [drafts & publishing](/docs/content-engine/publication), and a running cron job. The examples use `newsroom.story` from the newsroom plugin. + +## Turn on scheduling + +Add `scheduling` to the `editorial` block: + +```ts title="plugins/newsroom/src/content/story.ts (excerpt)" +editorial: { + revisions: { retention: 20 }, + preview: { expiresInMinutes: 30 }, + scheduling: true, // [!code ++] +}, +``` + +`scheduling` changes no columns. Leaving out `publication` is a type error, and the definition throws when it loads. + +## Book a schedule + +Open **More actions**, choose **Schedule**, pick **Publish** or **Unpublish** and a time, then press **Schedule it**. Booking or cancelling a schedule needs `can_publish`. + + + +A schedule is a queued task, and the `process-queue` [cron job](/docs/dev/cron) drains the queue once a minute. If nothing triggers your cron endpoint, schedules still save but never fire. When the install has no cron adapter at all, the dialog says so with a **No scheduler is running** warning. When a schedule fires, it publishes whatever the record says at that moment, not what it said when you booked it. + +A few rules keep schedules sane: + +- The API accepts a time up to two minutes in the past. Anything older fails with `CONTENT_SCHEDULE_IN_PAST`. +- An unpublish cannot come before a pending publish (`CONTENT_SCHEDULE_ORDER`). +- Booking emits `content.newsroom.story.scheduled` and cancelling emits `schedule_cancelled`. When the time comes, the usual `published` or `unpublished` event carries the `scheduleId`. +- A scheduled publish of a [hidden](/docs/content-engine/content-visibility) record leaves it hidden, so it stays off the public site until someone unhides it. + +If a separate front end caches pages, [Notify a separate front end](/docs/content-engine/caching#notify-a-separate-front-end) tells it about the change. diff --git a/apps/web/content/docs/content-engine/editorial.mdx b/apps/web/content/docs/content-engine/editorial.mdx index 11a24e3660..fcc3efa3ea 100644 --- a/apps/web/content/docs/content-engine/editorial.mdx +++ b/apps/web/content/docs/content-engine/editorial.mdx @@ -1,41 +1,28 @@ --- -title: Editorial -description: Add revision history with restore, version checks against overwrites, signed draft preview links and scheduled publishing to a Content Engine content type. -icon: Clock +title: Revisions +description: Add revision history with restore and version checks against overwrites to a VitNode Content Engine content type with the editorial option. +icon: History --- import { Tab, Tabs } from "fumadocs-ui/components/tabs"; + import { ImgDocs } from "@/components/fumadocs/img"; import history from "./editorial-history.webp"; -import previewLink from "./editorial-preview-link.webp"; -import schedule from "./editorial-schedule.webp"; - -The `editorial` block gives a content type the tools an editorial team expects: a history of every save with restore, protection against two people overwriting each other, signed links that show a draft to someone without an AdminCP account, and publishing at a chosen time. It is also what [live editing](/docs/content-engine/live-editing) builds on, which you turn on separately with `liveEditing: true`. -This page uses `example.article` from the example plugin. It already has [drafts and publishing](/docs/content-engine/public-api) and a public API, which previews and scheduling build on. +The `editorial` block keeps a history of every save that staff can restore, and stops two people from overwriting each other. It is also what [previews](/docs/content-engine/editorial-previews), [scheduling](/docs/content-engine/editorial-scheduling) and [live editing](/docs/content-engine/live-editing) build on. The examples use `newsroom.story` from the newsroom plugin. -## Turn on editorial features +## Turn on revisions -Add `editorial` to the definition: +Add `editorial` to the definition. `true` keeps the newest 50 revisions per record. Set `revisions.retention` for another number, up to 500: -```ts title="plugins/example/src/content/article.ts" -export const articleContentType = defineContentType({ - id: "example.article", - tableName: "example_articles", - publication: true, - publicApi: { - path: "articles", - fields: ["title", "slug", "excerpt"], - }, - // [!code ++:5] +```ts title="plugins/newsroom/src/content/story.ts (excerpt)" +export const storyContentType = defineContentType({ + id: "newsroom.story", + // ... + // [!code ++:4] editorial: { revisions: { retention: 20 }, - preview: { expiresInMinutes: 30 }, - scheduling: true, - }, - fields: { - title: field.text({ required: true, minLength: 3, maxLength: 200 }), - slug: field.slug({ source: "title" }), + // ... }, }); ``` @@ -58,9 +45,7 @@ npm run db:migrate -The migration adds a `version` column to `example_articles`. Revisions, schedules, drafts and locks live in shared core tables, so nothing else lands in your schema. Editorial also adds a `can_restore` staff permission, labelled by the `@vitnode/example:article:can_restore` key in your plugin's locale file. - -`preview` needs `publicApi` and `scheduling` needs `publication`. Leaving out the partner is a type error, and the definition throws when it loads. Every option and default is in the [content type reference](/docs/content-engine/reference#editorial). +The migration adds a `version` column. Revisions, schedules, drafts and locks live in shared core tables, so nothing else lands in your schema. Editorial also adds a `can_restore` staff permission. Label it with the `newsroom:story:can_restore` key in your plugin's locale file. Every option and default is in the [content type reference](/docs/content-engine/reference#editorial). ## Revisions @@ -72,80 +57,23 @@ To see them, open the row's **More actions** menu in the AdminCP list and choose withoutBackground className="*:max-w-full" src={history} - alt="History of this Article dialog: v3 by Tom Becker expanded to show the title change from Spring menu launch to Spring menu launches Monday and Featured switched on, with Restore buttons on v2 and v1" + alt="History of this Story dialog: v3 by Test Admin expanded to show the title change from Spring menu launch to Spring menu launches Monday and Featured switched on, with Restore buttons on v2 and v1" /> -**Restore** puts that version's field values back as a new version, so nothing in between is lost. The publication status and the [hidden](/docs/content-engine/content-visibility) state stay as they are. It needs `can_restore` and emits `content.example.article.restored`. Anyone with the record open in [live editing](/docs/content-engine/live-editing) gets a notice and a reloaded form. +**Restore** puts that version's field values back as a new version, so nothing in between is lost. The publication status and the [hidden](/docs/content-engine/content-visibility) state stay as they are. It needs `can_restore` and emits `content.newsroom.story.restored`. Anyone with the record open in [live editing](/docs/content-engine/live-editing) gets a notice and a reloaded form. ## Safe concurrent edits Each editorial write raises the record's `version` by one and succeeds only when the version still matches the one the editor loaded. If someone saved in between, the second save fails instead of quietly replacing their work: -```json title="PUT /api/@vitnode/example/admin/content/article/2 → 409" +```json title="PUT /api/newsroom/admin/content/story/2 → 409" { "code": "CONTENT_VERSION_CONFLICT", - "contentTypeId": "example.article", + "contentTypeId": "newsroom.story", "itemId": 2, "expectedVersion": 2, "currentVersion": 3 } ``` -The AdminCP sends the version for you. Your own calls to the edit routes must send it too: `PUT /{id}` takes `{ "expectedVersion": 3, "values": { "title": "…" } }` and `DELETE /{id}` takes `{ "expectedVersion": 3 }`. In server code, `articleContent.editorialService` does the same check; see [Save like the AdminCP](/docs/content-engine/services#save-like-the-admincp). A translation has its own version and fails with `CONTENT_TRANSLATION_VERSION_CONFLICT`. - -## Preview links - -A preview link lets a reviewer read a draft without signing in. In the AdminCP, open **More actions** and choose **Preview**. Creating a link needs `can_view`. - - - -The link is HMAC-signed, expires after `expiresInMinutes` (15 by default, 1 to 1440) and cannot be revoked early, so treat it like a password. It is pinned to the last saved version, so it shows what **Save** committed, not unsaved [live editing](/docs/content-engine/live-editing) drafts. - -The link points to the first of these that exists: - -1. `preview.pathTemplate`, such as `/preview/articles/{token}`, with exactly one `{token}`. -2. The record's own page with `?preview=`, when the type has [delivery](/docs/content-engine/public-pages). -3. The public API route `GET /api/@vitnode/example/content/articles/preview/{token}`. - -Every page still has to load the draft from that API route. A page built from [Show content on a public page](/docs/content-engine/public-pages) does not read `?preview=` on its own, so the example's link opens a 404 until you check for it in your loader: - -```ts title="Read a preview in a page loader" -const preview = await fetcher({ - plugin: "@vitnode/example", - method: "get", - module: "content/articles", - path: "/preview/{token}", - args: { params: { token: encodeURIComponent(token) } }, -}); -``` - -The route returns the record's public fields, and the same `404` for a forged, expired or unknown token. It sends `Cache-Control: private, no-store` and `X-Robots-Tag: noindex, nofollow`, so the draft stays out of caches and search results. - -Links are built from `VITNODE_WEB_URL` and `VITNODE_API_URL`. When either is not an absolute URL, the API logs a warning at startup and the **Preview** button answers `503`. - -## Schedule publishing - -Scheduling publishes or unpublishes a record at a chosen time. Open **More actions**, choose **Schedule**, pick **Publish** or **Unpublish** and a time, then press **Schedule it**. Booking or cancelling a schedule needs `can_publish`. - - - -A schedule is a queued task, drained by the `process-queue` [cron job](/docs/dev/cron) once a minute. If nothing triggers your cron endpoint, schedules are saved but never fire. When the install has no cron adapter at all, the dialog says so with a **No scheduler is running** warning. When a schedule fires, it publishes whatever the record says at that moment, not what it said when you booked it. - -A few rules keep schedules sane: - -- A time up to two minutes in the past is accepted. Anything older fails with `CONTENT_SCHEDULE_IN_PAST`. -- An unpublish cannot come before a pending publish (`CONTENT_SCHEDULE_ORDER`). -- Booking emits `content.example.article.scheduled` and cancelling emits `schedule_cancelled`. When the time comes, the usual `published` or `unpublished` event carries the `scheduleId`. -- A scheduled publish of a [hidden](/docs/content-engine/content-visibility) record leaves it hidden, so it stays off the public site until someone unhides it. - -If a separate front end caches pages, [Notify a separate front end](/docs/content-engine/caching#notify-a-separate-front-end) tells it about the change. +The AdminCP sends the version for you. Your own calls to the edit routes must send it too: `PUT /{id}` takes `{ "expectedVersion": 3, "values": { "title": "…" } }` and `DELETE /{id}` takes `{ "expectedVersion": 3 }`. In server code, `storyContent.editorialService` does the same check, as [Save like the AdminCP](/docs/content-engine/services#save-like-the-admincp) shows. A translation has its own version and fails with `CONTENT_TRANSLATION_VERSION_CONFLICT`. diff --git a/apps/web/content/docs/content-engine/events.mdx b/apps/web/content/docs/content-engine/events.mdx index 219dbe9f5d..fc55bd3a69 100644 --- a/apps/web/content/docs/content-engine/events.mdx +++ b/apps/web/content/docs/content-engine/events.mdx @@ -4,13 +4,13 @@ description: Every event a Content Engine content type emits, its payload and th icon: Radio --- -Each content type emits events named `content..` after a change, such as `content.example.article.published`. Subscribe to them to send a newsletter, sync an external index or purge a CDN, without touching the code that made the change. +Each content type emits events named `content..` after a change, such as `content.newsroom.story.published`. Subscribe to them to send a newsletter, sync an external index or purge a CDN, without touching the code that made the change. Events come from the generated routes, scheduled publishing and [`contentEditorialEffects`](/docs/content-engine/services#save-like-the-admincp). The plain `service(c)` emits nothing. A save that changes nothing emits nothing either. ## Event reference -Which events exist depends on what the definition enables. `example.category` declares only fields and emits three; `example.article` has publication, editorial with scheduling and delivery, and emits ten. +Which events exist depends on what the definition enables. `newsroom.category` declares only fields and emits three. `newsroom.story` has publication, editorial with scheduling and delivery, and emits ten. | Action | Payload | Needs | | --------------------------- | ----------------------------------------------------------------------------------------------- | ------------------------------ | @@ -58,23 +58,23 @@ The listener also receives an envelope with the actor, the time and `pluginId`. ### Register the event types -Add the content type to the global event map once per plugin. The listener's event name and payload are then type-checked, and an event the definition does not enable, such as `content.example.category.published`, does not compile. +Add the content type to the global event map once per plugin. The listener's event name and payload are then type-checked, and an event the definition does not enable, such as `content.newsroom.category.published`, does not compile. -```ts title="plugins/example/src/api/lib/events.ts" +```ts title="plugins/newsroom/src/api/lib/events.ts" import type { ContentEventsFor } from "@vitnode/core/content"; -import type { articleContentType } from "@/content/article"; import type { categoryContentType } from "@/content/category"; +import type { storyContentType } from "@/content/story"; declare module "@vitnode/core/api/models/events" { interface VitNodeEvents extends - ContentEventsFor, + ContentEventsFor, ContentEventsFor {} } ``` -The example plugin imports this file from `config.api.ts` with `import "@/api/lib/events";`, so the declaration travels with the API build. +The newsroom plugin imports this file from `config.api.ts` with `import "@/api/lib/events";`, so the declaration travels with the API build. @@ -83,18 +83,18 @@ The example plugin imports this file from `config.api.ts` with `import "@/api/li `contentEventName(id, action)` builds the name from the definition, so a renamed content type cannot leave a stale string behind: -```ts title="plugins/example/src/api/lib/listeners.ts" +```ts title="plugins/newsroom/src/api/lib/listeners.ts" import { buildEventListener } from "@vitnode/core/api/lib/events"; import { contentEventName } from "@vitnode/core/content"; -import { articleContentType } from "@/content/article"; +import { storyContentType } from "@/content/story"; -export const announceArticleListener = buildEventListener({ - event: contentEventName(articleContentType.id, "published"), - name: "announce-published-article", - description: "Logs every article that goes live", +export const announceStoryListener = buildEventListener({ + event: contentEventName(storyContentType.id, "published"), + name: "announce-published-story", + description: "Logs every story that goes live", handler: async (c, payload) => { - await c.get("log").debug(`Article ${payload.contentId} was published`); + await c.get("log").debug(`Story ${payload.contentId} was published`); }, }); ``` @@ -106,16 +106,28 @@ export const announceArticleListener = buildEventListener({ VitNode collects listeners only from the `events` array of a module passed directly to `buildApiPlugin`. A listener on a nested module is silently ignored. -```ts title="plugins/example/src/api/modules/admin/admin.module.ts" +```ts title="plugins/newsroom/src/api/modules/admin/admin.module.ts (excerpt)" export const adminModule = buildModule({ name: "admin", routes: [], modules: [ buildContentAdminModule({ - contentTypes: [articleContent], + containers: [deskContainers], + contentTypes: [ + articleContent, + categoryContent, + commentContent, + storyContent, + explainerContent, + bulletinContent, + tagContent, + eventContent, + pageContent, + letterContent, + ], }), ], - events: [announceArticleListener], // [!code ++] + events: [announceStoryListener], // [!code ++] }); ``` @@ -126,6 +138,6 @@ The blog plugin does the same with its `blogLegacyEventListeners`, which turn `c ## Check the result -Save the file, publish an article in **AdminCP → Example → Articles** and look for the debug line in the API terminal. A failing listener does not undo the change. The write has already committed, and the engine logs the failure. +Save the file, publish a story in **AdminCP → Newsroom → Stories** and look for the debug line in the API terminal. A failing listener does not undo the change. The write has already committed, and the engine logs the failure. Events from a scheduled publish run in a queue task that retries on failure, so the same `published` event can arrive twice. Make work that must happen once idempotent, for example keyed on `scheduleId`. See [A scheduled event may arrive twice](/docs/dev/events/built-in-events#a-scheduled-event-may-arrive-twice). diff --git a/apps/web/content/docs/content-engine/fields-article-form-basic.webp b/apps/web/content/docs/content-engine/fields-article-form-basic.webp index ae0e9fd875..cc9282b319 100644 Binary files a/apps/web/content/docs/content-engine/fields-article-form-basic.webp and b/apps/web/content/docs/content-engine/fields-article-form-basic.webp differ diff --git a/apps/web/content/docs/content-engine/fields-article-form-references.webp b/apps/web/content/docs/content-engine/fields-article-form-references.webp index c7559da0d9..75698a951a 100644 Binary files a/apps/web/content/docs/content-engine/fields-article-form-references.webp and b/apps/web/content/docs/content-engine/fields-article-form-references.webp differ diff --git a/apps/web/content/docs/content-engine/fields.mdx b/apps/web/content/docs/content-engine/fields.mdx index 50b95bd52a..2506ea756c 100644 --- a/apps/web/content/docs/content-engine/fields.mdx +++ b/apps/web/content/docs/content-engine/fields.mdx @@ -10,12 +10,13 @@ import formReferences from "./fields-article-form-references.webp"; A field is one property of a content type, such as a title or a view count. You build each one with a helper from `field`, exported by `@vitnode/core/content`. The helper decides three things at once: the Postgres column, the validation of every write and the control in the AdminCP form. -```ts title="plugins/example/src/content/article.ts" +```ts title="plugins/newsroom/src/content/story.ts (excerpt)" import { defineContentType, field } from "@vitnode/core/content"; -export const articleContentType = defineContentType({ - id: "example.article", - tableName: "example_articles", +export const storyContentType = defineContentType({ + id: "newsroom.story", + tableName: "newsroom_stories", + fields: { title: field.text({ required: true, minLength: 3, maxLength: 200 }), slug: field.slug({ source: "title" }), @@ -26,13 +27,13 @@ export const articleContentType = defineContentType({ }); ``` -The example plugin's article form shows most controls. The first half holds the plain values: +The newsroom plugin's story form shows most controls. The first half holds the plain values: The second half holds the references: a user picker, two file dropzones and a relation picker. @@ -41,7 +42,7 @@ The second half holds the references: a user picker, two file dropzones and a re withoutBackground className="*:max-w-full" src={formReferences} - alt="Lower half of the Create Article dialog: an Author select, an Animation dropzone that accepts one GIF up to 10 MB, a Gallery dropzone for up to 8 images and a Category search picker" + alt="Lower half of the Create Story dialog: an Author select, an Animation dropzone that accepts one GIF up to 10 MB, a Gallery dropzone for up to 8 images and a Category search picker" /> ## Field helpers @@ -175,7 +176,7 @@ slug: field.slug({ source: "title" }), A slug has no `required` or `nullable` option. It is required when there is no `source`, and it is never empty. Every value is normalized, so `"Weekend Pancakes!"` becomes `weekend-pancakes`. Slugs are unique across the table, or within each language when localized. The slug is derived only on create, so renaming the title later keeps the old URL. `source` must name a `field.text()`, and a localized slug needs a localized source. -For records without a natural title, such as forum comments, use `field.slug({ generated: true })`. The server mints a random token of lowercase letters and digits, 10 characters or `maxLength` if shorter, such as `k3v9q2m7xa`. A generated slug cannot be `localized` or have a `source`, and it is never [publicly writable](/docs/content-engine/public-write-api#writable-fields). See [Threaded replies](/docs/content-engine/threads). +For records without a natural title, such as forum comments, use `field.slug({ generated: true })`. The server mints a random token of lowercase letters and digits, 10 characters or `maxLength` if shorter, such as `k3v9q2m7xa`. A generated slug cannot be `localized` or have a `source`, and it is never [publicly writable](/docs/content-engine/public-write-api#writable-fields). See [Comments](/docs/content-engine/threads). ## User @@ -236,12 +237,12 @@ category: field.relation({ required: true, onDelete: "restrict", target: () => c A single relation must be `required` or `nullable`. A to-many relation can be neither. A to-one `self: true` relation, such as the reply a reply answers, must be `nullable`, because the first record has nothing to point at. -`container: true` files the record in the container of the record it points at, such as a comment in its topic's forum. The server always reads that container from the related row, so a client cannot claim another one, and [publishing policies](/docs/content-engine/container-policies) and container-scoped moderators apply as if the record had its own `field.container()`. A content type takes its container from one field only: one `field.container()` or one `container: true` relation. The target must have a `field.container()`, or the API stops at boot with ``Field "x" is `container: true`, but its target ... is not filed in a container.`` The column has the type of the target's key: `integer`, `uuid` or `bigint`, depending on its [`idStrategy`](/docs/content-engine/id-strategy). [Relations](/docs/content-engine/relations) shows the model wiring and the junction tables. +`container: true` files the record in the container of the record it points at, such as a comment in its topic's forum. The server always reads that container from the related row, so a client cannot claim another one, and [publishing policies](/docs/content-engine/container-policies) and container-scoped moderators apply as if the record had its own `field.container()`. A content type takes its container from one field only: one `field.container()` or one `container: true` relation. The target must have a `field.container()`, or the API stops at boot with ``Field "x" is `container: true`, but its target ... is not filed in a container.`` The column has the type of the target's key: `integer`, `uuid` or `bigint`, depending on its [`idStrategy`](/docs/content-engine/id-strategy). [Relations](/docs/content-engine/relations) shows the model wiring, and [Advanced fields](/docs/content-engine/relations-advanced) the junction tables. ## Container ```ts -board: field.container({ definition: boardContainers, required: true }), +desk: field.container({ definition: deskContainers, required: true }), ``` | Name | Type | Required | Default | Description | @@ -250,11 +251,11 @@ board: field.container({ definition: boardContainers, required: true }), | `required` | `boolean` | No | `false` | Every record needs a container. Without it, a record may have none | | `description` | `string` | No | | Text or an i18n key shown under the picker in the AdminCP | -`field.container()` files a record into one container of a [container collection](/docs/content-engine/containers), such as a discussion into a forum board. `nullable` is always the opposite of `required`, so there is no `nullable` option, no default and no `localized`. A content type can have at most one container field, and its collection must be registered with `buildContentAdminModule({ containers })`. +`field.container()` files a record into one container of a [container collection](/docs/content-engine/containers), such as a letter into a newsroom desk. `nullable` is always the opposite of `required`, so there is no `nullable` option, no default and no `localized`. A content type can have at most one container field, and you must register its collection with `buildContentAdminModule({ containers })`. The column is an `ON DELETE RESTRICT` foreign key with an index on the field (plus `publishedAt` when `publication` is on), so a container that still holds records cannot be deleted. Every write checks that the id belongs to the field's collection and answers `400` with `CONTAINER_NOT_FOUND` or `CONTAINER_DEFINITION_MISMATCH` otherwise. -The public API returns the container as its id. In `publicApi.filterableFields`, it filters a list with `?board=4`, and `includeDescendants=true` widens that to the whole subtree. In `publicWrite`, it may be listed in `create.fields` but not in `update.fields`. [Containers on the frontend](/docs/content-engine/container-frontend) shows both. +The public API returns the container as its id. In `publicApi.filterableFields`, it filters a list with `?desk=4`, and `includeDescendants=true` widens that to the whole subtree. In `publicWrite`, it may be listed in `create.fields` but not in `update.fields`. [Container pages](/docs/content-engine/container-frontend) shows both. ## Group @@ -298,7 +299,7 @@ Rows live in a child table. A repeatable is always shared between languages. ## Blocks ```ts -content: field.blocks({ allowed: ["core:*", "example:callout", "example:features"], max: 50 }), +content: field.blocks({ allowed: ["core:*", "newsroom:callout", "newsroom:features"], max: 50 }), ``` | Name | Type | Required | Default | Description | @@ -318,7 +319,7 @@ content: field.blocks({ allowed: ["core:*", "example:callout", "example:features ```ts excerpt: field.textarea({ ai: { - action: "@vitnode/example:article.excerpt", + action: "newsroom:story.excerpt", mode: "suggestion", sourceFields: ["title", "code"], }, @@ -333,7 +334,7 @@ excerpt: field.textarea({ | `mode` | `"suggestion"` | Yes | | The only supported mode. The editor reviews the text before it is used | | `sourceFields` | `string[]` | Yes | | Other fields of this content type sent to the action as input | -[AI in fields and the editor](/docs/dev/ai/fields-and-editor) shows how to write the action. +[Field suggestions](/docs/dev/ai/fields-and-editor) shows how to write the action. ## Searching and sorting diff --git a/apps/web/content/docs/content-engine/id-strategy.mdx b/apps/web/content/docs/content-engine/id-strategy.mdx index a13574a3bd..fb65b830f0 100644 --- a/apps/web/content/docs/content-engine/id-strategy.mdx +++ b/apps/web/content/docs/content-engine/id-strategy.mdx @@ -1,26 +1,34 @@ --- -title: Record ID strategies +title: Record IDs description: Key VitNode Content Engine records by serial integers, UUIDs or bigint, and see how those ids look in Postgres, JSON, URLs, relations and your own code. icon: KeyRound --- `idStrategy` decides the type of a content type's primary key. The default, `"serial"`, is an auto-incrementing integer. `"uuid"` gives random ids that reveal nothing about how many records exist. `"bigint"` gives integers that will not run out. Pick it when you create the content type, because changing it later means a [hand-written data migration](#change-the-strategy-later), and nobody enjoys those. -The example plugin keys its tags by UUID: +The newsroom plugin keys its tags by UUID: -```ts title="plugins/example/src/content/tag.ts" +```ts title="plugins/newsroom/src/content/tag.ts (excerpt)" export const tagContentType = defineContentType({ - id: "example.tag", - tableName: "example_tags", + id: "newsroom.tag", + tableName: "newsroom_tags", idStrategy: "uuid", // [!code ++] + localization: { defaultLocale: "en", fallback: "default" }, publication: true, + // visibility, duplication and editorial + fields: { - name: field.text({ localized: true, required: true, minLength: 1, maxLength: 100 }), + name: field.text({ + localized: true, + required: true, + minLength: 1, + maxLength: 100, + }), slug: field.slug({ localized: true, source: "name" }), // category and relatedTags relations }, - // editorial, visibility, duplication, publicApi and admin + // publicApi and admin }); ``` @@ -35,19 +43,19 @@ export const tagContentType = defineContentType({ Choose by what the ids are for: - **`serial`** suits most content. Ids are short and readable in URLs and logs. The blog's articles and categories use it. -- **`uuid`** suits content whose ids should not be guessable or countable, or records created in several places that must not collide. The example plugin's `example.tag` uses it. -- **`bigint`** suits tables that may outgrow two billion rows, such as events or logs. The example plugin's `example.event` uses it. +- **`uuid`** suits content whose ids should not be guessable or countable, or records created in several places that must not collide. The newsroom plugin's `newsroom.tag` uses it. +- **`bigint`** suits tables that may outgrow two billion rows, such as events or logs. The newsroom plugin's `newsroom.event` uses it. -Both example types appear in **AdminCP → Example → Tags** and **Events**, and their edit links carry the UUID or the decimal string, such as `/admin/content/example/event/9007199254740994/edit`. +Both types appear in **AdminCP → Newsroom → Tags** and **Events**, and their edit links carry the UUID or the decimal string, such as `/admin/content/newsroom/event/9007199254740994/edit`. ### Why bigint ids are strings A JavaScript number holds integers exactly only up to 2^53 − 1 (`9007199254740991`). A bigint key can be much larger, and a larger id read as a number is silently rounded to a different record: `Number("9007199254740993")` is `9007199254740992`. VitNode never converts a bigint id through `Number`. Drizzle reads the column in `string` mode, and every API, event, cursor and AdminCP URL carries the exact decimal string. -Once the `example_events` id sequence is past 2^53, the public API still returns every id intact: +Once the `newsroom_events` id sequence is past 2^53, the public API still returns every id intact: ```bash -curl "http://localhost:3000/api/@vitnode/example/content/events/big-launch" +curl "http://localhost:3000/api/newsroom/content/events/big-launch" ``` ```json title="Response (trimmed)" @@ -60,7 +68,7 @@ curl "http://localhost:3000/api/@vitnode/example/content/events/big-launch" } ``` -`primaryTag` and `tags` point at `example.tag` records, so they carry UUIDs. Keep bigint ids as strings in your own code too. +`primaryTag` and `tags` point at `newsroom.tag` records, so they carry UUIDs. Keep bigint ids as strings in your own code too. ## Valid ids @@ -78,8 +86,8 @@ Each record has exactly one spelling, so one record is never cached or indexed u Every Content Engine feature works with all three strategies: the public API, the AdminCP lists, editors, pickers and edit links, [public pages](/docs/content-engine/public-pages) and sitemaps, previews, schedules, revisions, [live editing](/docs/content-engine/live-editing), events, search, pagination cursors, [duplicate](/docs/content-engine/duplicate-content) and [hide](/docs/content-engine/content-visibility). -- **Relations** use the key type of the record they point at. A bigint event that links to uuid tags gets a `uuid` foreign key and a `bigint`-to-`uuid` junction table. See [Relations across id strategies](/docs/content-engine/relations#relations-across-id-strategies). -- **Shared core tables** that hold records of any content type, such as revisions, schedules, slug history, live editing drafts and the search index, store the id as text in a `varchar(64)` `itemId` column. See [Database](/docs/content-engine/database#ids-and-shared-tables). +- **Relations** use the key type of the record they point at. A bigint event that links to uuid tags gets a `uuid` foreign key and a `bigint`-to-`uuid` junction table. See [Relations across id strategies](/docs/content-engine/relations-advanced#relations-across-id-strategies). +- **Shared core tables** that hold records of any content type, such as revisions, schedules, slug history, live editing drafts and the search index, store the id as text in a `varchar(64)` `itemId` column. See [Migrations](/docs/content-engine/database#ids-and-shared-tables). - **Events** type `contentId` by strategy: `number` for serial, `string` for uuid and bigint. - **Sitemaps** page through records in key order. For a uuid content type that order is lexical, not creation order, and every record still appears once. - **Repeatable rows** keep their own `serial` ids, whatever the owner uses. User and file references stay integers. diff --git a/apps/web/content/docs/content-engine/index-articles-list.webp b/apps/web/content/docs/content-engine/index-articles-list.webp deleted file mode 100644 index e913bb33fd..0000000000 Binary files a/apps/web/content/docs/content-engine/index-articles-list.webp and /dev/null differ diff --git a/apps/web/content/docs/content-engine/index-public-form.webp b/apps/web/content/docs/content-engine/index-public-form.webp deleted file mode 100644 index 36a5dec002..0000000000 Binary files a/apps/web/content/docs/content-engine/index-public-form.webp and /dev/null differ diff --git a/apps/web/content/docs/content-engine/index.mdx b/apps/web/content/docs/content-engine/index.mdx index 7279d6ed46..0e7cc9436b 100644 --- a/apps/web/content/docs/content-engine/index.mdx +++ b/apps/web/content/docs/content-engine/index.mdx @@ -1,79 +1,73 @@ --- title: Introduction -description: Declare a content type once in TypeScript and the VitNode Content Engine generates its Postgres table, validation, staff API, permissions and AdminCP screens. +description: The VitNode Content Engine turns one TypeScript definition into a Postgres table, validation, staff API, permissions, AdminCP screens and optional public routes. Start with the beginner tutorial or add a feature. icon: Boxes --- import { ImgDocs } from "@/components/fumadocs/img"; -import articlesList from "./index-articles-list.webp"; -import publicForm from "./index-public-form.webp"; -import publicList from "./public-lists-discussions.webp"; -import publicPage from "./reports-discussion-page.webp"; - -The Content Engine turns one TypeScript definition into structured content for your plugin. You describe the fields of an article or a category, and VitNode generates the database table, Zod validation, staff-only API routes, staff permissions and AdminCP screens. Optional blocks add drafts, a public API, pages with SEO metadata, revisions, scheduling, live editing, translations, search, hiding and duplication. - -This is the whole definition behind the example plugin's **Categories** screen: - -```ts title="plugins/example/src/content/category.ts" -import { defineContentType, field } from "@vitnode/core/content"; - -export const categoryContentType = defineContentType({ - id: "example.category", - tableName: "example_categories", - fields: { - name: field.text({ required: true, minLength: 1, maxLength: 100 }), - }, - admin: { - path: "example/categories", - list: { - columns: ["name", "createdAt"], - orderableFields: ["name"], - }, - }, -}); -``` - -Its sibling `example.article` turns on more blocks. Same engine, more fields, a busier list: +import articlesList from "./create-a-content-type-articles-list.webp"; +import newsList from "./public-lists-news.webp"; - +The Content Engine turns one TypeScript definition into structured content for your plugin. You describe the fields of a content type, such as an article, and VitNode generates its database table, validation, staff API routes, staff permissions and AdminCP screens. Optional blocks add drafts, a public API, member writing, reports, comments, revisions and more. + +## Start here -## On the public site +The beginner tutorial builds one small newsroom, step by step, with an article that has a title, a slug and a body. Each page continues the previous one: -The same definition also drives the public side. Add `publicApi` and the records become readable; a few more options and members write their own. The example plugin's discussions use all of it. +1. [First content type](/docs/content-engine/create-a-content-type): define the article and manage it in the AdminCP. +2. [Drafts & publishing](/docs/content-engine/publication): keep unfinished articles private until staff publish them. +3. [Public API](/docs/content-engine/public-api): let anyone read published articles, and only the fields you choose. +4. [Content list](/docs/content-engine/public-lists): show the articles at `/news`, a page at a time. +5. [Content page](/docs/content-engine/public-pages): give every article its own page at `/news/:slug`. -A list with search, filters and pages, from [Public lists](/docs/content-engine/public-lists): +After that, [Embedded list](/docs/content-engine/public-lists-embed) puts the newest articles under each one, and every guide below builds on the same article. -A page for each record, from [Public pages](/docs/content-engine/public-pages), with **Report** from [Report content](/docs/content-engine/reports): +## What is generated and what you write - +The Content Engine owns the data and the rules, and your plugin owns every page readers see: + +| You build | The Content Engine provides | Your plugin writes | +| --- | --- | --- | +| The AdminCP screen | The list, search, sorting, create and edit forms, publish and hide actions | The sidebar link and, optionally, [custom cells or form layouts](/docs/content-engine/admincp) | +| A list page | The list route, `defineContentList`, `contentListQueryOptions`, `ContentListResults`, `ContentListPagination` | The route, the row layout and the labels | +| A record page | Slug resolution, `404`s, redirects and metadata through `contentDeliveryPage` | The page component, with `RichTextContent` for rich text | +| A member form | The write routes, ownership, validation, `useContentPublicWrite` | The fields with `AutoForm`, success messages and navigation | +| Edit and Report buttons | `canEdit` and `canReport` per visitor, `useContentPermissions`, `ReportButton` | Where the buttons go | +| Comments | The thread route and its placement rules | The comment section, its composer and its refresh | + +The page helpers come from `@vitnode/core/tanstack/content`, and every request goes through the typed [`fetcher`](/docs/dev/fetcher) with `module: "content/"`. Public pages are the same for every reader, so they can be cached. The browser asks for anything that depends on the visitor, such as an **Edit** button, and the server checks every write again. -And a form members post with, from [Public create form](/docs/content-engine/public-write-frontend): +## Add a feature + +Each guide starts from a content type you already have, says what it needs at the top, and ends with something you can try. [Reports](/docs/content-engine/reports) and [Relations](/docs/content-engine/relations), for example, only need the beginner path. Comments also need [Member writing](/docs/content-engine/public-write). + +| Area | Guides | +| --- | --- | +| Public content | [Embedded list](/docs/content-engine/public-lists-embed) | +| Member content | [Member writing](/docs/content-engine/public-write), [Create form](/docs/content-engine/public-write-frontend), [Edit form](/docs/content-engine/public-write-edit), [Permissions](/docs/content-engine/public-permissions) | +| Interactions | [Reports](/docs/content-engine/reports), [Comments](/docs/content-engine/threads), [Comment editing](/docs/content-engine/threads-editing), [Replies](/docs/content-engine/threads-frontend) | +| Organization | [Relations](/docs/content-engine/relations), [Advanced fields](/docs/content-engine/relations-advanced), [Containers](/docs/content-engine/containers), [Container pages](/docs/content-engine/container-frontend) | +| Moderation | [Approvals](/docs/content-engine/approvals), [Approval notices](/docs/content-engine/approval-notices), [Publishing rules](/docs/content-engine/container-policies), [Visibility](/docs/content-engine/content-visibility) | +| Content features | [Rich text](/docs/content-engine/rich-text), [Translations](/docs/content-engine/localization), [Search](/docs/content-engine/search), [SEO](/docs/content-engine/seo) | +| Editorial | [Revisions](/docs/content-engine/editorial), [Previews](/docs/content-engine/editorial-previews), [Scheduling](/docs/content-engine/editorial-scheduling), [Live editing](/docs/content-engine/live-editing), [Duplication](/docs/content-engine/duplicate-content) | +| Customization | [AdminCP](/docs/content-engine/admincp), [Services](/docs/content-engine/services), [Events](/docs/content-engine/events) | +| Deployment | [Migrations](/docs/content-engine/database), [Caching](/docs/content-engine/caching), [Production](/docs/content-engine/production) | -## What you get +## What each option adds | From the definition | VitNode generates | | ------------------- | -------------------------------------------------------------------------------------- | @@ -95,43 +89,8 @@ And a form members post with, from [Public create form](/docs/content-engine/pub Content types live in code and in your migrations, not in the database. There is no screen for creating a content type at runtime. -## Pick a guide - -| You want to | Guide | -| -------------------------------------------------------- | --------------------------------------------------------------------- | -| Build your first content type, with its AdminCP screen | [First content type](/docs/content-engine/create-a-content-type) | -| Let anyone read published records through an API | [Public API](/docs/content-engine/public-api) | -| Understand how public reads are cached and refreshed | [Caching](/docs/content-engine/caching) | -| Give each record its own page with SEO metadata | [Public pages](/docs/content-engine/public-pages) | -| Keep drafts private until an editor publishes them | [Publication](/docs/content-engine/publication) | -| Show a list with search, filters and pages | [Public lists](/docs/content-engine/public-lists) | -| Let members create and edit records from the site | [Public write](/docs/content-engine/public-write) | -| Let readers report content to moderators | [Report content](/docs/content-engine/reports) | -| Control titles, descriptions, noindex and the sitemap | [SEO](/docs/content-engine/seo) | -| Make records findable in the site search | [Search](/docs/content-engine/search) | -| Link records, group fields or repeat rows | [Relations](/docs/content-engine/relations) | -| Organize records into boards, sections or categories | [Containers](/docs/content-engine/containers) | -| Show a container tree and its content on the site | [Containers on the frontend](/docs/content-engine/container-frontend) | -| Decide who posts in each container and what needs review | [Publishing policies](/docs/content-engine/container-policies) | -| Let moderators approve new posts and edits first | [Approvals](/docs/content-engine/approvals) | -| Build nested replies under a topic or a post | [Threaded replies](/docs/content-engine/threads) | -| Translate content into several languages | [Translations](/docs/content-engine/localization) | -| Store and render formatted text | [Rich text](/docs/content-engine/rich-text) | -| Keep revisions, share previews or schedule publishing | [Editorial](/docs/content-engine/editorial) | -| Let several editors work on one record at the same time | [Live editing](/docs/content-engine/live-editing) | -| Take a record off the public site without unpublishing | [Hide content](/docs/content-engine/content-visibility) | -| Let editors copy a record as a new draft | [Duplicate content](/docs/content-engine/duplicate-content) | -| Key records by UUID or bigint instead of serial integers | [Record ID strategies](/docs/content-engine/id-strategy) | -| Change list columns, form sections or field components | [AdminCP](/docs/content-engine/admincp) | -| Read and write content from your own API routes | [Services](/docs/content-engine/services) | -| React to created, updated or published records | [Events](/docs/content-engine/events) | -| Understand the generated tables and ship a schema change | [Database](/docs/content-engine/database) | -| Check a content type before it goes live | [Production](/docs/content-engine/production) | - ## Reference - [Options](/docs/content-engine/reference): every `defineContentType` option and how the options depend on each other. - [Fields](/docs/content-engine/fields): every `field.*` helper with its options, column type and form control. - [Plugin files](/docs/content-engine/plugin-files): the files and exports the app and the API read from your plugin. -- [Live editing API](/docs/content-engine/live-editing-api): the HTTP API and deployment requirements behind live editing. -- [Publishing policies reference](/docs/content-engine/container-policies-api): policy keys, resolution order, routes and errors. diff --git a/apps/web/content/docs/content-engine/live-editing-api.mdx b/apps/web/content/docs/content-engine/live-editing-api.mdx index 34441af016..39e8606f52 100644 --- a/apps/web/content/docs/content-engine/live-editing-api.mdx +++ b/apps/web/content/docs/content-engine/live-editing-api.mdx @@ -4,7 +4,7 @@ description: Reference for Content Engine live editing, covering deployment requ icon: Cable --- -The AdminCP drives [live editing](/docs/content-engine/live-editing) for you. This page is for the cases where you need the details: deploying it, calling its routes from your own code, or building a form layout that shows who is editing. Examples use `example.article`, whose staff API lives at `/api/@vitnode/example/admin/content/article`. +The AdminCP drives [live editing](/docs/content-engine/live-editing) for you. This page is for the cases where you need the details: deploying it, calling its routes from your own code, or building a form layout that shows who is editing. Examples use `newsroom.story`, whose staff API lives at `/api/newsroom/admin/content/story`. ## Deployment requirements @@ -40,7 +40,7 @@ The routes live under the content type's staff API module. Every route needs an Taking a lock on the excerpt: ```http title="Request" -POST /api/@vitnode/example/admin/content/article/2/locks +POST /api/newsroom/admin/content/story/2/locks Content-Type: application/json { "field": "excerpt", "locale": null, "action": "acquire" } @@ -73,7 +73,7 @@ When someone else asks for the same field, the `409` names the holder: Reading the draft returns the shared fields and one draft per language: -```json title="GET /api/@vitnode/example/admin/content/article/2/draft" +```json title="GET /api/newsroom/admin/content/story/2/draft" { "shared": { "baseVersion": 3, @@ -127,14 +127,14 @@ The daily `content-editorial-cleanup` [cron job](/docs/dev/cron) removes expired A plugin that replaces the edit form with [its own layout](/docs/content-engine/admincp) still gets locks and outlines on every field it renders with `ContentFormField`. The rest of the live UI comes from `@vitnode/core/content/admin-form`: -```tsx title="plugins/example/src/views/admin/article-layout.tsx" +```tsx title="story-form-layout.tsx" import { ContentFormField, ContentLivePresence, ContentLiveStatus, } from "@vitnode/core/content/admin-form"; -export const ArticleFormLayout = () => ( +export const StoryFormLayout = () => (
@@ -146,7 +146,7 @@ export const ArticleFormLayout = () => ( ); ``` -Register it with `forms: { layout: ArticleFormLayout }` in the type's `contentTypeAdmin(...)` entry, the way the blog plugin registers `BlogArticleFormLayout`. +Register it with `forms: { layout: StoryFormLayout }` in the type's `contentTypeAdmin(...)` entry, the way the blog plugin registers `BlogArticleFormLayout`. | Export | What it gives you | | ----------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | diff --git a/apps/web/content/docs/content-engine/live-editing-field-lock.webp b/apps/web/content/docs/content-engine/live-editing-field-lock.webp index 156f47e947..9d34e3cd04 100644 Binary files a/apps/web/content/docs/content-engine/live-editing-field-lock.webp and b/apps/web/content/docs/content-engine/live-editing-field-lock.webp differ diff --git a/apps/web/content/docs/content-engine/live-editing.mdx b/apps/web/content/docs/content-engine/live-editing.mdx index d4e98fb80c..9b71f3f48e 100644 --- a/apps/web/content/docs/content-engine/live-editing.mdx +++ b/apps/web/content/docs/content-engine/live-editing.mdx @@ -14,16 +14,18 @@ Live editing lets a team work on the same record at the same time without overwr Add `liveEditing: true` next to [`editorial`](/docs/content-engine/editorial). Live editing needs `editorial`, because field locks and the shared draft are measured against the record version it adds. Without it, `defineContentType` throws ``liveEditing needs `editorial` ``, and TypeScript flags the option first: -```ts title="plugins/example/src/content/article.ts" -export const articleContentType = defineContentType({ - id: "example.article", - tableName: "example_articles", - editorial: true, - liveEditing: true, // [!code highlight] - fields: { - title: field.text({ required: true, minLength: 3, maxLength: 200 }), - excerpt: field.textarea({ maxLength: 500, nullable: true }), +```ts title="plugins/newsroom/src/content/story.ts (excerpt)" +export const storyContentType = defineContentType({ + id: "newsroom.story", + tableName: "newsroom_stories", + // ... + editorial: { + revisions: { retention: 20 }, + preview: { expiresInMinutes: 30 }, + scheduling: true, }, + liveEditing: true, // [!code highlight] + // ... }); ``` @@ -51,7 +53,7 @@ Clicking into a plain field, such as a title, slug or category, takes a lock on withoutBackground className="*:max-w-full" src={fieldLock} - alt="Edit Article dialog as Tom Becker sees it while Anna Kowalska edits the title: a teal outline with an Anna Kowalska name tag around the dimmed Title field, and her avatar with a teal ring in the header" + alt="Edit Story dialog as a second editor sees it while Test Admin edits the title: a teal outline with a Test Admin name tag around the dimmed Title field, and the Test Admin avatar with a teal ring in the header" /> A lock lasts 60 seconds and renews every 20 seconds while the editor stays in the field. Leaving the field releases it, and so does closing the record. A lock nobody renews, say because a laptop went to sleep, expires on its own. @@ -62,11 +64,11 @@ About 1.5 seconds after you stop typing in a field you hold, the value is saved Changed your mind? **Discard** next to the autosave time throws the draft away after you confirm, and the form goes back to the last saved version. It is a team draft, so everyone editing the record goes back too, and they get a short note saying so. -There is one draft per record and language, shared by the whole team. When you open the record, you see the draft, not only what was last saved. **Save** commits it through the usual [version check](/docs/content-engine/editorial#safe-concurrent-edits) and creates one revision. Autosave itself never creates revisions, emits no events and leaves search and caches alone. [Preview links](/docs/content-engine/editorial#preview-links) also ignore the draft until someone saves. +There is one draft per record and language, shared by the whole team. When you open the record, you see the draft, not only what was last saved. **Save** commits it through the usual [version check](/docs/content-engine/editorial#safe-concurrent-edits) and creates one revision. Autosave itself never creates revisions, emits no events and leaves search and caches alone. [Preview links](/docs/content-engine/editorial-previews) also ignore the draft until someone saves. ### Rich text co-editing -Rich text fields are not locked and get no outline. Everyone in the same field and language types into one shared document and sees each other's caret with a name tag, plus a tint over the text they select. Undo only undoes your own changes. The example plugin's article has no rich text field, so this is the blog plugin's article editor: +Rich text fields are not locked and get no outline. Everyone in the same field and language types into one shared document and sees each other's caret with a name tag, plus a tint over the text they select. Undo only undoes your own changes. The newsroom plugin's story has no rich text field, so this is the blog plugin's article editor: -The localized columns move to `example_localized_articles_translations`, keyed by `(itemId, languageId)`. That table has its own `version`, `createdAt` and `updatedAt`, plus `status` and `publishedAt` when the type has publication. +The localized columns move to `newsroom_bulletins_translations`, keyed by `(itemId, languageId)`. That table has its own `version`, `createdAt` and `updatedAt`, plus `status` and `publishedAt` when the type has publication. The migration does not copy existing values into the translations table. If @@ -134,8 +132,8 @@ When the server refuses a value anyway, nothing is saved. The save answers `400` With publication, each language has its own status. Publishing the record publishes its languages too, and you can then unpublish one language alone. A translation is public only when both the record and that translation are published, and the record is not [hidden](/docs/content-engine/content-visibility). With `editorial`, each language also keeps its own revisions and preview links. - The example plugin registers `example.localized-article` on the API only, so - it has no AdminCP screen. Add it to the plugin's `adminContent` and `adminNav` + The newsroom plugin registers `newsroom.bulletin` on the API only, so it has + no AdminCP screen. Add it to the plugin's `adminContent` and `adminNav` content types to get the generated form shown above. @@ -148,7 +146,7 @@ The public API serves one language per request, chosen in this order: 3. Otherwise `defaultLocale`. ```bash -curl -i "http://localhost:3000/api/@vitnode/example/content/localized-articles/witaj-content-engine?locale=pl" +curl -i "http://localhost:3000/api/newsroom/content/bulletins/witaj-content-engine?locale=pl" ``` ```http @@ -160,13 +158,13 @@ content-language: pl The `locale` property says which language the body is in. When the requested translation is missing or unpublished, `fallback: "default"` serves the default language instead, so `"locale"` becomes `"en"`. With `fallback: "none"` (the default), the API returns `404`. -In a page loader, pass the route's locale through. This is the example plugin's localized article page: +In a page loader, pass the route's locale through. This is the newsroom plugin's bulletin page: -```ts title="plugins/example/src/pages/localized-article-page.tsx" +```ts title="plugins/newsroom/src/pages/bulletin-page.tsx (excerpt)" const detail = await fetcher({ plugin: manifest.id, method: "get", - module: "content/localized-articles", + module: "content/bulletins", path: "/{slug}", args: { params: { slug: encodeURIComponent(slug) }, @@ -175,16 +173,16 @@ const detail = await fetcher({ }); ``` -Opening `/pl/localized-articles/witaj-content-engine` renders the Polish translation: +Opening `/pl/bulletins/witaj-content-engine` renders the Polish translation: -Each translation can have its own slug and URL; [Public pages](/docs/content-engine/public-pages) covers localized URLs and `hreflang`. +Each translation can have its own slug and URL. [Content page](/docs/content-engine/public-pages) covers localized URLs and `hreflang`. ## Translation routes @@ -199,7 +197,7 @@ Each translation can have its own slug and URL; [Public pages](/docs/content-eng | `POST /{id}/translations/{locale}/preview` | A preview link for one language. Needs editorial preview | | `POST /localized`, `PUT /{id}/localized` | Saves shared fields and several languages in one transaction | -Each translation write emits an event such as `content.example.localized-article.translation_created` or `translation_published`; see [Events](/docs/content-engine/events). +Each translation write emits an event such as `content.newsroom.bulletin.translation_created` or `translation_published`, listed in [Events](/docs/content-engine/events). ## Options diff --git a/apps/web/content/docs/content-engine/meta.json b/apps/web/content/docs/content-engine/meta.json index b1e2ab2a45..83d8250470 100644 --- a/apps/web/content/docs/content-engine/meta.json +++ b/apps/web/content/docs/content-engine/meta.json @@ -4,46 +4,55 @@ "icon": "Boxes", "root": true, "pages": [ - "---Start here---", + "---Getting started---", "index", "create-a-content-type", - "---Publish---", + "---Public content---", "publication", "public-api", - "public-pages", "public-lists", + "public-pages", "public-lists-embed", - "seo", - "search", - "caching", - "---Members write---", + "---Member content---", "public-write", "public-write-frontend", "public-write-edit", + "public-permissions", + "---Interactions---", "reports", - "---Model content---", - "relations", "threads", - "localization", - "rich-text", - "id-strategy", - "---Organize content---", + "threads-editing", + "threads-frontend", + "---Organization---", + "relations", + "relations-advanced", "containers", "container-hierarchy", "container-frontend", - "container-policies", + "---Moderation---", "approvals", + "approval-notices", + "container-policies", + "content-visibility", + "---Content features---", + "rich-text", + "localization", + "search", + "seo", + "id-strategy", "---Editorial---", "editorial", + "editorial-previews", + "editorial-scheduling", "live-editing", - "content-visibility", "duplicate-content", - "---Customize---", + "---Customization---", "admincp", "services", "events", - "---Ship---", + "---Deployment---", "database", + "caching", "production", "---Reference---", "reference", @@ -51,6 +60,8 @@ "plugin-files", "public-lists-api", "public-write-api", + "public-write-form-api", + "public-permissions-api", "container-policies-api", "live-editing-api" ] diff --git a/apps/web/content/docs/content-engine/plugin-files.mdx b/apps/web/content/docs/content-engine/plugin-files.mdx index 1bb11be66c..5bb4d2d607 100644 --- a/apps/web/content/docs/content-engine/plugin-files.mdx +++ b/apps/web/content/docs/content-engine/plugin-files.mdx @@ -25,24 +25,38 @@ VitNode reads a plugin's content types from several small files instead of one b A content type with an AdminCP screen needs an entry in both `admin/nav` and `admin/content`. The first adds the sidebar link, the second the screen behind it. They are separate files so the sidebar stays small on every AdminCP page, while form and editor code loads only with the screen. -Without custom cells, fields or layouts, `admin/content` can re-export the nav list: +Without custom cells, fields or layouts, `admin/content` can re-export the nav list, as the newsroom plugin does: -```tsx title="src/admin/content.tsx" +```tsx title="plugins/newsroom/src/admin/content.tsx" export { adminNav as adminContent } from "./nav"; ``` -Once you customize a screen, build `adminContent` on its own and reuse the nav entries, as the example plugin does: +Once you customize a screen, build `adminContent` on its own and reuse the nav entries, as the blog plugin does: -```tsx title="plugins/example/src/admin/content.tsx" +```tsx title="plugins/blog/src/admin/content.tsx (excerpt)" import type { ContentFrontendPluginSource } from "@vitnode/core/lib/plugin"; +import { contentTypeAdmin } from "@vitnode/core/lib/plugin"; + import { manifest } from "@/manifest.gen"; +import { BlogCategoryColorCell } from "@/views/admin/category/color-cell"; +import { BlogCategoryColorField } from "@/views/admin/category/color-field"; -import { exampleArticleNav, exampleCategoryNav, examplePageNav } from "./nav"; +import { blogCategoryNav } from "./nav"; export const adminContent = { pluginId: manifest.id, - contentTypes: [exampleArticleNav, exampleCategoryNav, examplePageNav], + contentTypes: [ + contentTypeAdmin({ + ...blogCategoryNav, + fields: { + color: { component: BlogCategoryColorField }, + }, + columns: { + color: { cell: BlogCategoryColorCell }, + }, + }), + ], } satisfies ContentFrontendPluginSource; ``` @@ -62,9 +76,9 @@ This happens each time `vite dev` or `vite build` starts, and the dev server reg Resolution only works when the plugin id equals the npm package name and the package exports its `dist` folder. A generated plugin already has the right `exports`: -```json title="plugins/example/package.json" +```json title="plugins/newsroom/package.json" { - "name": "@vitnode/example", + "name": "newsroom", "exports": { "./locales/*.json": "./src/locales/*.json", "./*": { @@ -76,24 +90,24 @@ Resolution only works when the plugin id equals the npm package name and the pac } ``` -Tables are the exception. Drizzle Kit reads `node_modules//dist/src/database/*.js` directly for every plugin in the API config. Export each table from those files, or it never reaches a migration. [Database](/docs/content-engine/database#export-every-table) shows a model with several tables. +Tables are the exception. Drizzle Kit reads `node_modules//dist/src/database/*.js` directly for every plugin in the API config. Export each table from those files, or it never reaches a migration. [Migrations](/docs/content-engine/database#export-every-table) shows a model with several tables. ## Keep `config.tsx` small `config.tsx` is bundled with the document shell of every public page: -```tsx title="plugins/example/src/config.tsx" +```tsx title="plugins/newsroom/src/config.tsx" import { buildPlugin } from "@vitnode/core/lib/plugin"; import messages from "./locales"; import { manifest } from "./manifest.gen"; import { routes } from "./routes"; -export const examplePlugin = () => +export const newsroomPlugin = () => buildPlugin({ manifest, localeFiles: { - en: "@vitnode/example/locales/en.json", + en: "newsroom/locales/en.json", }, messages, routes, @@ -106,25 +120,35 @@ Do not import `admin/nav` or `admin/content` here. The app loads `admin/nav` wit ## `content.ts` and public URLs -A content type with `delivery` or `search` publishes URLs. List every such content type in `src/content.ts`. The example plugin lists all of its types, which is allowed: +A content type with `delivery` or `search` publishes URLs. List every such content type in `src/content.ts`. The newsroom plugin lists all of its types, which is allowed: -```ts title="plugins/example/src/content.ts" -import { advancedArticleContentType } from "@/content/advanced-article"; +```ts title="plugins/newsroom/src/content.ts" import { articleContentType } from "@/content/article"; +import { bulletinContentType } from "@/content/bulletin"; import { categoryContentType } from "@/content/category"; -import { localizedArticleContentType } from "@/content/localized-article"; +import { commentContentType } from "@/content/comment"; +import { eventContentType } from "@/content/event"; +import { explainerContentType } from "@/content/explainer"; +import { letterContentType } from "@/content/letter"; import { pageContentType } from "@/content/page"; +import { storyContentType } from "@/content/story"; +import { tagContentType } from "@/content/tag"; export const contentTypes = [ articleContentType, - advancedArticleContentType, - localizedArticleContentType, categoryContentType, + commentContentType, + storyContentType, + explainerContentType, + bulletinContentType, + tagContentType, + eventContentType, pageContentType, + letterContentType, ]; ``` -Keep the module browser-safe: it imports definitions only, never `src/database`. The web build checks that one of your pages serves each URL, and the API refuses to start when a plugin publishes URLs that this module does not declare. See [Content URLs need a page](/docs/dev/i18n/localized-urls#content-urls-need-a-page). +Keep the module browser-safe. It imports definitions only, never `src/database`. The web build checks that one of your pages serves each URL, and the API refuses to start when a plugin publishes URLs that this module does not declare. See [Content URLs need a page](/docs/dev/i18n/localized-urls-advanced#content-urls-need-a-page). ## Errors diff --git a/apps/web/content/docs/content-engine/production-search-health.webp b/apps/web/content/docs/content-engine/production-search-health.webp index ee9f71fc4b..4746fdefcc 100644 Binary files a/apps/web/content/docs/content-engine/production-search-health.webp and b/apps/web/content/docs/content-engine/production-search-health.webp differ diff --git a/apps/web/content/docs/content-engine/production.mdx b/apps/web/content/docs/content-engine/production.mdx index 245751f7f8..cdf51d0d6b 100644 --- a/apps/web/content/docs/content-engine/production.mdx +++ b/apps/web/content/docs/content-engine/production.mdx @@ -15,11 +15,11 @@ A content type that works on your machine needs a few more checks before real ed - [ ] **Keep `publication` on.** `publicApi` and `search` both refuse to load without it, and new records start as drafts, so nothing is public until someone publishes it. - [ ] **Treat search as public.** `search.titleField`, `descriptionField` and `contentFields` must be public fields, and `search.authorField` shows who wrote a record in search results. -The example article lists eight of its own fields plus `publishedAt`. `code`, `views` and `author` stay private, because they are not in the list: +The newsroom story lists eight of its own fields plus `publishedAt`. `code`, `views` and `author` stay private, because they are not in the list: -```ts title="plugins/example/src/content/article.ts" +```ts title="plugins/newsroom/src/content/story.ts (excerpt)" publicApi: { - path: "articles", + path: "stories", fields: [ "title", "slug", @@ -31,6 +31,7 @@ publicApi: { "noIndex", "publishedAt", ], + // ... }, ``` @@ -43,7 +44,7 @@ publicApi: { - [ ] Commit the migration that `pnpm db:migrate` generated together with the content type. - [ ] Run `db:migrate` as a deploy step. Production never migrates on startup. -- [ ] Read the generated SQL when you change an existing content type. See [Database](/docs/content-engine/database). +- [ ] Read the generated SQL when you change an existing content type. See [Migrations](/docs/content-engine/database). ## Configure what editorial features rely on @@ -62,7 +63,7 @@ publicApi: { withoutBackground className="*:max-w-full" src={searchHealth} - alt="The AdminCP Search screen with status, engine, indexed items and last indexed cards above a table of indexed collections with coverage bars" + alt="The Search section of the AdminCP Debug Panel with status, engine, indexed items and last indexed cards above a table of indexed collections with coverage bars" /> For a monitoring probe, core has a staff-only route that reports every content type, its search drift per language and failed scheduled effects. It needs the `can_view` permission of the `system` module: @@ -79,8 +80,8 @@ curl -b "vitnode_auth_admin=" \ "effectsHealthy": true, "contentTypes": [ { - "contentTypeId": "example.article", - "pluginId": "@vitnode/example", + "contentTypeId": "newsroom.story", + "pluginId": "newsroom", "features": { "editorial": true, "localization": false, @@ -106,7 +107,7 @@ curl -b "vitnode_auth_admin=" \ } ``` -This excerpt shows two indexed documents for one published article, so search is not healthy until the collection is reindexed. `failedEffects` counts scheduled publishes that committed but whose event or search update failed. In your own code, `contentEngineDiagnostics(c)` from `@vitnode/core/content/server` returns the same object. +This excerpt shows two indexed documents for one published story, so search is not healthy until you reindex the collection. `failedEffects` counts scheduled publishes that committed but whose event or search update failed. In your own code, `contentEngineDiagnostics(c)` from `@vitnode/core/content/server` returns the same object. ## Know the limits diff --git a/apps/web/content/docs/content-engine/public-api-publish-confirm.webp b/apps/web/content/docs/content-engine/public-api-publish-confirm.webp index 6bc1be4c84..0262627a42 100644 Binary files a/apps/web/content/docs/content-engine/public-api-publish-confirm.webp and b/apps/web/content/docs/content-engine/public-api-publish-confirm.webp differ diff --git a/apps/web/content/docs/content-engine/public-api-publish-row.webp b/apps/web/content/docs/content-engine/public-api-publish-row.webp index af9c9ea6f5..de948d8ad3 100644 Binary files a/apps/web/content/docs/content-engine/public-api-publish-row.webp and b/apps/web/content/docs/content-engine/public-api-publish-row.webp differ diff --git a/apps/web/content/docs/content-engine/public-api.mdx b/apps/web/content/docs/content-engine/public-api.mdx index 812cc1562b..39d12c14bf 100644 --- a/apps/web/content/docs/content-engine/public-api.mdx +++ b/apps/web/content/docs/content-engine/public-api.mdx @@ -7,25 +7,21 @@ implementation: decisions: - Which items become public? Offer content types from the project's plugins that have `publication` but no `publicApi`. - Which fields can readers see? Offer the type's fields; user fields are never public, and the type needs exactly one slug field. - - What can readers search, sort and filter by? Offer its text fields for search, and its other fields for sorting and filtering. constraints: - Set `publicApi` on the content type and register the model in the plugin's `buildContentPublicModule`. - Read the routes with `fetcher` and `module` set to `content/{publicApi.path}`. related: - - title: Public lists + - title: Content list href: /docs/content-engine/public-lists - - title: Public pages + - title: Content page href: /docs/content-engine/public-pages - title: publicApi reference href: /docs/content-engine/reference#publicapi --- -import { ImgDocs } from "@/components/fumadocs/img"; -import listPage from "./public-lists-discussions.webp"; +`publicApi` gives a content type read-only routes anyone can call: a list and a single record. They return only published records, and only the fields you name. Your pages read these routes; nothing about a content type is public until you add them. -`publicApi` gives a content type read-only routes that anyone can call: one for a list and one for a single record. Only published records come back, and only the fields you name. Your pages read these routes; nothing is public until you add them. - -You need [publication](/docs/content-engine/publication) first, so drafts can never leak. +This continues the article from [Drafts & publishing](/docs/content-engine/publication): one published article, `Welcome to the newsroom`, and one draft, `Release notes for October`. @@ -33,130 +29,124 @@ You need [publication](/docs/content-engine/publication) first, so drafts can ne ### Choose the public fields -Add `publicApi` with a URL `path` and the `fields` readers may see: +Edit `src/content/article.ts` and add `publicApi` after `publication`: -```ts title="plugins/example/src/content/discussion.ts" -export const discussionContentType = defineContentType({ - id: "example.discussion", - // ... +```ts title="plugins/newsroom/src/content/article.ts" publication: true, + // [!code ++:4] publicApi: { - path: "discussions", - fields: ["id", "title", "slug", "body", "board", "publishedAt"], + path: "news", + fields: ["id", "title", "slug", "body", "publishedAt"], }, -}); ``` -- `fields` has no wildcard. A field you add to the content type later stays private until you list it. -- Exactly one slug field must be in the list, because the detail route finds records by it. -- User fields never appear here. To show an author, add `people: ["author"]`, which returns a small public profile instead. - -Search, sorting, filters and lighter list rows are a few more options, covered in [Public lists](/docs/content-engine/public-lists#choose-what-the-list-can-read). +- `path` becomes part of the URL, `/api/newsroom/content/news`, and the module name your pages use, `content/news`. Pick one no other plugin in the app uses, because [content pages](/docs/content-engine/public-pages) turn it into a site-wide URL. +- `fields` has no wildcard. A field you add later stays private until you list it here. +- Exactly one slug field must be listed, because the single-record route finds records by it. -### Register the routes +### Register the public routes -Add the model to `buildContentPublicModule` in the plugin's API config: +Edit `src/config.api.ts` and add the model to `buildContentPublicModule`. This is an excerpt: -```ts title="plugins/example/src/config.api.ts" -import { discussionContent } from "@/database/discussions"; +```ts title="plugins/newsroom/src/config.api.ts" +import { buildContentPublicModule } from "@/api/builders"; // [!code ++] +import { articleContent } from "@/database/articles"; // [!code ++] +import { manifest } from "@/manifest.gen"; + +import { adminModule } from "./api/modules/admin/admin.module"; +import { helloModule } from "./api/modules/hello/hello.module"; -export const exampleApiPlugin = () => +export const newsroomApiPlugin = () => buildApiPlugin({ manifest, modules: [ - // [!code ++:3] - buildContentPublicModule({ - contentTypes: [discussionContent], - }), + helloModule, + adminModule, + buildContentPublicModule({ contentTypes: [articleContent] }), // [!code ++] ], }); ``` -`pnpm dev` serves the new routes as soon as you save. `publicApi` adds no columns, so there is nothing to migrate. - - - - - -### Read it from a page - -Call the routes with the [fetcher](/docs/dev/fetcher). The module is `content/` plus `path`, and the route, its query and the response are all typed: - -```ts title="plugins/example/src/pages/discussion-page.tsx" -import { fetcher } from "@vitnode/core/tanstack/fetcher"; - -import { manifest } from "@/manifest.gen"; - -const res = await fetcher({ - plugin: manifest.id, - method: "get", - module: "content/discussions", - path: "/{slug}", - args: { params: { slug: encodeURIComponent(slug) } }, -}); - -if (res.status === 200) { - const discussion = await res.json(); // { id, title, slug, body, board, publishedAt } -} -``` - -A field you did not list, or a module that does not exist, is a compile error. +`publicApi` adds no columns, so there is nothing to migrate. `pnpm dev` serves the routes as soon as it rebuilds. -## The routes - -All routes live under `/api/{pluginId}/content/{publicApi.path}`. - -| Route | Returns | -| ---------------------------- | --------------------------------------------------------------------------------------------- | -| `GET /` | Published records, a page at a time: `{ edges, pageInfo }` | -| `GET /{slug}` | One published record, or `404` | -| `GET /permissions?ids=1,2` | What the visitor may do with each record: `canEdit`, `canReport`. With `publicWrite` or `reports` | -| `/delivery/...` | Page URLs, SEO data and sitemap entries. With [`delivery`](/docs/content-engine/public-pages) | -| `GET /thread?root={id}` | Nested replies under a record. With [`thread`](/docs/content-engine/threads) | -| `POST /`, `PATCH /{id}` | Create and edit from the site. With [`publicWrite`](/docs/content-engine/public-write) | - -Each record comes back with exactly the listed fields. Dates are ISO strings, a relation is `{ id }`, a container field is the container's id, and a file is `{ id, name, url, mimeType, size, width, height }`. A draft, a record scheduled for later, a hidden record and a typo all answer the same `404`. A record that belongs to a parent - its thread root, or its search context relation - is only as public as that parent: once the topic is hidden or unpublished, its comments drop out of the list and their detail answers `404` too. The list adds the hidden records a signed-in author, editor or moderator may see, marked `hidden: true`. - -The list accepts `first` (up to 50), `page` or `cursor`, `search`, `orderBy`, `order` and one parameter per filterable field. [Public lists](/docs/content-engine/public-lists) turns it into a page with search, filters and pagination. - ## Check the result Call the list route while `pnpm dev` runs: ```bash -curl "http://localhost:3000/api/@vitnode/example/content/discussions?first=1" +curl "http://localhost:3000/api/newsroom/content/news" ``` -```json title="Response (trimmed)" +```json title="200" { "edges": [ { - "id": 13, - "title": "Show the author on list rows", - "slug": "show-the-author-on-list-rows", - "board": 3, - "publishedAt": "2026-10-20T16:04:32.879Z", - "author": { "id": 2, "name": "Ada Lovelace", "nameCode": "Ada-Lovelace" } + "id": 1, + "title": "Welcome to the newsroom", + "slug": "welcome-to-the-newsroom", + "body": { + "type": "doc", + "content": [ + { + "type": "paragraph", + "attrs": { "textAlign": null }, + "content": [{ "type": "text", "text": "This is our first article. Staff write and edit it in the AdminCP." }] + } + ] + }, + "publishedAt": "2026-10-23T18:57:51.156Z" } ], - "pageInfo": { "totalCount": 12, "totalPages": 12, "hasNextPage": true } + "pageInfo": { + "totalCount": 1, + "totalPages": 1, + "currentPage": null, + "pageSize": 25, + "count": 1, + "hasNextPage": false, + "hasPreviousPage": false, + "endCursor": "eyJjb2x1bW4iOiJwdWJsaXNoZWRBdCIs...", + "startCursor": "eyJjb2x1bW4iOiJwdWJsaXNoZWRBdCIs..." + } } ``` -Pages turn these responses into what readers see. The example plugin's `/discussions` page, from [Public lists](/docs/content-engine/public-lists), shows the same records: +Only the published article is in `edges`, with exactly the five listed fields. Now ask for each article by slug: + +```bash +curl -i "http://localhost:3000/api/newsroom/content/news/welcome-to-the-newsroom" +curl -i "http://localhost:3000/api/newsroom/content/news/release-notes-for-october" +``` + +The first answers `200` with the same record. The draft answers `404` with `Article not found.`, the same answer as a slug that never existed, so the API never reveals that a draft exists. + +## Read it from a page + +Pages call these routes with the [fetcher](/docs/dev/fetcher). The module is `content/` plus `path`; the route, its parameters and its response are typed from your definition: + +```ts +import { fetcher } from "@vitnode/core/tanstack/fetcher"; + +import { manifest } from "@/manifest.gen"; + +const res = await fetcher({ + plugin: manifest.id, + method: "get", + module: "content/news", + path: "/{slug}", + args: { params: { slug: encodeURIComponent(slug) } }, +}); +``` + +A field you did not list, or a module that does not exist, is a compile error. The list route also takes `first` (up to 50), `page` or `cursor`, `search`, `orderBy` and `order`; [List API](/docs/content-engine/public-lists-api) has every parameter, and [Search](/docs/content-engine/public-lists#add-search) shows which options turn them on. - +Next, show the articles on the site with a [Content list](/docs/content-engine/public-lists). diff --git a/apps/web/content/docs/content-engine/public-lists-api.mdx b/apps/web/content/docs/content-engine/public-lists-api.mdx index c352f1aba1..739f6e4272 100644 --- a/apps/web/content/docs/content-engine/public-lists-api.mdx +++ b/apps/web/content/docs/content-engine/public-lists-api.mdx @@ -1,10 +1,10 @@ --- -title: Public list API +title: List API description: Reference for the VitNode Content Engine public list helpers in @vitnode/core/tanstack/content - defineContentList, contentListQueryOptions, contentListPageHead, ContentListResults, ContentListPagination and useContentListSearchInput. icon: FileJson --- -Every helper on this page is exported from `@vitnode/core/tanstack/content` and is universal unless noted. For a guided setup, follow [Public lists](/docs/content-engine/public-lists). Examples use the blog plugin's `/blog` list. +Every helper on this page is exported from `@vitnode/core/tanstack/content` and is universal unless noted. For a guided setup, follow [Content list](/docs/content-engine/public-lists). Examples use the blog plugin's `/blog` list. ## `defineContentList` diff --git a/apps/web/content/docs/content-engine/public-lists-discussions.webp b/apps/web/content/docs/content-engine/public-lists-discussions.webp deleted file mode 100644 index 792887512d..0000000000 Binary files a/apps/web/content/docs/content-engine/public-lists-discussions.webp and /dev/null differ diff --git a/apps/web/content/docs/content-engine/public-lists-embed-latest.webp b/apps/web/content/docs/content-engine/public-lists-embed-latest.webp index b318135952..4b255084c9 100644 Binary files a/apps/web/content/docs/content-engine/public-lists-embed-latest.webp and b/apps/web/content/docs/content-engine/public-lists-embed-latest.webp differ diff --git a/apps/web/content/docs/content-engine/public-lists-embed.mdx b/apps/web/content/docs/content-engine/public-lists-embed.mdx index fdf727bf5b..799192ed49 100644 --- a/apps/web/content/docs/content-engine/public-lists-embed.mdx +++ b/apps/web/content/docs/content-engine/public-lists-embed.mdx @@ -1,127 +1,246 @@ --- -title: Add a list to another page -description: Show a VitNode Content Engine public list inside any page or component, with your own item layout, using contentListQueryOptions and ContentListResults. +title: Embedded list +description: Show a few VitNode Content Engine records inside another page, such as a Latest articles box under each article, by reusing the list's query options with useQuery and ContentListResults, server-rendered. icon: PanelsTopLeft +implementation: + objective: Show the newest records of a content list inside another page, rendered on the server. + decisions: + - Which list feeds the box? Offer list query options built with `contentListQueryOptions`; if none exist, point to the Content list guide. + - Which page shows it, and how many items? Recommend 3 to 5 from the first page of the list. + constraints: + - Reuse the list's query options with `useQuery` and render them with `ContentListResults`; do not write a second fetch. + - Warm the same options in the host route's loader with `queryClient.query`, without `LOADER_DATA_QUERY_META`, and catch its failure. + related: + - title: Content list + href: /docs/content-engine/public-lists + - title: List API + href: /docs/content-engine/public-lists-api --- +import { Tab, Tabs } from "fumadocs-ui/components/tabs"; + import { ImgDocs } from "@/components/fumadocs/img"; import latest from "./public-lists-embed-latest.webp"; -A list does not need its own route. The same query options that feed `/discussions` can fill a "Latest discussions" box on a home page, a sidebar or another page, rendered however that page needs. This guide adds the five newest discussions to the example plugin's `/boards` page, reusing the query from [Public lists](/docs/content-engine/public-lists). - -## Before you begin +A list does not need its own route. The query options behind `/news` can also fill a small box on another page. This guide adds a **Latest articles** box under every article, with the five newest other articles, in the server-rendered HTML. -You need list query options built with `contentListQueryOptions`, such as `discussionsQueryOptions` in `plugins/example/src/pages/discussion-list.ts`. +This continues [Content page](/docs/content-engine/public-pages). It reuses `articlesQueryOptions` from `src/pages/article-list.ts`, written in [Content list](/docs/content-engine/public-lists), and changes `src/pages/article-page.tsx`. -### Render the list with your own items +### Add TanStack Query to the plugin + +The box reads the list with `useQuery`, so the plugin needs `@tanstack/react-query` as a dependency. Run this from the workspace root: + + + +```bash tab="bun" +bun add @tanstack/react-query --cwd plugins/newsroom +``` + +```bash tab="pnpm" +pnpm --filter newsroom add @tanstack/react-query +``` + +```bash tab="npm" +npm install @tanstack/react-query --workspace newsroom +``` + + -Call the query options with `useQuery` and hand the rows to `ContentListResults`. `renderItem` receives each row exactly as the query returned it: + -```tsx title="plugins/example/src/pages/latest-discussions.tsx" + + +### Write the box + +Create `src/pages/latest-articles.tsx`. This is the complete file: + +```tsx title="plugins/newsroom/src/pages/latest-articles.tsx" import { useQuery } from "@tanstack/react-query"; +import { Skeleton } from "@vitnode/core/components/ui/skeleton"; import { ContentListResults } from "@vitnode/core/tanstack/content"; import { HrefLink } from "@vitnode/core/tanstack/link"; import { useLocale, useTranslations } from "use-intl"; -import { discussionsQueryOptions } from "./discussion-list"; -import { discussionHref } from "./discussion-search"; +import { articlesQueryOptions } from "./article-list"; + +/** How many articles the box shows, out of the first page of `/news`. */ +const LATEST_COUNT = 5; -export const LatestDiscussions = () => { - const t = useTranslations("@vitnode/example.boards.latest"); +/** The newest published articles, without the one being read. */ +export const LatestArticles = ({ currentId }: { currentId?: number }) => { + const t = useTranslations("newsroom.articles.latest"); const locale = useLocale(); const { data, isError, isFetching } = useQuery( - discussionsQueryOptions({ locale, search: {} }), + articlesQueryOptions({ locale, search: {} }), ); + const items = data?.edges + .filter(article => article.id !== currentId) + .slice(0, LATEST_COUNT); return ( -
-

- {t("title")} -

+
+
+

+ {t("title")} +

+ + {t("all")} + +
+ {t("empty")}

} error={

{t("error")}

} - getKey={discussion => discussion.id} + getKey={article => article.id} isError={isError} isFetching={isFetching} isFiltered={false} itemClassName="px-4 py-3" - items={data?.edges.slice(0, 5)} + items={items} label={t("title")} - loading={

{t("loading")}

} - noResults={null} - renderItem={discussion => - discussion.slug === null ? ( - {discussion.title} - ) : ( - - {discussion.title} - - ) + loading={ +
+ + {t("loading")} + + {[0, 1, 2].map(key => ( + + ))} +
} + noResults={null} + renderItem={article => ( + + {article.title} + + )} />
); }; ``` -An empty `search` is the plain list, newest first. The box never filters, so `isFiltered` is `false` and `noResults` is never shown. The labels live under `@vitnode/example.boards.latest`, a namespace the `/boards` route already loads. +- **`articlesQueryOptions({ locale, search: {} })`** is the plain list, the first page of `/news` with the ten newest articles. The box shares that request and its cache entry with the list page. +- **Five of ten.** The box drops the article being read and keeps five, so it stays full while the list has at least six articles. +- **`ContentListResults`** picks the state: `loading` until the first answer, `error` when it failed, `empty` when nothing else is published. `isFiltered` is `false` because the box never filters, so `noResults` never shows. The skeleton is hidden from screen readers, which hear the `role="status"` label instead. -### Render it on the server too +### Render it under the article -`useQuery` does not fetch during SSR, so on its own the box starts as `loading`. To send it with the HTML, warm the same options in the host route's loader. Leave out `LOADER_DATA_QUERY_META`, so the query is dehydrated and `useQuery` finds it in the browser: +Edit `src/pages/article-page.tsx`. Import the box and render it after the `
`, in a wrapper that keeps the page's width. This is an excerpt: -```tsx title="plugins/example/src/pages/boards-page.tsx" -const loadBoards = async ( - queryClient: RouteLoadContext["queryClient"], - locale: string, -) => { - const [boards] = await Promise.all([ - queryClient.query(boardsQueryOptions()), - // [!code ++:3] - queryClient - .query(discussionsQueryOptions({ locale, search: {} })) - .catch(() => null), - ]); +```tsx title="plugins/newsroom/src/pages/article-page.tsx" +import { LatestArticles } from "./latest-articles"; // [!code ++] - return { tree: contentContainerTree(boards) }; -}; +const ArticlePage = ({ + loaderData: { item }, +}: PluginRoutePageProps) => ( +
+
+ {/* the header and RichTextContent, as before */} +
+ + {/* [!code ++] */} +
+); +``` -export const route = defineRoute({ - load: async ({ context }) => - await loadBoards(context.queryClient, context.locale), // [!code highlight] +The container classes move from `
` to the wrapper. The box sits outside `
` because it is not part of the article. + + + + + +### Warm the query on the server + +`useQuery` does not fetch during server rendering, so on its own the box arrives as a skeleton and loads in the browser. To send it with the HTML, warm the same options in the page's loader. That needs the query client, which `defineRoute` from `@vitnode/core/tanstack/plugin-routes` passes to `load`. Replace the route in `src/pages/article-page.tsx`; this is an excerpt: + +```tsx title="plugins/newsroom/src/pages/article-page.tsx" +import type { PluginRoutePageProps } from "@vitnode/core/routing"; // [!code highlight] +import { defineRoute } from "@vitnode/core/tanstack/plugin-routes"; // [!code ++] + +import { articlesQueryOptions } from "./article-list"; // [!code ++] + +export const route = defineRoute({ + load: async ({ context, params }) => { + const page = await loadArticle(params.slug); + // A failure leaves the box to retry in the browser instead of + // breaking the article. + await context.queryClient + .query(articlesQueryOptions({ locale: context.locale, search: {} })) + .catch(() => null); + + return page; + }, + + head: ({ loaderData }) => + contentDeliveryPageHead(loaderData?.metadata, { + title: loaderData?.item.title, + }), }); ``` -Then render `` anywhere in the page. Both calls build the key `["@vitnode/example", "discussions", { locale }]`, the same one the first page of `/discussions` uses, so they share one cache entry. `invalidateDiscussionLists` refreshes the box together with the list after a member posts. +`definePluginRoute` is no longer imported from `@vitnode/core/routing`; only the `PluginRoutePageProps` type is. The `/news` loader passes `LOADER_DATA_QUERY_META`, which keeps its query out of the HTML because the page reads it from loader data. This loader leaves it out on purpose. The server sends the query with the page, so `useQuery` finds it in the browser without a request. Both build the same query key, so moving from `/news` to an article reuses the cached list too. - + -## Use a different list shape +### Add the labels + +Add a `latest` group under `newsroom.articles` in `src/locales/en.json`. The article page already loads the `newsroom.articles` messages: + +```json title="plugins/newsroom/src/locales/en.json" +{ + "newsroom": { + "articles": { + "latest": { + "title": "Latest articles", + "all": "All news", + "empty": "No other articles yet.", + "error": "The latest articles could not be loaded.", + "loading": "Loading the latest articles" + } + } + } +} +``` -`/discussions` shows ten rows a page, newest first. For a box that needs a different page size, sort or filter set, declare a second list with [`defineContentList`](/docs/content-engine/public-lists-api#definecontentlist) and its own query options. Keep the same `queryKey` prefix, `[manifest.id, "discussions"]`, so one invalidation still covers both. + + + ## Check the result -Open `http://localhost:3000/boards` and view the page source. The discussion titles are in the HTML. Start a discussion from **Start a discussion**, then come back to `/boards`, and it sits at the top of the box. +Open `http://localhost:3000/news/welcome-to-the-newsroom`. Below the article, **Latest articles** lists the five newest other articles, with **All news** linking to `/news`: + +- **View the page source.** The five titles are in the HTML, so the box needs no request after the page loads. +- **Open another article.** The box leaves that article out and shows the next one instead. +- **Publish an article in the AdminCP** and reload an article page. The new article tops the box. Pages that write articles, such as the [Create form](/docs/content-engine/public-write-frontend), invalidate `[manifest.id, "articles"]`, which refreshes the box together with `/news`. + +## Use a different list shape + +Reusing the first page of `/news` keeps one request and one cache entry, but it also means the box follows that list's sort and size. For a box that needs its own page size, sort or filter, such as the three newest articles in one category, declare a second list with [`defineContentList`](/docs/content-engine/public-lists-api#definecontentlist) and its own query options. Keep the `queryKey` prefix `[manifest.id, "articles"]`, so one invalidation still refreshes both. + +Next, let members write articles too with [Member writing](/docs/content-engine/public-write). To let readers flag an article first, [Reports](/docs/content-engine/reports) works from here as well. diff --git a/apps/web/content/docs/content-engine/public-lists-news-search.webp b/apps/web/content/docs/content-engine/public-lists-news-search.webp new file mode 100644 index 0000000000..c80a4719d7 Binary files /dev/null and b/apps/web/content/docs/content-engine/public-lists-news-search.webp differ diff --git a/apps/web/content/docs/content-engine/public-lists-news.webp b/apps/web/content/docs/content-engine/public-lists-news.webp new file mode 100644 index 0000000000..832a944502 Binary files /dev/null and b/apps/web/content/docs/content-engine/public-lists-news.webp differ diff --git a/apps/web/content/docs/content-engine/public-lists-no-results.webp b/apps/web/content/docs/content-engine/public-lists-no-results.webp deleted file mode 100644 index e48784c48e..0000000000 Binary files a/apps/web/content/docs/content-engine/public-lists-no-results.webp and /dev/null differ diff --git a/apps/web/content/docs/content-engine/public-lists.mdx b/apps/web/content/docs/content-engine/public-lists.mdx index 13e6333c74..862c7e5eea 100644 --- a/apps/web/content/docs/content-engine/public-lists.mdx +++ b/apps/web/content/docs/content-engine/public-lists.mdx @@ -1,90 +1,47 @@ --- -title: Public lists -description: Build a public VitNode Content Engine list page with search, filters, sorting and pagination kept in the URL, using listFields, defineContentList and the ContentList components. +title: Content list +description: Build a public VitNode Content Engine list page with pagination using defineContentList, contentListQueryOptions, ContentListResults and ContentListPagination, then add search and sorting. icon: LayoutGrid implementation: - objective: Add a public list page with search, filters, sorting and pagination for an existing content type. + objective: Add a public list page with pagination, and optionally search and sorting, for an existing content type. decisions: - Which items does the page list? Offer content types with a public API from the plugins in the project. - Where does the list live? Recommend a URL next to the items' own pages, such as the plural of the type's name. - - What can readers search and sort by? Offer the type's text, textarea and slug fields for search, and its orderable fields plus the publish date for sorting. - - Which filters does the page offer? Offer the type's container, relation and enum fields that can be filtered. - How many items fit on a page? From 1 to 50; recommend 10 to 12. + - Should readers search or sort? Offer the type's text fields for search and its orderable fields plus the publish date for sorting. constraints: - Declare the list with `defineContentList` and fetch it with `contentListQueryOptions` and an inline `fetcher` call, as the guide shows. - - Add `searchableFields`, `orderableFields`, `filterableFields` and `listFields` to `publicApi` instead of filtering in the browser. - Render the list with `ContentListResults` and `ContentListPagination`, and build the head with `contentListPageHead`. - - Keep every label in the plugin's locale files. + - Add `searchableFields` and `orderableFields` to `publicApi` instead of filtering in the browser. related: - - title: Public list API + - title: List API href: /docs/content-engine/public-lists-api - - title: Add a list to another page + - title: Embedded list href: /docs/content-engine/public-lists-embed - - title: Public pages + - title: Content page href: /docs/content-engine/public-pages --- import { ImgDocs } from "@/components/fumadocs/img"; -import listPage from "./public-lists-discussions.webp"; -import noResults from "./public-lists-no-results.webp"; +import listPage from "./public-lists-news.webp"; +import listSearch from "./public-lists-news-search.webp"; -A public list is a page such as `/discussions` that shows published records a page at a time, with search, filters and sorting in its URL. The Content Engine provides the data and the list behavior; your plugin defines the route, the item layout and the words. This guide builds the example plugin's discussion list: search by title, a board filter, three sorts, ten discussions a page, and links to each discussion. +A content list is a page such as `/news` that shows published records a page at a time. The Content Engine provides the data, the paging and the list states; your plugin owns the route, the row layout and the words. This tutorial builds `/news` with article titles, links and page numbers, then adds search and sorting. -| The Content Engine provides | Your plugin defines | -| ------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------- | -| The list route `GET /content/{publicApi.path}` with paging, search, filters, locale fallback, caching and cache tags | The `page()` route and where it lives | -| Only published, already-live records. Drafts and scheduled records never appear, and hidden ones only for their authors and editors | Which sorts and filters the URL accepts | -| `defineContentList`, `contentListQueryOptions`, `contentListPageHead` | The `fetcher` call and the item layout | -| `ContentListResults`, `ContentListPagination`, `useContentListSearchInput` | The search field, the selects and every label | - -The Content Engine never creates list pages on its own, and it ships no sort or filter controls. You add a list where it belongs and draw the controls you want. - -## Before you begin - -You need a content type with a [public API](/docs/content-engine/public-api) and pages for its records, such as [public pages](/docs/content-engine/public-pages). This guide uses `example.discussion` from the example plugin, which members write through [public write](/docs/content-engine/public-write). +This continues the article from [Public API](/docs/content-engine/public-api), whose public routes live at `/api/newsroom/content/news`. Publish a few more articles in the AdminCP first, so there is more than one page; this tutorial shows 10 a page. -### Choose what the list can read - -Tell the public API which fields a list row carries and what readers may search, sort and filter by: - -```ts title="plugins/example/src/content/discussion.ts" -publicApi: { - path: "discussions", - fields: ["id", "title", "slug", "body", "board", "publishedAt"], - // [!code ++:5] - listFields: ["id", "title", "slug", "board", "publishedAt"], - filterableFields: ["board"], - searchableFields: ["title"], - orderableFields: ["title"], - people: ["author"], -}, -``` - -- `listFields` must be a subset of `fields`. The list route selects only these, so a page of ten discussions never loads ten rich text bodies. The detail route still returns every field in `fields`. -- `searchableFields` takes text, textarea and slug fields. `orderableFields` adds sorts; `publishedAt` is always orderable. -- `filterableFields` adds one query parameter per field. A [container field](/docs/content-engine/containers) such as `board` also accepts `includeDescendants=true`. -- `people: ["author"]` adds a small public profile of each discussion's author to every row. A user field never appears through `fields`. - -These options change the API, not the table, so there is nothing to migrate. Every option is in the [`publicApi` reference](/docs/content-engine/reference#publicapi). - - - - - -### Declare the list and its query +### Declare the list -Create `src/pages/discussion-list.ts`. `defineContentList` from `@vitnode/core/tanstack/content` names the sorts, the URL filters and the page size once. `contentListQueryOptions` turns one URL state into TanStack Query options. Write the `fetcher` call inline in `fetch`, so the route stays typed: +Create `src/pages/article-list.ts`. `defineContentList` names the sorts and the page size once. `contentListQueryOptions` turns one URL state into [TanStack Query](/docs/dev/cache) options; write the `fetcher` call inline in `fetch`, so the route and its response stay typed: -```ts title="plugins/example/src/pages/discussion-list.ts" -import type { QueryClient } from "@tanstack/react-query"; +```ts title="plugins/newsroom/src/pages/article-list.ts" import type { ContentListSearch } from "@vitnode/core/tanstack/content"; import { - contentListIdFilter, contentListQueryOptions, defineContentList, } from "@vitnode/core/tanstack/content"; @@ -92,73 +49,46 @@ import { fetcher } from "@vitnode/core/tanstack/fetcher"; import { manifest } from "@/manifest.gen"; -export const DISCUSSIONS_HREF = "/discussions"; - -export const discussionList = defineContentList({ +/** `/news`: published articles, newest first, 10 a page. */ +export const articleList = defineContentList({ defaultSort: "newest", - filters: { board: contentListIdFilter }, pageSize: 10, sorts: { newest: { order: "desc", orderBy: "publishedAt" }, - oldest: { order: "asc", orderBy: "publishedAt" }, - title: { order: "asc", orderBy: "title" }, }, }); -export type DiscussionListSearch = ContentListSearch< - (typeof discussionList.sorts)[number], - { board: typeof contentListIdFilter } +export type ArticleListSearch = ContentListSearch< + (typeof articleList.sorts)[number] >; -export const discussionListSearch = ( - input: Record, -): DiscussionListSearch => discussionList.parseSearch(input); - -const discussionQueryKeys = { - all: [manifest.id, "discussions"] as const, -}; - -export const discussionsQueryOptions = (request: { +export const articlesQueryOptions = (request: { locale: string; - search: DiscussionListSearch; + search: ArticleListSearch; }) => contentListQueryOptions({ ...request, - queryKey: discussionQueryKeys.all, + queryKey: [manifest.id, "articles"], fetch: async ({ search }) => { const res = await fetcher({ plugin: manifest.id, method: "get", - module: "content/discussions", + module: "content/news", path: "/", - args: { - query: { - ...discussionList.apiQuery(search), - ...(search.board === undefined - ? {} - : { board: String(search.board), includeDescendants: "true" }), - }, - }, + args: { query: articleList.apiQuery(search) }, }); if (res.status !== 200) { - throw new Error(`Loading discussions answered ${res.status}.`); + throw new Error(`Loading articles answered ${res.status}.`); } return await res.json(); }, }); - -export const invalidateDiscussionLists = async (queryClient: QueryClient) => { - await queryClient.invalidateQueries({ queryKey: discussionQueryKeys.all }); -}; ``` -- The URL keys are `q`, `sort`, `page` and one per filter, here `board`. Each `orderBy` must be in `publicApi.orderableFields` or be `publishedAt`. -- `apiQuery` turns the URL state into `first`, `page`, `order`, `orderBy` and `search`. Filters are yours to map, because a URL name and an API name can differ. -- The response is typed from the content type. Each row carries exactly `listFields` plus `author`, with dates as ISO strings. There is nothing to re-validate. -- `invalidateDiscussionLists` expires every cached page of the list. The create and edit pages call it after a save. See [Public create form](/docs/content-engine/public-write-frontend#write-the-page). - -A localized content type also passes `locale` in the query, so each language gets its own response and cache entry. Discussions are not localized, so the locale only keys the cache. +- `sorts` maps a URL value to the API's `orderBy` and `order`. `publishedAt` is always orderable. +- `ArticleListSearch` is the list's state as the URL holds it: `page`, and later `q` and `sort`. +- `apiQuery(search)` builds `first`, `page`, `orderBy` and `order` for the list route. @@ -166,260 +96,155 @@ A localized content type also passes `locale` in the query, so each language get ### Add the route -Register the page with the list's `search` validator and a skeleton for the first load: - -```ts title="plugins/example/src/routes.ts" -import { FeedPendingSkeleton } from "@vitnode/core/tanstack/pending"; +Edit `src/routes.ts` and add the page. This is an excerpt: -import { discussionListSearch } from "./pages/discussion-list"; +```ts title="plugins/newsroom/src/routes.ts" +import { articleList } from "./pages/article-list"; // [!code ++] export const routes = definePluginRoutes([ - // [!code ++:6] - page("/discussions", { - component: lazy(() => import("./pages/discussions-page")), - messages: ["@vitnode/example.discussions"], - pendingComponent: FeedPendingSkeleton, - search: discussionListSearch, + // [!code ++:5] + page("/news", { + component: lazy(() => import("./pages/articles-page")), + messages: ["newsroom.articles"], + search: articleList.parseSearch, }), ]); ``` -`messages` loads the `@vitnode/example.discussions` namespace for the page, its `head` and every component inside it. `pendingComponent` shows while the loader runs on a client-side navigation. A server render waits for the loader instead. +`search` cleans the query string before the loader sees it, so `/news?page=2` becomes `{ page: 2 }` and `/news?page=1` the same state as `/news`. `messages` loads the `newsroom.articles` translations for the page and its head. -### Load the page and build its head +### Write the page + +Create `src/pages/articles-page.tsx`. This is the complete file: -Create `src/pages/discussions-page.tsx`. The loader warms the query, so the server renders the list and the browser starts with it: +```tsx title="plugins/newsroom/src/pages/articles-page.tsx" +import type { PluginRoutePageProps } from "@vitnode/core/routing"; -```tsx title="plugins/example/src/pages/discussions-page.tsx" +import { DateFormat } from "@vitnode/core/components/date-format"; import { LOADER_DATA_QUERY_META } from "@vitnode/core/lib/query-client"; -import { contentListPageHead } from "@vitnode/core/tanstack/content"; +import { + contentListPageHead, + ContentListPagination, + ContentListResults, +} from "@vitnode/core/tanstack/content"; +import { HrefLink } from "@vitnode/core/tanstack/link"; import { defineRoute, type RouteLoadContext, } from "@vitnode/core/tanstack/plugin-routes"; +import { useTranslations } from "use-intl"; -import { - DISCUSSIONS_HREF, - type DiscussionListSearch, - discussionsQueryOptions, -} from "./discussion-list"; -import { boardsQueryOptions } from "./example-queries"; - -const loadDiscussions = async ( - queryClient: RouteLoadContext["queryClient"], - { locale, search }: { locale: string; search: DiscussionListSearch }, -) => { - // A failed list is an error state on the page, not an error page. - const [discussions, boards] = await Promise.all([ - queryClient - .query({ - ...discussionsQueryOptions({ locale, search }), - meta: LOADER_DATA_QUERY_META, - }) - .catch(() => null), - queryClient.query(boardsQueryOptions()).catch(() => []), - ]); - - return { boards, discussions }; -}; +import type { ArticleListSearch } from "./article-list"; -type DiscussionsPageData = Awaited>; +import { articleList, articlesQueryOptions } from "./article-list"; -export const route = defineRoute({ - load: async ({ context, search }) => - await loadDiscussions(context.queryClient, { - locale: context.locale, - search, - }), +const loadArticles = async ( + { locale, queryClient }: RouteLoadContext, + search: ArticleListSearch, +) => + // A failed list becomes a message on the page, not an error page. + await queryClient + .query({ + ...articlesQueryOptions({ locale, search }), + meta: LOADER_DATA_QUERY_META, + }) + .catch(() => null); + +type ArticlesPageData = Awaited>; + +export const route = defineRoute({ + load: async ({ context, search }) => await loadArticles(context, search), head: ({ search, t }) => contentListPageHead({ - description: t("@vitnode/example.discussions.list.desc"), - pathname: DISCUSSIONS_HREF, + description: t("newsroom.articles.desc"), + pathname: "/news", search, - title: t("@vitnode/example.discussions.list.title"), + title: t("newsroom.articles.title"), }), }); -``` - -- `LOADER_DATA_QUERY_META` keeps the result out of the dehydrated cache, because the loader data already carries it to the browser. -- `.catch(() => null)` turns a failed request into the list's error state instead of the app's error page. -- `contentListPageHead` gives the plain list `index, follow` and a canonical link, and every searched, filtered, sorted or paged view `noindex, follow`. -- `boardsQueryOptions` is the example's own query for the board picker. It is not part of the list. - - - - - -### Render the controls and the results - -Add the component to the same file. `ContentListResults` decides between the loading, error, empty, no-results and item states. `ContentListPagination` renders page links from `pageInfo`: -```tsx title="plugins/example/src/pages/discussions-page.tsx" -const DiscussionsPage = ({ - loaderData: { boards, discussions }, - navigate, +const ArticlesPage = ({ + loaderData: articles, search, -}: PluginRoutePageProps) => { - const t = useTranslations("@vitnode/example.discussions.list"); - const [isPending, startTransition] = React.useTransition(); - const update = (patch: Partial) => { - startTransition(async () => { - await navigate({ - resetScroll: false, - search: discussionList.withSearch(search, patch), - }); - }); - }; - const input = useContentListSearchInput({ - onCommit: q => { - update({ q }); - }, - value: search.q, - }); +}: PluginRoutePageProps) => { + const t = useTranslations("newsroom.articles"); return (
-
- { - input.setValue(event.target.value); - }} - onKeyDown={event => { - if (event.key === "Enter") input.commit(); - }} - placeholder={t("search_placeholder")} - type="search" - value={input.value} - /> - { - const board = Number(event.target.value); - update({ board: board > 0 ? board : undefined }); - }} - value={String(search.board ?? "")} - > - {t("all_boards")} - {boards.map(board => ( - - {board.name} - - ))} - - { - update({ - sort: discussionList.sorts.find( - sort => sort === event.target.value, - ), - }); - }} - value={search.sort ?? discussionList.defaultSort} - > - {discussionList.sorts.map(sort => ( - - {t(`sort.${sort}`)} - - ))} - -
+

+ {t("title")} +

} - error={} - getKey={discussion => discussion.id} - isError={discussions === null} - isFetching={isPending} - isFiltered={discussionList.isFiltered(search)} - itemClassName="flex flex-col gap-2 p-4 md:flex-row md:justify-between" - items={discussions?.edges} + empty={

{t("empty")}

} + error={

{t("error")}

} + getKey={article => article.id} + isError={articles === null} + isFiltered={articleList.isFiltered(search)} + itemClassName="flex flex-col gap-1 p-4" + items={articles?.edges} label={t("title")} loading={null} - noResults={ - {t("no_results.clear")}} - title={t("no_results.title")} - /> - } - renderItem={discussion => - discussion.slug === null ? ( - {discussion.title} - ) : ( - - {discussion.title} + noResults={

{t("empty")}

} + renderItem={article => ( + <> + + {article.title} - ) - } + {article.publishedAt ? ( + + + + ) : null} + + )} /> - {discussions ? ( + {articles ? ( - discussionList.href(DISCUSSIONS_HREF, { ...search, page }) - } + hrefFor={page => articleList.href("/news", { ...search, page })} label={t("pagination")} - pageInfo={discussions.pageInfo} + pageInfo={articles.pageInfo} /> ) : null}
); }; -export default DiscussionsPage; +export default ArticlesPage; ``` -The excerpt trims styling, the author line and the per-row actions. `ListState` is a small wrapper around the `Empty` components from `@vitnode/core/components/ui/empty`. The full file is `plugins/example/src/pages/discussions-page.tsx`. - -- `withSearch` returns the new URL state and drops `page` when anything else changes, because page 2 of the old results says nothing about the new ones. -- `useContentListSearchInput` waits 500 ms after the last keystroke before it navigates, commits at once on Enter, and follows the URL when the reader goes back or forward. -- `navigate` inside `startTransition` keeps the current rows on screen, dimmed and `aria-busy`, while the next page loads. -- `empty` shows when nothing is published and `noResults` when a search or filter matches nothing, so only the second one offers **Clear filters**. -- `loading` renders while `items` is `undefined`. This page always has data from its loader, so it passes `null`; a list that fetches in the browser shows a skeleton there. -- Link each row to its record's page with the same helper that page's route uses, here `discussionHref(slug)`. A row whose `slug` is `null` has no page to link to. - - - -To put Edit and Report buttons on each row, see [Public edit form](/docs/content-engine/public-write-edit#show-the-edit-button) and [Report content](/docs/content-engine/reports#add-it-to-every-row-of-a-list). +- **The loader** runs on the server for the first visit and in the browser on later navigations. It warms the query with `queryClient.query`, so the page is in the server-rendered HTML. `LOADER_DATA_QUERY_META` keeps the query out of the HTML's cache payload, because the router already sends the loader data. +- **A failed request** becomes `null`, and `ContentListResults` shows `error` instead of breaking the page. It also decides between `empty` (nothing published) and `noResults` (nothing matches a search). +- **`ContentListPagination`** renders real links built by `articleList.href`, so paging works before hydration and crawlers can follow it. It renders nothing when there is one page. +- **`contentListPageHead`** sets the title and description. The plain list is indexable; page 2 and later get `noindex, follow`.
-### Add the messages +### Add the labels -Every label lives in the plugin's locale file, under the namespace the route loads: +Add the `articles` keys to `src/locales/en.json`, inside `newsroom`: -```json title="plugins/example/src/locales/en.json" +```json title="plugins/newsroom/src/locales/en.json" { - "@vitnode/example": { - "discussions": { - "list": { - "title": "Discussions", - "desc": "Questions, ideas and show-and-tell from the community, newest first.", - "search_label": "Search discussions", - "search_placeholder": "Search by title", - "board_label": "Board", - "all_boards": "All boards", - "sort_label": "Sort by", - "sort": { "newest": "Newest", "oldest": "Oldest", "title": "Title (A-Z)" }, - "pagination": "Discussion pages", - "empty": { "title": "No discussions yet" }, - "no_results": { "title": "No discussions match", "clear": "Clear filters" }, - "error": { "title": "Couldn't load discussions" } - } + "newsroom": { + "articles": { + "title": "News", + "desc": "The latest articles from our team.", + "empty": "No articles yet. Check back soon.", + "error": "The articles could not be loaded. Try again in a moment.", + "pagination": "Article pages" } } } @@ -429,42 +254,177 @@ Every label lives in the plugin's locale file, under the namespace the route loa
-## How URL parameters, locales and pagination interact +## Check the result -- **Normalized URLs.** `parseSearch` drops unknown keys, invalid values, `page=1` and the default sort. `/discussions?sort=newest&page=1&utm_source=x` and `/discussions` are the same state, so they share a query key and a canonical link. -- **Locales.** On a [localized](/docs/content-engine/localization) content type, pass `locale` in the query. A record with no translation in that language falls back to the default language when the type uses `fallback: "default"`, and each row says which language it is in under `locale`. An unknown `?locale=` answers `404` rather than an English list. -- **Pagination.** `ContentListPagination` reads `currentPage`, `totalPages`, `hasNextPage` and `hasPreviousPage` from `pageInfo`, and every page is a real link, so it works before hydration. A `page` past the end gets the last page. The list route also accepts `cursor` for "Older" and "Newer" links; [Containers on the frontend](/docs/content-engine/container-frontend#add-the-board-page) uses it. -- **Caching.** The API caches each list read per query and locale, and every publish, edit, unpublish, hide or delete of the content type expires it. TanStack Query keeps its own copy in the browser for `RECORD_STALE_TIME`, which is why a page that writes records invalidates its list. -- **Hidden records.** With [`visibility`](/docs/content-engine/content-visibility#who-can-still-see-it), a signed-in author, editor or moderator also gets the hidden records they may see. Those rows carry `hidden: true`, so mark them, for example with `HiddenIcon` from `@vitnode/core/components/hidden-icon`. That response is `private, no-store` and skips the API cache. A route with a page `cache` serves signed-in visitors the HTML rendered for guests, so refetch the list in the browser under a key that includes the viewer, like the blog's `blogViewerPostsQueryOptions`. For your own counts or queries, combine `contentListHiddenAccess` and `contentPublicListCondition` from `@vitnode/core/content/server`. +Open `http://localhost:3000/news`: -Records reach crawlers through their own pages and the [sitemap](/docs/content-engine/seo#sitemap). The sitemap route lists records, not lists, so add the list's own URL, such as `/discussions`, where your app builds `/sitemap.xml`. + -The blog plugin builds `/blog` and `/blog/category/:slug` from the same pieces with only pages and a category, in `plugins/blog/src/pages/blog-list.ts` and `blog-queries.ts`. +1. The newest ten published articles show, newest first. `Release notes for October` is a draft, so it never appears. +2. Click **2**. The URL becomes `/news?page=2` and the remaining articles show. +3. Titles link to `/news/{slug}`. The next tutorial, [Content page](/docs/content-engine/public-pages), creates that page. -## Check the result +## Add search + +Searching narrows the list on the server. Add `searchableFields` to `publicApi` in `src/content/article.ts`: + +```ts title="plugins/newsroom/src/content/article.ts" + publicApi: { + path: "news", + fields: ["id", "title", "slug", "body", "publishedAt"], + searchableFields: ["title"], // [!code ++] + }, +``` + +Then add a search field to the page, in three places in `src/pages/articles-page.tsx`. First, the imports: + +```tsx title="plugins/newsroom/src/pages/articles-page.tsx" +import { Input } from "@vitnode/core/components/ui/input"; +import { + contentListPageHead, + ContentListPagination, + ContentListResults, + useContentListSearchInput, // [!code ++] +} from "@vitnode/core/tanstack/content"; +import { useTransition } from "react"; +``` + +Second, the search state at the top of `ArticlesPage`, which now also reads `navigate` from its props: + +```tsx title="plugins/newsroom/src/pages/articles-page.tsx" +const ArticlesPage = ({ + loaderData: articles, + navigate, // [!code ++] + search, +}: PluginRoutePageProps) => { + const t = useTranslations("newsroom.articles"); + // [!code ++:15] + const [isPending, startTransition] = useTransition(); + const update = (patch: Partial) => { + startTransition(async () => { + await navigate({ + resetScroll: false, + search: articleList.withSearch(search, patch), + }); + }); + }; + const input = useContentListSearchInput({ + onCommit: q => { + update({ q }); + }, + value: search.q, + }); +``` + +Third, the field below the `

`, and two props on `ContentListResults`: + +```tsx title="plugins/newsroom/src/pages/articles-page.tsx" +
+ { + input.setValue(event.target.value); + }} + onKeyDown={event => { + if (event.key === "Enter" && !event.nativeEvent.isComposing) { + input.commit(); + } + }} + placeholder={t("search_label")} + type="search" + value={input.value} + /> +
+ +{t("no_results")}

} // [!code highlight] + // ...the other props stay as they are +/> +``` + +- `useContentListSearchInput` waits until typing pauses, then calls `onCommit`. Enter commits at once. +- `withSearch` writes `?q=` and goes back to page 1, because page 4 of the old results says nothing about the new ones. +- `isFetching` keeps the current results on screen, dimmed and marked `aria-busy`, while the next ones load. -Open `http://localhost:3000/discussions`: +Add `"search_label": "Search articles"` and `"no_results": "No articles match your search."` to the `articles` labels. -1. Type a word from a title. After half a second the URL gets `?q=...` and the list narrows. -2. Pick a board, then **Title (A-Z)**. The URL keeps both, and the list jumps back to page 1. -3. Search for a word no title contains. **No discussions match** shows with **Clear filters**. -4. Move to page 2, refresh, then press back. Each view comes back exactly as the URL describes it. +## Add sorting -The plain list is the indexable one; every other state stays out of the index but keeps its links followable: +Add `orderableFields: ["title"]` to `publicApi`, then more sorts to `articleList`: -```bash -curl -s http://localhost:3000/discussions | grep -oE '' -curl -s "http://localhost:3000/discussions?board=1" | grep -oE '' +```ts title="plugins/newsroom/src/pages/article-list.ts" + sorts: { + newest: { order: "desc", orderBy: "publishedAt" }, + oldest: { order: "asc", orderBy: "publishedAt" }, // [!code ++] + title: { order: "asc", orderBy: "title" }, // [!code ++] + }, ``` -```html - - +Render a select next to the search field, using the same `update` function: + +```tsx title="plugins/newsroom/src/pages/articles-page.tsx" +import { + NativeSelect, + NativeSelectOption, +} from "@vitnode/core/components/ui/native-select"; + + { + update({ sort: articleList.sorts.find(sort => sort === event.target.value) }); + }} + value={search.sort ?? articleList.defaultSort} +> + {articleList.sorts.map(sort => ( + + {t(`sort.${sort}`)} + + ))} +; ``` +Label each sort under `articles.sort`, such as `"sort": { "newest": "Newest first", "oldest": "Oldest first", "title": "Title A–Z" }`, and add `"sort_label": "Sort articles"`. The list never writes the default sort to the URL, so `/news?sort=newest` and `/news` are the same page. + + +## Keep list rows light + +The list returns every field in `fields`, including each article's body. Name the fields a row needs with `listFields`; the single-record route still returns all of `fields`: + +```ts title="plugins/newsroom/src/content/article.ts" + publicApi: { + path: "news", + fields: ["id", "title", "slug", "body", "publishedAt"], + listFields: ["id", "title", "slug", "publishedAt"], // [!code ++] + }, +``` + +## Add a filter + +A filter needs a field that `publicApi.filterableFields` lists, such as a relation, an enum or a [container](/docs/content-engine/containers). Add a parser for its URL value in `defineContentList({ filters })`, and pass the value to the list route in `fetch`. The newsroom plugin's letters list filters by desk this way in `plugins/newsroom/src/pages/letter-list.ts`; [List API](/docs/content-engine/public-lists-api#definecontentlist) lists the parsers. + +## How the list URL behaves + +- **Normalized URLs.** `parseSearch` drops unknown keys, invalid values, `page=1` and the default sort. `/news?sort=newest&page=1&utm_source=x` and `/news` are the same state, so they share a query key and a canonical link. +- **Locales.** On a [localized](/docs/content-engine/localization) content type, pass `locale` in the query. A record with no translation in that language falls back to the default language when the type uses `fallback: "default"`, and each row says which language it is in under `locale`. An unknown `?locale=` answers `404` rather than an English list. +- **Pagination.** `ContentListPagination` reads `currentPage`, `totalPages`, `hasNextPage` and `hasPreviousPage` from `pageInfo`, and every page is a real link, so it works before hydration. A `page` past the end gets the last page. The list route also accepts `cursor` for "Older" and "Newer" links; [Container pages](/docs/content-engine/container-frontend#add-the-desk-page) uses it. +- **Caching.** The API caches each list read per query and locale, and every publish, edit, unpublish, hide or delete of the content type expires it. TanStack Query keeps its own copy in the browser for `RECORD_STALE_TIME`, which is why a page that writes records invalidates its list. +- **Hidden records.** With [`visibility`](/docs/content-engine/content-visibility#who-can-still-see-it), a signed-in author, editor or moderator also gets the hidden records they may see. Those rows carry `hidden: true`, so mark them, for example with `HiddenIcon` from `@vitnode/core/components/hidden-icon`. That response is `private, no-store` and skips the API cache. A route with a page `cache` serves signed-in visitors the HTML rendered for guests, so refetch the list in the browser under a key that includes the viewer, like the blog's `blogViewerPostsQueryOptions`. For your own counts or queries, combine `contentListHiddenAccess` and `contentPublicListCondition` from `@vitnode/core/content/server`. + +Records reach crawlers through their own pages and the [sitemap](/docs/content-engine/seo#sitemap). The sitemap route lists records, not lists, so add the list's own URL, such as `/news`, where your app builds `/sitemap.xml`. + +The blog plugin builds `/blog` and `/blog/category/:slug` from the same pieces with only pages and a category, in `plugins/blog/src/pages/blog-list.ts` and `blog-queries.ts`. + +Next, give every article its own page with [Content page](/docs/content-engine/public-pages). diff --git a/apps/web/content/docs/content-engine/public-pages-article.webp b/apps/web/content/docs/content-engine/public-pages-article.webp index 055e5af89e..8fcdc84c72 100644 Binary files a/apps/web/content/docs/content-engine/public-pages-article.webp and b/apps/web/content/docs/content-engine/public-pages-article.webp differ diff --git a/apps/web/content/docs/content-engine/public-pages.mdx b/apps/web/content/docs/content-engine/public-pages.mdx index 0b44dffaf2..40a00182f7 100644 --- a/apps/web/content/docs/content-engine/public-pages.mdx +++ b/apps/web/content/docs/content-engine/public-pages.mdx @@ -1,24 +1,21 @@ --- -title: Public pages -description: Give every published VitNode Content Engine record its own URL with delivery, a plugin page, slug redirects and localized URLs. +title: Content page +description: Give every published VitNode Content Engine record its own page and URL with delivery, a plugin route and contentDeliveryPage, with missing records and drafts answering 404. icon: Compass implementation: objective: Give each published record of an existing content type its own public page and URL. decisions: - Which items get their own page? Offer content types with a public API that have no `delivery` block yet. - What URL do the pages use? Recommend the default `/{publicApi.path}/:slug`, or a custom path with exactly one `:slug` that does not start with `/admin` or `/api`. - - Which fields fill the page title, the description and "hide from search engines"? Offer the type's public text fields, its text or rich text fields, and a yes/no field. - - Should old URLs redirect after a slug changes? This needs editorial history; if the type has none, ask whether to add it. - - Should the pages be listed in the sitemap, and how often do they change? Always, hourly, daily, weekly, monthly, yearly or never. + - Should old URLs redirect after a slug changes, and should pages be in the sitemap? Both are optional; redirects need `editorial`. constraints: - - Add the `delivery` block and claim the URL with a `page()` in the plugin's routes; the build fails while a content URL has no page. + - Add `delivery`, export the content types from the plugin's `src/content.ts`, and claim the URL with a `page()` in the plugin's routes; the build fails while a content URL has no page. - In the page loader, read the record with `fetcher` and build the page and its head with `contentDeliveryPage` and `contentDeliveryPageHead`, as the guide shows. - - Delivery adds no columns, so there is nothing to migrate. related: - title: SEO href: /docs/content-engine/seo - - title: Localized URLs - href: /docs/dev/i18n/localized-urls + - title: Rich text + href: /docs/content-engine/rich-text - title: Delivery reference href: /docs/content-engine/reference#delivery --- @@ -26,9 +23,9 @@ implementation: import { ImgDocs } from "@/components/fumadocs/img"; import articlePage from "./public-pages-article.webp"; -Delivery turns a published record into a web page at a stable URL, such as `/articles/hello-content-engine`. The Content Engine resolves the URL, answers `404` for drafts, redirects retired slugs and builds the page's SEO metadata from fields you choose. Your plugin owns the page component, so the content type, its API and its URL stay in one plugin. +Delivery gives every published record a page at a stable URL, such as `/news/welcome-to-the-newsroom`. The Content Engine resolves the URL, answers `404` for drafts and unknown slugs, and builds the page's metadata; your plugin owns the page component. -The content type needs [publication and a public API](/docs/content-engine/public-api). This guide continues with `example.article`. +This continues the article from [Content list](/docs/content-engine/public-lists), whose rows already link to `/news/{slug}`. @@ -36,29 +33,18 @@ The content type needs [publication and a public API](/docs/content-engine/publi ### Turn on delivery -Add a `delivery` block to the definition: - -```ts title="plugins/example/src/content/article.ts" -export const articleContentType = defineContentType({ - id: "example.article", - // [!code ++:10] - delivery: { - redirects: true, - seo: { - titleField: "title", - descriptionField: "excerpt", - noIndexField: "noIndex", - openGraph: { titleField: "title", descriptionField: "excerpt" }, - }, - sitemap: { changeFrequency: "weekly", priority: 0.7 }, +Edit `src/content/article.ts` and add `delivery` after `publicApi`: + +```ts title="plugins/newsroom/src/content/article.ts" + publicApi: { + path: "news", + fields: ["id", "title", "slug", "body", "publishedAt"], }, -}); + + delivery: true, // [!code ++] ``` -- Every SEO field must also be in `publicApi.fields`, because the page renders it publicly. That is why the example lists `noIndex` there. -- `noIndexField` names a boolean. When it is `true`, the page gets `noindex, nofollow` and the record leaves the sitemap. -- `redirects` needs `editorial: true`, which the example article already has. `sitemap` needs `publication`. -- The page URL defaults to `/{publicApi.path}/:slug`, so articles live at `/articles/:slug`. Set `delivery.path` to use another URL. It needs exactly one `:slug` and cannot start with `/admin` or `/api`. +`true` uses every default. The page URL is `/{publicApi.path}/:slug`, so articles live at `/news/:slug`. URLs are site-wide, which is why no other plugin's content type may use the same `publicApi.path`. Delivery adds no columns, so there is nothing to migrate. @@ -66,25 +52,15 @@ export const articleContentType = defineContentType({ ### Export the plugin's content types -Export every content type with public URLs from `src/content.ts`: +Create `src/content.ts`. It lists every content type with public URLs: -```ts title="plugins/example/src/content.ts" -import { advancedArticleContentType } from "@/content/advanced-article"; +```ts title="plugins/newsroom/src/content.ts" import { articleContentType } from "@/content/article"; -import { categoryContentType } from "@/content/category"; -import { localizedArticleContentType } from "@/content/localized-article"; -import { pageContentType } from "@/content/page"; - -export const contentTypes = [ - articleContentType, - advancedArticleContentType, - localizedArticleContentType, - categoryContentType, - pageContentType, -]; + +export const contentTypes = [articleContentType]; ``` -The build compares these URLs with your plugin's routes. A content type whose URL no page serves stops `dev` and `build` with a `content-url-without-page` error, so a broken canonical link never ships. [Plugin files](/docs/content-engine/plugin-files) explains how the build finds this module. +VitNode reads this module by its name. It compares these URLs with your plugin's routes, and `dev` and `build` stop with a `content-url-without-page` error while a content URL has no page, so a broken canonical link never ships. [Plugin files](/docs/content-engine/plugin-files) lists every file VitNode reads. @@ -92,14 +68,20 @@ The build compares these URLs with your plugin's routes. A content type whose UR ### Claim the URL -Add a page for the same path to the plugin's routes: +Edit `src/routes.ts` and add the page below the list. This is an excerpt: -```ts title="plugins/example/src/routes.ts" +```ts title="plugins/newsroom/src/routes.ts" export const routes = definePluginRoutes([ + page("/news", { + component: lazy(() => import("./pages/articles-page")), + messages: ["newsroom.articles"], + search: articleList.parseSearch, + }), + // [!code ++:4] - page("/articles/:slug", { + page("/news/:slug", { component: lazy(() => import("./pages/article-page")), - messages: ["@vitnode/example.articles"], + messages: ["newsroom.articles"], }), ]); ``` @@ -110,9 +92,12 @@ export const routes = definePluginRoutes([ ### Write the page -The loader calls two public routes in parallel. `/delivery/resolve/{slug}` says whether the URL is a record, an old slug to redirect, or nothing. `/{slug}` returns the record's public fields. +Create `src/pages/article-page.tsx`. This is the complete file: -```tsx title="plugins/example/src/pages/article-page.tsx" +```tsx title="plugins/newsroom/src/pages/article-page.tsx" +import { DateFormat } from "@vitnode/core/components/date-format"; +import { RichTextContent } from "@vitnode/core/components/rich-text"; +import { zodRichTextDocument } from "@vitnode/core/content"; import { definePluginRoute, type PluginRoutePageProps, @@ -126,11 +111,11 @@ import { z } from "zod"; import { manifest } from "@/manifest.gen"; -import { ContentArticle } from "./content-article"; - const zodArticle = z.object({ - excerpt: z.string().nullable(), + body: zodRichTextDocument, + id: z.number(), publishedAt: z.string().nullable(), + slug: z.string(), title: z.string(), }); @@ -139,14 +124,14 @@ const loadArticle = async (slug: string) => { fetcher({ plugin: manifest.id, method: "get", - module: "content/articles", + module: "content/news", path: "/delivery/resolve/{slug}", args: { params: { slug: encodeURIComponent(slug) } }, }), fetcher({ plugin: manifest.id, method: "get", - module: "content/articles", + module: "content/news", path: "/{slug}", args: { params: { slug: encodeURIComponent(slug) } }, }), @@ -178,25 +163,88 @@ export const route = definePluginRoute({ const ArticlePage = ({ loaderData: { item }, }: PluginRoutePageProps) => ( - +
+
+

+ {item.title} +

+ {item.publishedAt ? ( + + + + ) : null} +
+ + +
); export default ArticlePage; ``` -- `contentDeliveryPage` returns `{ item, metadata }`. It throws a not-found for an unknown slug or a draft, and a `308` redirect for a retired slug. -- `contentDeliveryPageHead` sets the title, the meta description (HTML stripped, cut at 160 characters), `robots` for a no-index record and `hreflang` alternates for localized content. -- `load` comes before `head`, so TypeScript can infer `loaderData`. `ContentArticle` is the plugin's own layout component. +- **The loader** asks two public routes at once. `/delivery/resolve/{slug}` says whether the URL is a record, an old slug to redirect, or nothing; `/{slug}` returns the public fields. +- **`contentDeliveryPage`** returns `{ item, metadata }`. For an unknown slug or a draft it throws the router's not-found, so the app shows its 404 page with a `404` status, and `item` is never `null` in the component. +- **`zodArticle`** checks the response and types `body` as a rich text document. `RichTextContent` renders it as React elements, on the server and in the browser. +- **`contentDeliveryPageHead`** builds the head from `metadata`. The `title` option is the fallback while no SEO field is configured. + +`load` comes before `head`, so TypeScript infers `loaderData`.
-## Render the record's fields +## Check the result + +Open `http://localhost:3000/news/welcome-to-the-newsroom`: + + + +- The page title is `Welcome to the newsroom - VitNode`. +- `/news/release-notes-for-october`, the draft, shows the not-found page with status `404`. Publish it in the AdminCP and the same URL works. +- `/news/no-such-article` answers `404` too. + +The loader's first request shows what delivery decided: + +```bash +curl http://localhost:3000/api/newsroom/content/news/delivery/resolve/welcome-to-the-newsroom +``` + +```json title="200" +{ + "type": "content", + "canonicalPath": "/news/welcome-to-the-newsroom", + "canonicalInternalPath": "/news/welcome-to-the-newsroom", + "itemId": 1, + "seo": { "description": null, "title": null }, + "openGraph": null, + "robots": null, + "alternates": [], + "hreflang": { "languages": {} }, + "isFallback": false, + "hidden": false, + "locale": null, + "requestedLocale": null +} +``` + +`seo` is empty because no SEO fields are configured yet. + +## Optional next steps + +- **Titles, descriptions and the sitemap.** Set `delivery.seo` and `delivery.sitemap`, as [SEO](/docs/content-engine/seo) shows. +- **Redirects after a slug change.** Add `editorial: true` and `delivery: { redirects: true }`. The old slug is kept in `core_content_slug_history`, and `contentDeliveryPage` turns a visit to it into a `308` redirect to the new URL. While a record is [hidden](/docs/content-engine/content-visibility), its page and old slugs answer `404`. +- **Edit and Report buttons.** [Permissions](/docs/content-engine/public-permissions) and [Reports](/docs/content-engine/reports) add them next to the title. +- **A custom URL.** Set `delivery.path`, such as `"/articles/:slug"`. It needs exactly one `:slug` and cannot start with `/admin` or `/api`. + +## Render other fields The detail route returns each field in `publicApi.fields` in a shape made for rendering. The blog's `plugins/blog/src/pages/post-page.tsx` uses all of them: @@ -209,8 +257,8 @@ The detail route returns each field in `publicApi.fields` in a shape made for re | People from `publicApi.people` | `{ id, name, fullName, nameCode, avatarColor, avatarUrl, role }`, or `null` for a deleted account | `UserFormat` from `@vitnode/core/components/user-format`, `Avatar` from `@vitnode/core/components/avatar` | | To-one relations | `{ id }` | Load the related record separately when you need more than its id | -```tsx title="plugins/example/src/pages/discussion-page.tsx" -

+```tsx title="plugins/newsroom/src/pages/letter-page.tsx (excerpt)" +

{t("posted_by")} {item.author ? ( @@ -219,49 +267,34 @@ The detail route returns each field in `publicApi.fields` in a shape made for re )}

- + ``` `RichTextContent` renders the document as React elements on the server and in the browser, without `dangerouslySetInnerHTML`. Validate an author with `zodContentPublicPerson` from `@vitnode/core/content` when your page parses the response with Zod. ## Pages without delivery -Delivery is optional. A content type without it still has `GET /{slug}`, and your page reads it directly. The example plugin's discussions do this. `contentPageItem` from `@vitnode/core/tanstack/content` throws the router's not-found for a `null` record, like `contentDeliveryPage` does: +Delivery is optional. A content type without it still has `GET /{slug}`, and your page reads it directly. The newsroom plugin's letters do this, in `plugins/newsroom/src/pages/letter-page.tsx`. `contentPageItem` from `@vitnode/core/tanstack/content` throws the router's not-found for a `null` record, like `contentDeliveryPage` does: -```ts title="plugins/example/src/pages/discussion-page.tsx" +```ts title="plugins/newsroom/src/pages/letter-page.tsx (excerpt)" const detail = await fetcher({ plugin: manifest.id, method: "get", - module: "content/discussions", + module: "content/letters", path: "/{slug}", args: { params: { slug: encodeURIComponent(slug) } }, }); const item = contentPageItem( - detail.status === 200 ? zodDiscussion.parse(await detail.json()) : null, + detail.status === 200 ? zodLetter.parse(await detail.json()) : null, ); ``` Without delivery there are no slug redirects, no canonical metadata and no sitemap entries, so write `head` yourself. The [SEO guide](/docs/content-engine/seo) covers the head helpers. -## Missing, hidden and unavailable records - -The public routes answer the same `404` for a slug that never existed, a draft, a scheduled record and a record whose translation is unpublished, so a page cannot reveal that a draft exists. Both `contentDeliveryPage` and `contentPageItem` turn that into the app's not-found page. Give the route a `notFound` component when you want a page-specific message. - -A [hidden](/docs/content-engine/content-visibility) record is the exception. Its author and moderators who may view hidden records still get it, with `Cache-Control: private, no-store` and `noindex`, and delivery sets `metadata.hidden: true`. The blog uses that flag to show a "hidden" notice and to drop the Report button. Everyone else gets `404`. - -## Add Edit and Report buttons - -Put **Edit** and **Report** next to the title. `useContentPermissions` tells you which ones this visitor gets, as `canEdit` and `canReport`. See [Public edit form](/docs/content-engine/public-write-edit#show-the-edit-button) and [Report content](/docs/content-engine/reports). - -## Keep old URLs working - -Renaming a slug breaks links people already shared. With `delivery.redirects`, the old slug is stored in `core_content_slug_history` when an editor changes it, and the resolve route answers `{ "type": "redirect", "status": 308, "location": "/articles/new-slug" }`. `contentDeliveryPage` turns that into a `308` redirect. While a record is [hidden](/docs/content-engine/content-visibility), its page and its old slugs answer `404` instead. - -## Sitemap entries - -`delivery.sitemap` lists every published URL for crawlers. [SEO](/docs/content-engine/seo#sitemap) covers the route, paging and turning the entries into XML. - ## Localized URLs Every URL delivery builds follows the app's [localized URL rules](/docs/dev/i18n/localized-urls): language prefixes, per-language domains and translated route segments. The blog plugin's posts are a working example. They are [localized](/docs/content-engine/localization) and live at `/blog/article/:slug`, and the web app translates that route for Polish: @@ -278,58 +311,4 @@ i18n: { The blog's sitemap then lists `/blog/article/english-title` for English and `/pl/wpisy/polish-title` for Polish, and only published translations become `hreflang` alternates. Every `delivery` option is in the [content type reference](/docs/content-engine/reference#delivery). -## Check the result - -Open `http://localhost:3000/articles/hello-content-engine`. Delivery adds no columns, so there is nothing to migrate. - -The page head carries the article's title and excerpt: - -```bash -curl -s http://localhost:3000/articles/hello-content-engine | grep -oE '[^<]*|' -``` - -```html -Hello Content Engine - VitNode - -``` - -The resolve route shows what the loader received. `openGraph`, `canonicalPath` and `hreflang` are there too if you want to render more tags yourself: - -```bash -curl http://localhost:3000/api/@vitnode/example/content/articles/delivery/resolve/hello-content-engine -``` - -```json -{ - "type": "content", - "canonicalPath": "/articles/hello-content-engine", - "canonicalInternalPath": "/articles/hello-content-engine", - "seo": { - "title": "Hello Content Engine", - "description": "A first article from the example plugin, served by the public API and rendered on its own page." - }, - "openGraph": { - "title": "Hello Content Engine", - "description": "A first article from the example plugin, served by the public API and rendered on its own page." - }, - "robots": { "follow": true, "index": true }, - "alternates": [], - "hreflang": { "languages": {} }, - "isFallback": false, - "itemId": null, - "locale": null, - "requestedLocale": null -} -``` - -`itemId` is `null` because `id` is not in the article's `publicApi.fields`. Unpublish the article and the same URL shows the app's not-found page. - - +Next, show the newest articles under each one with an [Embedded list](/docs/content-engine/public-lists-embed). diff --git a/apps/web/content/docs/content-engine/public-permissions-api.mdx b/apps/web/content/docs/content-engine/public-permissions-api.mdx new file mode 100644 index 0000000000..63f62a01c2 --- /dev/null +++ b/apps/web/content/docs/content-engine/public-permissions-api.mdx @@ -0,0 +1,96 @@ +--- +title: Permissions API +description: Reference for the VitNode Content Engine GET /permissions route and the useContentPermissions hook, with ids, limits, the canEdit and canReport flags, omitted records, batching, cache keys and invalidation. +icon: KeyRound +--- + +This page is the contract behind [Permissions](/docs/content-engine/public-permissions). It covers what `GET /{publicApi.path}/permissions` accepts and answers, and how `useContentPermissions` from `@vitnode/core/tanstack/content` asks it. Examples use the tutorial's articles, at `/api/newsroom/content/news`. + +## When the route exists + +The Content Engine mounts `GET /permissions` next to the [public read routes](/docs/content-engine/public-api) when the content type has [`publicWrite.update`](/docs/content-engine/public-write) or [`reports`](/docs/content-engine/reports). Without either, there is nothing to answer and the [fetcher](/docs/dev/fetcher) refuses the path at compile time. + +```http title="Request" +GET /api/newsroom/content/news/permissions?ids=1,2,15 +Cookie: vitnode_auth= +``` + +```json title="200" +{ + "items": [ + { "id": 1, "canEdit": false, "canReport": true }, + { "id": 15, "canEdit": true, "canReport": false } + ] +} +``` + +| Part | Rule | +| --- | --- | +| `ids` | Comma-separated record ids, for example `12,13,14`. Duplicates collapse, and a value that is not a valid id for the content type's [ID strategy](/docs/content-engine/id-strategy) is ignored | +| Limit | At most 50 distinct ids (`CONTENT_PUBLIC_MAX_PAGE_SIZE`). More answers `400` with `{ "error": "Ask for at most 50 ids." }` | +| `items` | One entry per record the visitor may see, in the order asked | +| Caching | `Cache-Control: private, no-store`. The answer depends on who asks, so no shared cache ever stores it | + +## What the flags mean + +| Flag | `true` when | Guests | +| --- | --- | --- | +| `canEdit` | `GET /{id}/edit` and `PATCH /{id}` would accept this visitor: a moderator with `can_edit` in the record's container, or the owner while the record is not hidden, `update.own` is on and the container's `authorEdit` policy still allows it | Always `false` | +| `canReport` | The content type has `reports`, the record is public, and the visitor did not write it | `true` on public records. The report dialog asks them to sign in | + +A record is **left out of `items`** when the visitor can neither read it publicly nor edit it: a missing id, someone else's draft, or a hidden record for anyone but a moderator with `can_edit`. The route answers the same way for all of them, so it never confirms that a draft exists. Your own draft comes back with `canEdit: true` and `canReport: false`. + +The flags answer "should this button show?", not "why not?". They carry no denial reason. When an edit is refused, the write route says why, such as `edit_window_closed`; see [Write API](/docs/content-engine/public-write-api#rejection). + + + The answer is what the server decided when the page asked. An edit window can close, or a moderator can lose `can_edit`, while the page stays open. The server authorizes every write again, so a stale `true` only means the save gets refused with a message. + + +## `useContentPermissions` + +```ts +const permissions = useContentPermissions({ fetch, ids, queryKey }); +``` + +| Name | Type | Required | Description | +| --- | --- | --- | --- | +| `ids` | `readonly (number \| string)[]` | Yes | The records on screen, any number and in any order | +| `fetch` | `(ids) => Promise<{ items }>` | Yes | Calls `GET /permissions` with `ids.join(",")`. Receives at most 50 ids per call. Throw on anything but `200` | +| `queryKey` | `QueryKey` | Yes | The content type's query prefix, such as `[manifest.id, "comments"]` | + +| Returns | Type | Behavior | +| --- | --- | --- | +| `of(id)` | `(id) => { canEdit, canReport }` | The record's flags. All `false` until known, for a record left out of `items` and for a record in a failed batch. Stable until an answer changes, so memoized rows can receive it | +| `isPending` | `boolean` | `true` while the session or any batch still waits for its first answer. `false` for an empty `ids` list | +| `isError` | `boolean` | `true` when at least one batch failed. Its records read as all `false`; the others keep their answers | + +`useContentPermissions` runs in the browser. Shared, cached HTML never contains a visitor's buttons. They appear after the session is known. + +### Batches + +The hook removes duplicate ids and sends the rest in batches of 50, the route's limit, with at most three requests in flight. Each batch is its own query, cut from `ids` in the order you pass them and sorted inside, so: + +- A thread with 120 comments on screen makes three requests instead of one refused one. +- **Load more** that appends ids keeps every full batch before it, and its cached answer, untouched. +- The same records in the same order always produce the same query keys. + +Hide a button while its record's flags are unknown, rather than showing a page-wide spinner. A failed batch hides its buttons and nothing else, so the page stays usable. + +### Cache keys and invalidation + +Every batch is stored under `[...queryKey, "permissions", { ids, userId }]`. The user id makes the answer personal. Signing in or out moves to new keys, so one visitor never sees another's buttons, even on a page served from a shared cache. + +The Content Engine's public reads are the same for everybody and can be cached on a CDN; the permissions route is per visitor and is never cached outside the tab. Keep the two in separate queries, as the hook does, and never put flags into a shared response. + +VitNode's query client does not refetch on mount or window focus, so an answer lives until it is invalidated, the network reconnects or the tab reloads. Refresh it after anything that can change it: + +```ts +await queryClient.invalidateQueries({ + queryKey: [manifest.id, "comments", "permissions"], +}); +``` + +- **After a write**, new records get their own batch automatically, because their ids are new. Invalidate when a write changes who may edit, such as a moderator hiding a record. +- **After a role or policy change** in the same tab, such as a member becoming a moderator, invalidate the prefix or reload. +- Invalidating the plain prefix, `[manifest.id, "comments"]`, refreshes the permissions together with every other query under it. + diff --git a/apps/web/content/docs/content-engine/public-permissions-edit-link.webp b/apps/web/content/docs/content-engine/public-permissions-edit-link.webp new file mode 100644 index 0000000000..9517f2f50a Binary files /dev/null and b/apps/web/content/docs/content-engine/public-permissions-edit-link.webp differ diff --git a/apps/web/content/docs/content-engine/public-permissions.mdx b/apps/web/content/docs/content-engine/public-permissions.mdx new file mode 100644 index 0000000000..30d273a9a6 --- /dev/null +++ b/apps/web/content/docs/content-engine/public-permissions.mdx @@ -0,0 +1,158 @@ +--- +title: Permissions +description: Show Edit and other actions only to visitors who may use them with useContentPermissions, while the VitNode Content Engine authorizes every write on the server. Explains staff, moderator and member permissions. +icon: KeyRound +implementation: + objective: Show action buttons, such as Edit, only to visitors the Content Engine allows. + decisions: + - Which items get the buttons? Offer content types with `publicWrite.update` or `reports`. + - Which pages show them? Offer the record pages and lists that render those items. + constraints: + - Ask `GET /{publicApi.path}/permissions` through `useContentPermissions`; never compare author ids or roles in the browser. + - Pass every id on screen to one hook call. + related: + - title: Permissions API + href: /docs/content-engine/public-permissions-api + - title: Reports + href: /docs/content-engine/reports + - title: Edit form + href: /docs/content-engine/public-write-edit +--- + +import { ImgDocs } from "@/components/fumadocs/img"; +import editLink from "./public-permissions-edit-link.webp"; + +A page should show **Edit** only to people who may edit. `useContentPermissions` asks the Content Engine what the current visitor may do with the records on screen, so the page never compares author ids or guesses who is a moderator. The server still checks every write itself. + +This continues the article from [Edit form](/docs/content-engine/public-write-edit). Authors can edit at `/news/:id/edit`, but nothing links there yet. + + + + + +### Ask the Content Engine + +Create `src/pages/article-permissions.ts`. It wraps `useContentPermissions` with the typed `fetcher` call for your articles: + +```ts title="plugins/newsroom/src/pages/article-permissions.ts" +import { useContentPermissions } from "@vitnode/core/tanstack/content"; +import { fetcher } from "@vitnode/core/tanstack/fetcher"; + +import { manifest } from "@/manifest.gen"; + +/** What the visitor may do with each article on screen: `canEdit`, `canReport`. */ +export const useArticlePermissions = (ids: readonly number[]) => + useContentPermissions({ + ids, + queryKey: [manifest.id, "articles"], + fetch: async request => { + const res = await fetcher({ + plugin: manifest.id, + method: "get", + module: "content/news", + path: "/permissions", + args: { query: { ids: request.join(",") } }, + }); + if (res.status !== 200) throw new Error("Loading permissions failed."); + + return await res.json(); + }, + }); +``` + +The route, `GET /api/newsroom/content/news/permissions`, exists because the article has `publicWrite.update`. `queryKey` starts with the article list's prefix, so refreshing the list refreshes the answers too. + + + + + +### Show the Edit link + +Edit `src/pages/article-page.tsx`. Add an `ArticleActions` component above `ArticlePage`, and render it in the article's header. These are excerpts: + +```tsx title="plugins/newsroom/src/pages/article-page.tsx" +import { buttonVariants } from "@vitnode/core/components/ui/button"; +import { HrefLink } from "@vitnode/core/tanstack/link"; +import { useTranslations } from "use-intl"; + +import { useArticlePermissions } from "./article-permissions"; + +/** Asked in the browser, so the cached page stays the same for everyone. */ +const ArticleActions = ({ id }: { id: number }) => { + const t = useTranslations("newsroom.articles"); + const { canEdit } = useArticlePermissions([id]).of(id); + + return ( +
+ {canEdit ? ( + + {t("edit_link")} + + ) : null} +
+ ); +}; +``` + +```tsx title="plugins/newsroom/src/pages/article-page.tsx" +
+ {/* title and date as before */} + +
+``` + +Add `"edit_link": "Edit"` under `newsroom.articles` in `src/locales/en.json`. + +`of(id)` reads all `false` until the answer arrives, so the link appears a moment after the page, and never for someone who may not use it. + +
+ +
+ +## Check the result + +1. Sign in as the member who wrote an article and open it. **Edit** shows below the date and opens the edit form. +2. Open it as another member. There is no **Edit**. +3. Open an article staff wrote in the AdminCP. Its `author` is empty, so only a moderator sees **Edit**. + + + +## The server still decides + +The answer is a hint for the interface, not a lock. Every write route checks the session, the owner and the moderator permissions again, so a stale **Edit**, for example after an edit window closed, only leads to a refusal message. The flags also carry no reason; the write route says why when it refuses. + +Keep the answer out of anything shared. Article pages are the same for every reader and can be cached; the permissions request is personal, never cached outside the tab, and keyed by the signed-in user. + +## Staff, moderators and members + +Three kinds of permission meet on a content type: + +| Who | Where they act | Granted in | Example | +| --- | --- | --- | --- | +| **Staff** | The AdminCP and its `/admin/` routes | **AdminCP → Staff → Administrators** | `newsroom:article:can_edit` edits any article in the AdminCP | +| **Moderators** | The public site, the ModCP | **AdminCP → Staff → Moderators** | The moderator `can_edit` edits any article from the site | +| **Members** | The public site | Ownership, set by the server | The author edits their own article | + +`canEdit` combines the last two. It is `true` for the owner while the rules allow it, and for a moderator with `can_edit`. An AdminCP session never adds powers on the public site. Moderators edit with the same forms as members, so one edit page serves both. + +## Lists and threads + +On a page with many records, pass every id on screen to one hook call and read each row with `of(id)`. The hook splits more than 50 ids into batches, so a long thread works too: + +```tsx +const permissions = useArticlePermissions( + articles?.edges.map(article => article.id) ?? [], +); + +permissions.of(article.id).canEdit; +``` + +[Permissions API](/docs/content-engine/public-permissions-api) has the route's contract, every option and return value of the hook, and when to invalidate it. Next, let readers flag a problem with [Reports](/docs/content-engine/reports). diff --git a/apps/web/content/docs/content-engine/public-write-admin-list.webp b/apps/web/content/docs/content-engine/public-write-admin-list.webp deleted file mode 100644 index 5fb69a656f..0000000000 Binary files a/apps/web/content/docs/content-engine/public-write-admin-list.webp and /dev/null differ diff --git a/apps/web/content/docs/content-engine/public-write-api.mdx b/apps/web/content/docs/content-engine/public-write-api.mdx index 6e2343ea5f..d38f0d311f 100644 --- a/apps/web/content/docs/content-engine/public-write-api.mdx +++ b/apps/web/content/docs/content-engine/public-write-api.mdx @@ -1,19 +1,19 @@ --- -title: Public write API +title: Write API description: Reference for Content Engine publicWrite, with options and defaults, writable fields, generated create and edit routes, request and response bodies, status and error codes, createContentPublicWriteService and definition errors. icon: FileJson --- -This page lists everything `publicWrite` accepts and everything its routes answer. For a guided setup, follow [Public write](/docs/content-engine/public-write). Examples use `example.discussion` from the example plugin, whose write routes live at `/api/@vitnode/example/content/discussions`. +This page lists everything `publicWrite` accepts and everything its routes answer. For a guided setup, follow [Member writing](/docs/content-engine/public-write). Examples use `newsroom.letter` from the newsroom plugin, whose write routes live at `/api/newsroom/content/letters`. ## `publicWrite` options `publicWrite` is a block of `defineContentType` from `@vitnode/core/content`. Leave it out, or set it to `false`, to keep public writing off. -```ts title="plugins/example/src/content/discussion.ts" +```ts title="plugins/newsroom/src/content/letter.ts" publicWrite: { ownership: { field: "author" }, - create: { fields: ["title", "body"], status: "published" }, + create: { fields: ["title", "body", "desk"], status: "published" }, update: { fields: ["title", "body"] }, }, ``` @@ -57,17 +57,17 @@ They never accept: A `relation` is writable only when its target content type has a `publicApi`. On each request, every referenced record must also be published and not hidden. -A `container` is writable in `create.fields` only. A member picks the container once, when they create the record; moving it later is a staff edit, so an author cannot drop their record into a container they could not post in. On each create, the server reads the container back inside the write's transaction and checks that it belongs to the field's collection. A missing or foreign container answers `400` with an `unavailable` issue on that field. See [Containers on the frontend](/docs/content-engine/container-frontend#let-members-pick-a-board). +A `container` is writable in `create.fields` only. A member picks the container once, when they create the record; moving it later is a staff edit, so an author cannot drop their record into a container they could not post in. On each create, the server reads the container back inside the write's transaction and checks that it belongs to the field's collection. A missing or foreign container answers `400` with an `unavailable` issue on that field. See [Container pages](/docs/content-engine/container-frontend#let-members-pick-a-desk). ### Moderator permission -A content type with `update` adds `can_edit` to its plugin's moderator permissions, under the content type's permission module, such as `discussion`. It shares the `@vitnode/example:discussion:can_edit` label with the admin permission. If the plugin declares that module under `permissionStaff.moderator` itself, the declared list wins. +A content type with `update` adds `can_edit` to its plugin's moderator permissions, under the content type's permission module, such as `letter`. It shares the `newsroom:letter:can_edit` label with the admin permission. If the plugin declares that module under `permissionStaff.moderator` itself, the declared list wins. When the content type lives in a container collection with [publishing policies](/docs/content-engine/container-policies), `can_edit` is scoped by that collection and `can_approve` is added next to it. See [Container-scoped permissions](/docs/dev/moderation/scoped-permissions#container-scoped-permissions). ## Generated routes -The routes live in the public module, under `/api/{pluginId}/content/{publicApi.path}`, next to the [public read routes](/docs/content-engine/public-api#the-routes). Only the ones `publicWrite` enables are mounted, and the [fetcher](/docs/dev/fetcher) types them the same way: a `post` to a content type without `create` is a compile error. +The routes live in the public module, under `/api/{pluginId}/content/{publicApi.path}`, next to the [public read routes](/docs/content-engine/public-api). The Content Engine mounts only the ones `publicWrite` enables, and the [fetcher](/docs/dev/fetcher) types them the same way: a `post` to a content type without `create` is a compile error. | Method and path | Enabled by | Body | Success | | ---------------- | ---------- | ----------------------------- | -------------------------------------------------------------------------- | @@ -78,7 +78,7 @@ The routes live in the public module, under `/api/{pluginId}/content/{publicApi. `GET /{id}/edit` and `PATCH /{id}` answer only for the owner and for moderators. There is no public delete, publish or hide route. -`GET /permissions` takes up to 50 comma-separated ids and answers for guests too. `canEdit` follows the same rules as `PATCH`; `canReport` is `true` for a public record, with `reports` on, that the visitor did not write. A record the visitor can neither read nor edit is left out of `items`, so the route never confirms a draft exists. +`GET /permissions` takes up to 50 comma-separated ids and answers for guests too. `canEdit` follows the same rules as `PATCH`; `canReport` is `true` for a public record, with `reports` on, that the visitor did not write. A record the visitor can neither read nor edit is left out of `items`, so the route never confirms a draft exists. [Permissions](/docs/content-engine/public-permissions) has the full contract. A request passes these checks in order: the rate limit, the captcha (create only), the session, the body, then for an edit the owner or moderator check and the relation targets, all before anything is written. @@ -95,7 +95,7 @@ Both bodies are strict JSON objects. A key outside the allowlist is refused, nev Each value uses the field's own validation, the same as in the AdminCP. A rich text field takes a ProseMirror document, and a relation takes an id, or an array of ids for `multiple: true`. ```http title="PATCH request" -PATCH /api/@vitnode/example/content/discussions/1 +PATCH /api/newsroom/content/letters/1 Content-Type: application/json Cookie: vitnode_auth= @@ -110,10 +110,10 @@ Every successful response is the same record shape: | ------------- | -------------------------- | -------------------------------------------------------------------------------------- | | `id` | `number` or `string` | A string for the `uuid` and `bigint` [ID strategies](/docs/content-engine/id-strategy) | | `slug` | `string` \| `null` | The value of the `publicApi` slug field, the key of `GET /{slug}` | -| `status` | `"draft"` \| `"published"` | | +| `status` | `"draft"` \| `"published"` | Only `"published"` is public. A draft may or may not wait for a moderator; check `approval` | | `publishedAt` | ISO date string \| `null` | | -| `version` | `number` | Editorial content types only. Send it back as `expectedVersion` | -| `approval` | object \| `null` | The record's pending submission, or a rejection nothing replaced yet. See [Approvals](/docs/content-engine/approvals#what-the-author-sees) | +| `version` | `number` | Editorial content types only. Send it back as `expectedVersion`; without `editorial`, `expectedVersion` is refused with `400` | +| `approval` | object \| `null` | The record's pending submission, or a rejection nothing replaced yet. Always `null` outside a container with policies, even for drafts. See [Drafts and approval](/docs/content-engine/approvals#drafts-and-approval) | | `values` | object | Every field in `create.fields` or `update.fields`, `null` when empty. Nothing else | ```json title="200" @@ -180,7 +180,7 @@ A title that is too short and an owner the client tried to set: ```json title="400" { "code": "CONTENT_INVALID_INPUT", - "contentTypeId": "example.discussion", + "contentTypeId": "newsroom.letter", "issues": [ { "code": "too_small", @@ -201,7 +201,7 @@ A body that is not JSON: ```json title="400" { "code": "CONTENT_INVALID_INPUT", - "contentTypeId": "example.discussion", + "contentTypeId": "newsroom.letter", "issues": [ { "code": "invalid_json", @@ -212,12 +212,12 @@ A body that is not JSON: } ``` -If discussions had a `category` relation, a draft, hidden or missing category would all get the same answer: +If letters had a `category` relation, a draft, hidden or missing category would all get the same answer: ```json title="400" { "code": "CONTENT_INVALID_INPUT", - "contentTypeId": "example.discussion", + "contentTypeId": "newsroom.letter", "issues": [ { "code": "unavailable", @@ -228,12 +228,12 @@ If discussions had a `category` relation, a draft, hidden or missing category wo } ``` -A member editing somebody else's published discussion: +A member editing somebody else's published letter: ```json title="403" { "code": "CONTENT_FORBIDDEN", - "contentTypeId": "example.discussion", + "contentTypeId": "newsroom.letter", "issues": [] } ``` @@ -247,7 +247,7 @@ The same member editing somebody else's draft gets `404` with `"code": "CONTENT_ ```json title="409 version conflict" { "code": "CONTENT_VERSION_CONFLICT", - "contentTypeId": "example.discussion", + "contentTypeId": "newsroom.letter", "currentVersion": 4, "expectedVersion": 3, "itemId": 1 @@ -257,12 +257,12 @@ The same member editing somebody else's draft gets `404` with `"code": "CONTENT_ ```json title="409 unique conflict" { "code": "CONTENT_UNIQUE_CONFLICT", - "contentTypeId": "example.discussion", + "contentTypeId": "newsroom.letter", "itemId": null } ``` -`itemId` is `null` for a unique conflict on create. On `example.discussion`, a unique conflict means the slug derived from the title is taken. +`itemId` is `null` for a unique conflict on create. On `newsroom.letter`, a unique conflict means the slug derived from the title is taken. ### Rate limit @@ -304,42 +304,14 @@ const rejection = parseContentPublicWriteRejection( ## Frontend helpers -From `@vitnode/core/tanstack/content`, for pages in the browser. +From `@vitnode/core/tanstack/content`, for pages in the browser: -### `useContentPermissions` - -```ts -const permissions = useContentPermissions({ fetch, ids, queryKey }); -permissions.of(id); // { canEdit, canReport } -``` - -| Option | Type | Description | -| ---------- | ------------------------------------------------------- | --------------------------------------------------------------------------- | -| `ids` | `readonly (number \| string)[]` | The records on screen, at most 50 | -| `fetch` | `(ids) => Promise<{ items }>` | Calls `GET /permissions` through `fetcher` with `ids.join(",")` | -| `queryKey` | `QueryKey` | The content type's query prefix; invalidating it refreshes the answer | - -`of(id)` returns all `false` until the answer arrives and for a record left out. The hook waits for the session and asks again when somebody signs in or out. - -### `useContentPublicWrite` - -```ts -const write = useContentPublicWrite({ fields, latestVersion, uniqueField, version }); -const record = await write.submit(form, async expectedVersion => await fetcher(/* ... */)); -``` - -| Option | Type | Description | -| --------------- | --------------------------------- | ------------------------------------------------------------------------------------ | -| `fields` | `readonly string[]` | The form's fields. Server issues on them show under the field; others are a toast | -| `uniqueField` | `string` | Where "already taken" shows on `409 CONTENT_UNIQUE_CONFLICT`. Without it, a toast | -| `version` | `number` | The version the edit form opened with. Leave it out for a create | -| `latestVersion` | `() => Promise` | Reads the current version for **Keep my version**. Without it, only a reload is offered | - -`submit` returns the record on a `2xx` and `null` otherwise, after showing the reason. It passes the current `expectedVersion` to `request` and stores the new one after each save. `write.conflict` is the version-conflict alert, or `null`; render it above the form. Messages come from `core.global.content_write` and follow the reader's language. +- [`useContentPublicWrite`](/docs/content-engine/public-write-form-api) sends a create or edit from an `AutoForm` and shows field errors, refusals and version conflicts. +- [`useContentPermissions`](/docs/content-engine/public-permissions-api#usecontentpermissions) reads `GET /permissions` for any number of records, in batches of 50. ## Server API -Use these from `@vitnode/core/content/server` when a plugin needs its own write route, such as "start a discussion from a question", with the same ownership, authorization and effects as the generated routes. +Use these from `@vitnode/core/content/server` when a plugin needs its own write route, such as "write a letter from a question", with the same ownership, authorization and effects as the generated routes. ### `createContentPublicWriteService` @@ -347,7 +319,7 @@ Use these from `@vitnode/core/content/server` when a plugin needs its own write createContentPublicWriteService({ c, model, pluginId }); ``` -`c` is the Hono context, `model` is the content model, such as `discussionContent`, and `pluginId` is the plugin that owns the content type. The service acts as the user session of `c`, never as an AdminCP session. +`c` is the Hono context, `model` is the content model, such as `letterContent`, and `pluginId` is the plugin that owns the content type. The service acts as the user session of `c`, never as an AdminCP session. | Method | Returns | Behavior | | ------------------ | ----------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | @@ -365,9 +337,9 @@ The methods throw instead of answering: | `ContentVersionConflict` | `expectedVersion` is out of date (editorial content types) | | Postgres unique violation | A unique value is taken | -A route that starts a discussion from a single question. `withHttpErrors` turns a unique conflict into the structured `409`, and the `catch` maps a refusal to its status: +A route that starts a letter from a single question on a chosen desk. `withHttpErrors` turns a unique conflict into the structured `409`, and the `catch` maps a refusal to its status: -```ts title="plugins/example/src/api/modules/discussions/ask.route.ts" +```ts title="Example: a letter from a question" import { ContentPublicWriteDenied } from "@vitnode/core/content"; import { createContentPublicWriteService, @@ -377,21 +349,24 @@ import { HTTPException } from "hono/http-exception"; import { z } from "zod"; import { buildRoute } from "@/api/builders"; -import { discussionContent } from "@/database/discussions"; +import { letterContent } from "@/database/letters"; import { manifest } from "@/manifest.gen"; export const askRoute = buildRoute({ route: { method: "post", path: "/ask", - description: "Start a discussion from a question", + description: "Start a letter from a question", withCaptcha: true, request: { body: { required: true, content: { "application/json": { - schema: z.object({ question: z.string().trim().min(3).max(200) }), + schema: z.object({ + question: z.string().trim().min(3).max(200), + desk: z.number().int().positive(), + }), }, }, }, @@ -403,17 +378,17 @@ export const askRoute = buildRoute({ schema: z.object({ id: z.number(), slug: z.string().nullable() }), }, }, - description: "Discussion started", + description: "Letter started", }, 401: { description: "Not signed in" }, - 409: { description: "A discussion with this title exists" }, + 409: { description: "A letter with this title exists" }, }, }, handler: async c => { - const { question } = c.req.valid("json"); + const { question, desk } = c.req.valid("json"); const service = createContentPublicWriteService({ c, - model: discussionContent, + model: letterContent, pluginId: manifest.id, }); @@ -423,6 +398,7 @@ export const askRoute = buildRoute({ async () => await service.create({ title: question, + desk, body: { type: "doc", content: [ @@ -433,7 +409,7 @@ export const askRoute = buildRoute({ ], }, }), - { contentTypeId: discussionContent.definition.id, structured: true }, + { contentTypeId: letterContent.definition.id, structured: true }, ); return c.json({ id: record.id, slug: record.slug }, 201); diff --git a/apps/web/content/docs/content-engine/public-write-create-form.webp b/apps/web/content/docs/content-engine/public-write-create-form.webp index 76ea6fbac8..359ea6c2a9 100644 Binary files a/apps/web/content/docs/content-engine/public-write-create-form.webp and b/apps/web/content/docs/content-engine/public-write-create-form.webp differ diff --git a/apps/web/content/docs/content-engine/public-write-edit-conflict.webp b/apps/web/content/docs/content-engine/public-write-edit-conflict.webp index a6cfbebf33..7ea9a127e3 100644 Binary files a/apps/web/content/docs/content-engine/public-write-edit-conflict.webp and b/apps/web/content/docs/content-engine/public-write-edit-conflict.webp differ diff --git a/apps/web/content/docs/content-engine/public-write-edit-forbidden.webp b/apps/web/content/docs/content-engine/public-write-edit-forbidden.webp deleted file mode 100644 index 6122ad5b1a..0000000000 Binary files a/apps/web/content/docs/content-engine/public-write-edit-forbidden.webp and /dev/null differ diff --git a/apps/web/content/docs/content-engine/public-write-edit-form.webp b/apps/web/content/docs/content-engine/public-write-edit-form.webp new file mode 100644 index 0000000000..d85a54fe9d Binary files /dev/null and b/apps/web/content/docs/content-engine/public-write-edit-form.webp differ diff --git a/apps/web/content/docs/content-engine/public-write-edit-pending.webp b/apps/web/content/docs/content-engine/public-write-edit-pending.webp new file mode 100644 index 0000000000..4a36dcac94 Binary files /dev/null and b/apps/web/content/docs/content-engine/public-write-edit-pending.webp differ diff --git a/apps/web/content/docs/content-engine/public-write-edit.mdx b/apps/web/content/docs/content-engine/public-write-edit.mdx index 567b5790da..7490826029 100644 --- a/apps/web/content/docs/content-engine/public-write-edit.mdx +++ b/apps/web/content/docs/content-engine/public-write-edit.mdx @@ -1,102 +1,199 @@ --- -title: Public edit form -description: Let authors and moderators edit Content Engine records from the site with an Edit button from canEdit, a form loaded from GET /{id}/edit, and useContentPublicWrite for versions, conflicts and errors. +title: Edit form +description: Build a VitNode Content Engine edit page for authors and moderators that loads the editable record, handles sign-in and refusals, saves with the expected version through useContentPublicWrite and keeps the member's input on a version conflict. icon: FilePen implementation: objective: Let authors and moderators edit existing records from the public site. decisions: - - Which items can be edited? Offer content types from the project's plugins that have `publicWrite.update`; if none has it, point to the Public write guide. - - Where do the Edit button and the edit page go? Offer the pages that show the chosen items; recommend `/{items}/:id/edit` for the form. + - Which items can be edited? Offer content types from the project's plugins that have `publicWrite.update`; if none has it, point to the Member writing guide. + - Where does the edit page go? Offer the pages that show the chosen items; recommend `/{items}/:id/edit`. constraints: - - Show Edit only when `useContentPermissions` answers `canEdit`; never decide it from the author or the session. - - Load the form with `GET /{id}/edit` and save with `PATCH /{id}` inside `useContentPublicWrite().submit`, passing `version` so conflicts are caught. - - Show pending proposals from `approval` instead of claiming the edit is live. + - Load the form with `GET /{id}/edit`, keep 401, 403, 404 and unexpected failures apart, and save with `PATCH /{id}` inside `useContentPublicWrite().submit`, passing `version`. + - After a save, move the form's defaults to the saved values, reset it and invalidate the cached lists. + - When the content type is filed in a container with publishing rules, also follow the Approval notices guide. related: - - title: Public create form - href: /docs/content-engine/public-write-frontend - - title: Report content - href: /docs/content-engine/reports - - title: Approvals - href: /docs/content-engine/approvals + - title: Form helpers + href: /docs/content-engine/public-write-form-api + - title: Permissions + href: /docs/content-engine/public-permissions + - title: Approval notices + href: /docs/content-engine/approval-notices --- +import { Accordion, Accordions } from "fumadocs-ui/components/accordion"; + import { ImgDocs } from "@/components/fumadocs/img"; import conflict from "./public-write-edit-conflict.webp"; -import forbidden from "./public-write-edit-forbidden.webp"; +import editForm from "./public-write-edit-form.webp"; -This guide adds an **Edit** button to each discussion and the page behind it. The Content Engine decides who may edit; your page asks it and shows the button. Saving catches the case where someone else saved first, without losing what the member typed. +This page builds `/news/:id/edit`, where an article's author, or a moderator, changes it. The page loads what may be edited, saves with the version it opened, and keeps the member's text when someone else saved first. -You need [public write](/docs/content-engine/public-write) with `update`, and the create form from [Public create form](/docs/content-engine/public-write-frontend). +This continues the [Create form](/docs/content-engine/public-write-frontend). It reuses `ArticleForm` from `src/pages/article-form.tsx`, and needs `editorial: true` from [Member writing](/docs/content-engine/public-write), which gives every article a `version`. All the code goes into one new file, `src/pages/article-edit-page.tsx`; the steps build it in order, and [the complete file](#the-complete-file) is at the end. -### Show the Edit button - -`useContentPermissions` answers `canEdit` for every record on screen in one request. It is `true` for the owner while the rules allow it and for moderators with the edit permission; you never compare user ids yourself: +### Register the edit route -```tsx title="plugins/example/src/pages/discussion-page.tsx" -const { canEdit } = useDiscussionPermissions([item.id]).of(item.id); +Edit `src/routes.ts` and add the page. This is an excerpt: -{canEdit ? ( - - {t("edit")} - -) : null} +```ts title="plugins/newsroom/src/routes.ts" + // [!code ++:4] + page("/news/:id/edit", { + component: lazy(() => import("./pages/article-edit-page")), + messages: ["newsroom.articles"], + }), ``` -`useDiscussionPermissions` is a five-line wrapper with the typed `fetcher` call, shown in [Report content](/docs/content-engine/reports#ask-who-may-report-what). On a list, pass every row's id at once. +There is no `requires: "authenticated"`. The page shows its own sign-in prompt, because it must tell "not signed in" apart from "not yours". During a navigation the current page stays on screen with the top progress bar until the loader finishes. -### Load the record +### Load the editable values -`GET /{id}/edit` answers for the people who may edit, drafts included. Turn every other answer into a page state: +Create `src/pages/article-edit-page.tsx` and start it with the loader. `GET /{id}/edit` answers only for the author and moderators, drafts included: -```tsx title="plugins/example/src/pages/discussion-edit-page.tsx" -const fetchEditable = async (id: number) => +```tsx title="plugins/newsroom/src/pages/article-edit-page.tsx (excerpt)" +// Answers only for the author and moderators, drafts included. +const fetchEditableArticle = async (id: number) => await fetcher({ plugin: manifest.id, method: "get", - module: "content/discussions", + module: "content/news", path: "/{id}/edit", args: { params: { id } }, }); +const loadEditableArticle = async (param: string) => { + const id = Number(param); + if (!Number.isSafeInteger(id) || id < 1) + return { state: "notFound" as const }; + + const res = await fetchEditableArticle(id); + if (res.status === 200) { + return { record: await res.json(), state: "ready" as const }; + } + if (res.status === 401) return { id, state: "unauthenticated" as const }; + if (res.status === 403) return { state: "forbidden" as const }; + if (res.status === 404) return { state: "notFound" as const }; + + // A server failure is not "not found": let the app's error page show it. + throw new Error(`Loading article ${id} for editing failed.`); +}; + +type ArticleEditData = Awaited>; + +type EditableArticle = Extract["record"]; + export const route = definePluginRoute({ - load: async ({ params }) => { - const res = await fetchEditable(Number(params.id)); - if (res.status === 200) return { record: await res.json(), state: "ready" as const }; - if (res.status === 401) return { state: "signIn" as const }; - if (res.status === 403) return { state: "forbidden" as const }; + load: async ({ params }) => await loadEditableArticle(params.id), - return { state: "notFound" as const }; - }, + head: ({ t }) => ({ + robots: "noindex, nofollow", + title: t("newsroom.articles.edit.title"), + }), }); ``` -- `401`: the visitor is signed out. Show a sign-in link. -- `403`: they can read the record but not edit it. -- `404`: it does not exist, or it is a draft or hidden record that is not theirs. The page never confirms it exists. +The record has `values` (the editable fields), `version`, `status` and `slug`. TypeScript infers its type from the route, so `EditableArticle` needs no hand-written interface. + + + + + +### Handle each answer + +At the end of the file, the page component turns each loader state into a screen: + +```tsx title="plugins/newsroom/src/pages/article-edit-page.tsx (excerpt)" +const ArticleEditPage = ({ + loaderData, + params, +}: PluginRoutePageProps) => { + const t = useTranslations("newsroom.articles"); + + if (loaderData.state === "unauthenticated") { + return ( +
+

+ {t("state.unauthenticated.title")} +

+ + {t("state.unauthenticated.action")} + +
+ ); + } + + if (loaderData.state !== "ready") { + const key = loaderData.state === "forbidden" ? "forbidden" : "not_found"; + + return ( +
+

+ {t(`state.${key}.title`)} +

+

+ {t(`state.${key}.desc`)} +

+
+ ); + } + + return ( +
+

+ {t("edit.title")} +

+ +
+ ); +}; + +export default ArticleEditPage; +``` + +| Answer | Screen | +| --- | --- | +| `401` | The visitor is signed out. The page shows a sign-in link that comes back here | +| `403` | They can read the article but may not edit it | +| `404` | It does not exist, or it is a draft or hidden article that is not theirs. The page never confirms that it exists | +| Anything else | The loader throws, so a server failure shows the app's error page instead of "not found" | + +`key={params.id}` remounts the form for another article, because the form reads `version` once.
-### Save with the version +### Render the shared form -Pass the record's `version` to `useContentPublicWrite`. It sends the right `expectedVersion` with every save and keeps it current afterwards: +Between the loader and the page component goes `ArticleEditForm`. It fills `ArticleForm` with the saved values and sets up `useContentPublicWrite` with the article's `version`. Its `onSubmit` comes in the next step: -```tsx title="plugins/example/src/pages/discussion-edit-page.tsx" -const DiscussionEditForm = ({ record }: { record: EditableDiscussion }) => { - const t = useTranslations("@vitnode/example.discussions"); +```tsx title="plugins/newsroom/src/pages/article-edit-page.tsx (excerpt)" +const ArticleEditForm = ({ record }: { record: EditableArticle }) => { + const t = useTranslations("newsroom.articles.edit"); + const queryClient = useQueryClient(); + const [current, setCurrent] = useState({ + slug: record.slug, + status: record.status, + }); + // AutoForm resets an untouched form to the defaults it renders with, so the + // defaults follow every save. + const [defaults, setDefaults] = useState({ + body: record.values.body, + title: record.values.title, + }); const write = useContentPublicWrite({ - fields: ["title", "body"], + fields: ARTICLE_FORM_FIELDS, latestVersion: async () => { - const res = await fetchEditable(record.id); + const res = await fetchEditableArticle(record.id); return res.status === 200 ? (await res.json()).version : null; }, @@ -104,72 +201,369 @@ const DiscussionEditForm = ({ record }: { record: EditableDiscussion }) => { version: record.version, }); - const onSubmit: AutoFormOnSubmit = async (values, form) => { - const updated = await write.submit(form, async expectedVersion => - await fetcher({ - plugin: manifest.id, - method: "patch", - module: "content/discussions", - path: "/{id}", - args: { params: { id: record.id }, body: { values, expectedVersion } }, - }), - ); - if (!updated) return; - - form.reset(values); - toast.success(t("edit.success.title")); - }; + // onSubmit, from the next step return ( <> {write.conflict} - + + {current.status === "published" && current.slug !== null ? ( + + {t("view")} + + ) : null} ); }; ``` -- `record.values` holds exactly the editable fields, so it fills the form as is. -- `PATCH` changes only the fields you send. -- **When someone else saved first**, nothing is written and `write.conflict` shows an alert above the form. The member's text stays. **Keep my version** uses `latestVersion` so the next save replaces the other change; **Load latest version** reloads the page. -- Field errors, an expired session and a refusal show the same way as on the create form. +**View article** links to the public page only while the article is published, because a draft's page answers `404`. + + + + + +### Save with the expected version + +Add `onSubmit` inside `ArticleEditForm`, where the comment says. `write.submit` sends `PATCH /{id}` with the version the form holds: + +```tsx title="plugins/newsroom/src/pages/article-edit-page.tsx (excerpt)" + const onSubmit: AutoFormOnSubmit = async ( + values, + form, + ) => { + const updated = await write.submit( + form, + async expectedVersion => + await fetcher({ + plugin: manifest.id, + method: "patch", + module: "content/news", + path: "/{id}", + args: { + params: { id: record.id }, + body: { values, expectedVersion }, + }, + }), + ); + if (!updated) return; + + // the next step continues here + }; +``` + +- **`expectedVersion`** is the `version` the form opened with. After a successful save, `useContentPublicWrite` stores the new version itself, so the next save sends that one. +- **`write.submit`** returns the record, or `null` after showing the reason: a field error under its field, a taken title under **Title**, or a toast for the rest, such as an expired session. + + + + + +### Update the form and the cached lists + +Replace the comment at the end of `onSubmit` with what happens after a successful save: + +```tsx title="plugins/newsroom/src/pages/article-edit-page.tsx (excerpt)" + setCurrent({ slug: updated.slug, status: updated.status }); + setDefaults(values); + form.reset(values); + await queryClient.invalidateQueries({ + queryKey: [manifest.id, "articles"], + }); + + toast.success(t("success.title"), { + description: t("success.desc", { title: values.title }), + }); +``` + +- **`setDefaults(values)` and `form.reset(values)`** make the saved text the form's new starting point, so the form is no longer dirty. `defaults` lives in state because `AutoForm` resets an untouched form to the `defaultValues` it renders with; without `setDefaults`, the old text would come back after a successful save. +- **`setCurrent`** keeps `slug` and `status` from the answer, so **View article** follows the latest state. +- **`invalidateQueries`** expires the cached [article list](/docs/content-engine/public-lists), whose query key starts with `[manifest.id, "articles"]`, so `/news` shows the new title. + + + + + +### Handle version conflicts + +There is nothing extra to write. `write.conflict`, rendered above the form, is the conflict alert. When someone else saved after the form opened, `PATCH /{id}` answers `409 CONTENT_VERSION_CONFLICT`, writes nothing, and the alert appears while the form keeps every character the member typed: + +- **Keep my version** reads the current version with `latestVersion`. The next **Save changes** replaces the other change. +- **Load latest version** reloads the page and drops the unsaved text. + + + + + +### Add the labels + +Add these keys under `newsroom.articles` in `src/locales/en.json`: + +```json title="plugins/newsroom/src/locales/en.json" +{ + "newsroom": { + "articles": { + "edit": { + "title": "Edit article", + "submit": "Save changes", + "view": "View article", + "success": { "title": "Article saved", "desc": "\"{title}\" is up to date." } + }, + "state": { + "unauthenticated": { "title": "Sign in to edit", "action": "Sign in" }, + "forbidden": { + "title": "You can't edit this article", + "desc": "Only its author and moderators can edit it." + }, + "not_found": { + "title": "Article not found", + "desc": "It does not exist, or you cannot see it." + } + } + } + } +} +```
-## Edits that wait for a moderator +## The complete file + + + + +```tsx title="plugins/newsroom/src/pages/article-edit-page.tsx" +import type { AutoFormOnSubmit } from "@vitnode/core/components/form/auto-form"; + +import { useQueryClient } from "@tanstack/react-query"; +import { buttonVariants } from "@vitnode/core/components/ui/button"; +import { + definePluginRoute, + type PluginRoutePageProps, +} from "@vitnode/core/routing"; +import { useContentPublicWrite } from "@vitnode/core/tanstack/content"; +import { fetcher } from "@vitnode/core/tanstack/fetcher"; +import { HrefLink } from "@vitnode/core/tanstack/link"; +import { useState } from "react"; +import { toast } from "sonner"; +import { useTranslations } from "use-intl"; + +import { manifest } from "@/manifest.gen"; + +import { + ARTICLE_FORM_FIELDS, + ArticleForm, + type ArticleFormSchema, +} from "./article-form"; + +// Answers only for the author and moderators, drafts included. +const fetchEditableArticle = async (id: number) => + await fetcher({ + plugin: manifest.id, + method: "get", + module: "content/news", + path: "/{id}/edit", + args: { params: { id } }, + }); + +const loadEditableArticle = async (param: string) => { + const id = Number(param); + if (!Number.isSafeInteger(id) || id < 1) + return { state: "notFound" as const }; + + const res = await fetchEditableArticle(id); + if (res.status === 200) { + return { record: await res.json(), state: "ready" as const }; + } + if (res.status === 401) return { id, state: "unauthenticated" as const }; + if (res.status === 403) return { state: "forbidden" as const }; + if (res.status === 404) return { state: "notFound" as const }; + + // A server failure is not "not found": let the app's error page show it. + throw new Error(`Loading article ${id} for editing failed.`); +}; + +type ArticleEditData = Awaited>; + +type EditableArticle = Extract["record"]; + +export const route = definePluginRoute({ + load: async ({ params }) => await loadEditableArticle(params.id), + + head: ({ t }) => ({ + robots: "noindex, nofollow", + title: t("newsroom.articles.edit.title"), + }), +}); + +const ArticleEditForm = ({ record }: { record: EditableArticle }) => { + const t = useTranslations("newsroom.articles.edit"); + const queryClient = useQueryClient(); + const [current, setCurrent] = useState({ + slug: record.slug, + status: record.status, + }); + // AutoForm resets an untouched form to the defaults it renders with, so the + // defaults follow every save. + const [defaults, setDefaults] = useState({ + body: record.values.body, + title: record.values.title, + }); + const write = useContentPublicWrite({ + fields: ARTICLE_FORM_FIELDS, + latestVersion: async () => { + const res = await fetchEditableArticle(record.id); + + return res.status === 200 ? (await res.json()).version : null; + }, + uniqueField: "title", + version: record.version, + }); + + const onSubmit: AutoFormOnSubmit = async ( + values, + form, + ) => { + const updated = await write.submit( + form, + async expectedVersion => + await fetcher({ + plugin: manifest.id, + method: "patch", + module: "content/news", + path: "/{id}", + args: { + params: { id: record.id }, + body: { values, expectedVersion }, + }, + }), + ); + if (!updated) return; + + setCurrent({ slug: updated.slug, status: updated.status }); + setDefaults(values); + form.reset(values); + await queryClient.invalidateQueries({ + queryKey: [manifest.id, "articles"], + }); + + toast.success(t("success.title"), { + description: t("success.desc", { title: values.title }), + }); + }; + + return ( + <> + {write.conflict} + + {current.status === "published" && current.slug !== null ? ( + + {t("view")} + + ) : null} + + ); +}; -In a container whose [publishing policy](/docs/content-engine/container-policies) says `edits: "approve"`, an author's save of a published record succeeds, but readers still see the old text. `updated.approval?.status` is `"pending"` and `updated.values` is the live record. Say so instead of "Changes saved", and open the form on the proposal next time: +const ArticleEditPage = ({ + loaderData, + params, +}: PluginRoutePageProps) => { + const t = useTranslations("newsroom.articles"); + + if (loaderData.state === "unauthenticated") { + return ( +
+

+ {t("state.unauthenticated.title")} +

+ + {t("state.unauthenticated.action")} + +
+ ); + } + + if (loaderData.state !== "ready") { + const key = loaderData.state === "forbidden" ? "forbidden" : "not_found"; + + return ( +
+

+ {t(`state.${key}.title`)} +

+

+ {t(`state.${key}.desc`)} +

+
+ ); + } -```ts -const proposed = - record.approval?.status === "pending" && record.approval.kind === "edit" - ? record.approval.values - : null; + return ( +
+

+ {t("edit.title")} +

+ +
+ ); +}; + +export default ArticleEditPage; ``` -[Approvals](/docs/content-engine/approvals#show-it-on-your-pages) shows the notice the forum renders. +
+
## Check the result -Sign in as the author of a discussion: - -1. Its page shows **Edit**. Other members see **Report** instead. -2. Change the title and click **Save changes**. A **Changes saved** toast appears. -3. Save the same discussion in a second tab, then save in the first. **Someone else saved in the meantime** appears and your text stays. Click **Keep my version**, then save again. -4. Open the edit URL as another member. The page says they can't edit it. +Sign in as the member who wrote an article in the [Create form](/docs/content-engine/public-write-frontend), and open `/news/{id}/edit` with its id. [Permissions](/docs/content-engine/public-permissions) adds an **Edit** link to the article page next. +1. Change the title and click **Save changes**. **Article saved** appears and the form keeps the new title. The slug stays the same, so links keep working, and `/news` shows the new title. +2. Open the same edit page in a second tab and save a change there. Then save in the first tab. **Someone else saved in the meantime** appears and your text stays. Click **Keep my version**, then **Save changes**. +3. Open the edit URL as another member, and you get **You can't edit this article**. Signed out, you get **Sign in to edit**, even for an id that does not exist, because the server checks the session first. Signed in, an id that does not exist shows **Article not found**. + + +## When edits wait for a moderator + +Newsroom articles are not filed in a container, so a `200` from `PATCH /{id}` always means the change is live. A content type filed in a container whose [publishing rules](/docs/content-engine/container-policies) hold edits for approval answers `200` with `approval.status: "pending"` instead, and the live record stays as it was. [Approval notices](/docs/content-engine/approval-notices) adds the pieces such a page needs. + +Next, show the **Edit** link only to the people who may use it with [Permissions](/docs/content-engine/public-permissions). diff --git a/apps/web/content/docs/content-engine/public-write-form-api.mdx b/apps/web/content/docs/content-engine/public-write-form-api.mdx new file mode 100644 index 0000000000..73a19f6e26 --- /dev/null +++ b/apps/web/content/docs/content-engine/public-write-form-api.mdx @@ -0,0 +1,122 @@ +--- +title: Form helpers +description: Reference for useContentPublicWrite, the VitNode hook that sends a public Content Engine create or edit from an AutoForm and shows field errors, refusals and version conflicts. +icon: SquareFunction +--- + +`useContentPublicWrite` from `@vitnode/core/tanstack/content` sends one public create or edit and handles every answer that is not a success. Your `onSubmit` only deals with the record it gets back. For a guided setup, follow [Create form](/docs/content-engine/public-write-frontend) or [Edit form](/docs/content-engine/public-write-edit). + +## Signature + +```tsx +import { useContentPublicWrite } from "@vitnode/core/tanstack/content"; + +const write = useContentPublicWrite({ fields, latestVersion, uniqueField, version }); + +const record = await write.submit(form, async expectedVersion => await fetcher(/* ... */)); + +<>{write.conflict}; +``` + +## Options + +| Name | Type | Required | Default | Description | +| --- | --- | --- | --- | --- | +| `fields` | `readonly string[]` | Yes | | The form's field names. A server issue on one of them shows under that field; an issue on any other field becomes a toast | +| `uniqueField` | `string` | No | | Where "This is already taken" shows on `409 CONTENT_UNIQUE_CONFLICT`, such as `title` when the slug is derived from it. Without it, the message is a toast | +| `version` | `number` | No | | The record's `version` when the edit form opened. Leave it out for a create. Read once, on the first render | +| `latestVersion` | `() => Promise` | No | | Reads the record's current `version`, usually from `GET /{id}/edit`. Enables **Keep my version** on a conflict. Return `null` when it cannot be read | + +## Return value + +| Name | Type | Behavior | +| --- | --- | --- | +| `submit(form, request)` | `Promise` | Calls `request`, then returns the parsed record on a `2xx` and `null` on anything else, after showing the reason | +| `conflict` | `ReactNode` | The version-conflict alert while a conflict is open, otherwise `null`. Render it above the form | + +### `submit` + +- **`form`** is the form API `AutoForm` passes as the second argument of `onSubmit`. The hook uses it to set field errors. +- **`request`** receives `expectedVersion` and returns the typed `fetcher` response. For an edit, put `expectedVersion` in the body; for a create, ignore the argument. +- The returned record is typed from the response, so `record.slug`, `record.values` and `record.approval` need no annotation. + +After a successful write, the hook stores the record's new `version`, so the next save sends it without reloading the page, and clears an open conflict. + +## How answers are handled + +| Answer | What the member sees | `submit` returns | +| --- | --- | --- | +| `200` or `201` | Nothing; showing success is up to you | The record | +| `400` with issues on fields in `fields` | Each message under its field | `null` | +| `400` with any other issue, or none | One toast with the first message | `null` | +| `401`, `403`, `404`, `429` | One toast: session ended, "You can't change this.", "This no longer exists.", "You're doing that too often." | `null` | +| `403` with `edit_window_closed` or `moderators_only` | One toast naming that reason | `null` | +| `409 CONTENT_UNIQUE_CONFLICT` | "This is already taken" under `uniqueField`, or a toast | `null` | +| `409 CONTENT_VERSION_CONFLICT` | The `conflict` alert; the form keeps every value | `null` | +| A network error or any other status | "Something went wrong on our side." | `null` | + +Each issue code maps to a message. `not_writable`, `unavailable`, `edit_window_closed` and `moderators_only` have their own, and every other code, such as Zod's `too_small`, shows "Check this value." [Write API](/docs/content-engine/public-write-api#codes) lists the codes. + +## The conflict alert + +`409 CONTENT_VERSION_CONFLICT` means someone saved the record after the form opened. Nothing was written, and the form keeps what the member typed. The alert offers: + +- **Keep my version**, shown only with `latestVersion`. It reads the current version, stores it and closes the alert with a toast. The member's next save replaces the other change. If `latestVersion` returns `null` or throws, a toast says so and the alert stays. +- **Load latest version** reloads the page. Anything not saved is lost, and the alert says so. + +## What stays your job + +The hook never decides what a success means. After `submit` returns a record: + +- **Say what happened.** Show a success toast, or a "waiting for approval" message when `record.approval?.status === "pending"`, because then readers still see the old content. See [Approvals](/docs/content-engine/approvals#what-the-author-sees). +- **Keep the form in step.** Call `form.reset(values)` so the form is no longer dirty, and pass the saved values as the form's defaults on the next render, or `AutoForm` puts the old defaults back. For a pending edit, `record.values` is still the live record and the proposal is in `record.approval.values`. +- **Invalidate** the queries that show the record, such as its list or thread. +- **Navigate** if the page should change, for example to the new record after a create. + +The hook does not stop double submits. `AutoForm` does that by disabling its submit button while `onSubmit` runs. + +## Requirements and limits + +- **Browser-only.** It uses `use-intl`, `sonner` toasts and `window.location`. VitNode's root providers mount all three. +- **Messages** come from `core.global.content_write`, which the root route always loads, so they follow the reader's language and the plugin needs no labels for errors. +- **One record per hook.** `version` is read on the first render. When the same component switches to another record, remount it with a `key`, such as the record id. +- **One field error per issue.** Messages are generic ("Check this value."). To show your own text for a field rule, validate it in the form schema first, so the request never leaves the browser. + +## Example + +```tsx title="Excerpt: an edit form" +import type { AutoFormOnSubmit } from "@vitnode/core/components/form/auto-form"; + +import { useContentPublicWrite } from "@vitnode/core/tanstack/content"; +import { fetcher } from "@vitnode/core/tanstack/fetcher"; + +const write = useContentPublicWrite({ + fields: ["title", "body"], + latestVersion: async () => { + const res = await fetchEditableTopic(record.id); + + return res.status === 200 ? (await res.json()).version : null; + }, + uniqueField: "title", + version: record.version, +}); + +const onSubmit: AutoFormOnSubmit = async (values, form) => { + const updated = await write.submit( + form, + async expectedVersion => + await fetcher({ + plugin: manifest.id, + method: "patch", + module: "content/topics", + path: "/{id}", + args: { params: { id: record.id }, body: { values, expectedVersion } }, + }), + ); + if (!updated) return; + + form.reset(values); +}; +``` + +The complete page, with loading, approvals and invalidation, is in [Edit form](/docs/content-engine/public-write-edit). diff --git a/apps/web/content/docs/content-engine/public-write-frontend.mdx b/apps/web/content/docs/content-engine/public-write-frontend.mdx index f64b87335e..7a4635bfd0 100644 --- a/apps/web/content/docs/content-engine/public-write-frontend.mdx +++ b/apps/web/content/docs/content-engine/public-write-frontend.mdx @@ -1,57 +1,67 @@ --- -title: Public create form -description: Build a page where members create Content Engine records with one AutoForm, one fetcher call and useContentPublicWrite, which shows server errors for you. +title: Create form +description: Build a page where members write VitNode Content Engine records with AutoForm and useContentPublicWrite, which shows server errors in the form, and send new records to the right page. icon: TextCursorInput implementation: objective: Add a public page where signed-in members create records of an existing content type. decisions: - - Which items do members create? Offer content types from the project's plugins that have `publicWrite.create`; if none has it, point to the Public write guide. + - Which items do members create? Offer content types from the project's plugins that have `publicWrite.create`; if none has it, point to the Member writing guide. - Where does the form live? Recommend a `/new` URL next to the items' own pages. - - Where does the member land after posting? The new record's page, or its edit page while it waits for approval. constraints: - Send `POST /` of `content/{publicApi.path}` through `fetcher` inside `useContentPublicWrite().submit`, with only the fields in `publicWrite.create.fields`. - Build the form with `AutoForm` and keep labels in the plugin's locale files. - - Treat `approval.status === "pending"` as waiting for a moderator, not as published. + - Check `approval.status` and `status` before opening the record's public page, which answers 404 until the record is published, and say "draft" and "waiting for approval" with different messages. related: - - title: Public edit form + - title: Edit form href: /docs/content-engine/public-write-edit - - title: Public write - href: /docs/content-engine/public-write - - title: Public write API + - title: Form helpers + href: /docs/content-engine/public-write-form-api + - title: Write API href: /docs/content-engine/public-write-api --- +import { Tab, Tabs } from "fumadocs-ui/components/tabs"; + import { ImgDocs } from "@/components/fumadocs/img"; -import createForm from "./public-write-create-form.webp"; +import createTaken from "./public-write-create-form.webp"; -This guide builds `/discussions/new`, where members start a discussion. It takes one form and one request. When the server says no, `useContentPublicWrite` from core deals with it: field errors land under their fields, and the rest become translated toasts. +This page builds `/news/new`, where signed-in members write an article. It takes one form and one request. `useContentPublicWrite` from core sends the request and shows every refusal, so the page only decides what happens after a successful save. -You need a content type with [public write](/docs/content-engine/public-write) turned on. +This continues the article from [Member writing](/docs/content-engine/public-write), which turned on `publicWrite` with `title` and `body` in `create.fields`. -### Add the route +### Add TanStack Query to the plugin + +The page refreshes cached lists with `useQueryClient`, so the plugin needs `@tanstack/react-query` as a dependency. Run this from the workspace root: + + -```ts title="plugins/example/src/routes.ts" -page("/discussions/new", { - component: lazy(() => import("./pages/discussion-new-page")), - messages: ["@vitnode/example.discussions"], - requires: "authenticated", -}), +```bash tab="bun" +bun add @tanstack/react-query --cwd plugins/newsroom ``` -`requires: "authenticated"` sends guests to the sign-in page and brings them back afterwards. `messages` loads the labels below. +```bash tab="pnpm" +pnpm --filter newsroom add @tanstack/react-query +``` + +```bash tab="npm" +npm install @tanstack/react-query --workspace newsroom +``` + + -### Write the page +### Write the form -```tsx title="src/pages/discussion-new-page.tsx" -import { useQueryClient } from "@tanstack/react-query"; +Create `src/pages/article-form.tsx`. The [edit form](/docs/content-engine/public-write-edit) reuses it: + +```tsx title="plugins/newsroom/src/pages/article-form.tsx" import { AutoForm, type AutoFormOnSubmit, @@ -59,86 +69,201 @@ import { import { AutoFormEditor } from "@vitnode/core/components/form/fields/editor"; import { AutoFormInput } from "@vitnode/core/components/form/fields/input"; import { - emptyRichTextDocument, isRichTextEmpty, + type RichTextDocument, zodRichTextDocument, } from "@vitnode/core/content"; -import { useContentPublicWrite } from "@vitnode/core/tanstack/content"; -import { fetcher } from "@vitnode/core/tanstack/fetcher"; -import { useHrefNavigate } from "@vitnode/core/tanstack/link"; import { useTranslations } from "use-intl"; import { z } from "zod"; -import { manifest } from "@/manifest.gen"; - -import { invalidateDiscussionLists } from "./discussion-list"; +/** The fields the form renders, so server errors on them show in place. */ +export const ARTICLE_FORM_FIELDS = ["title", "body"] as const; -const formSchema = z.object({ - title: z.string().trim().min(3).max(200).default(""), - body: zodRichTextDocument - .refine(doc => !isRichTextEmpty(doc)) - .default(emptyRichTextDocument()), -}); +export interface ArticleFormValues { + body: RichTextDocument; + title: string; +} -const DiscussionNewPage = () => { - const t = useTranslations("@vitnode/example.discussions"); - const navigate = useHrefNavigate(); - const queryClient = useQueryClient(); - const write = useContentPublicWrite({ - fields: ["title", "body"], - uniqueField: "title", +const createArticleFormSchema = (defaults: ArticleFormValues) => + z.object({ + title: z.string().trim().min(3).max(200).default(defaults.title), + body: zodRichTextDocument + .refine(doc => !isRichTextEmpty(doc)) + .default(defaults.body), }); - const onSubmit: AutoFormOnSubmit = async (values, form) => { - const record = await write.submit(form, async () => - await fetcher({ - plugin: manifest.id, - method: "post", - module: "content/discussions", - path: "/", - args: { body: { values } }, - }), - ); - if (!record) return; +export type ArticleFormSchema = ReturnType; - await invalidateDiscussionLists(queryClient); - await navigate(`/discussions/${record.slug}?created=true`); - }; +export const ArticleForm = ({ + defaultValues, + onSubmit, + submitLabel, +}: { + defaultValues: ArticleFormValues; + onSubmit: AutoFormOnSubmit; + submitLabel: string; +}) => { + const t = useTranslations("newsroom.articles.form"); return ( , + component: props => , }, { id: "body", component: props => ( - + ), }, ]} - formSchema={formSchema} + formSchema={createArticleFormSchema(defaultValues)} onSubmit={onSubmit} - submitButtonProps={{ children: t("new.submit") }} + submitButtonProps={{ children: submitLabel, className: "self-start" }} /> ); }; +``` + +The schema checks the values in the browser first, with the same limits as the definition, so members see mistakes before anything is sent. `format="json"` makes the editor produce the document a [rich text field](/docs/content-engine/rich-text) stores. `AutoForm` disables the submit button while `onSubmit` runs, so a double click never posts twice. + + + + + +### Write the page + +Create `src/pages/article-new-page.tsx`: + +```tsx title="plugins/newsroom/src/pages/article-new-page.tsx" +import type { AutoFormOnSubmit } from "@vitnode/core/components/form/auto-form"; + +import { useQueryClient } from "@tanstack/react-query"; +import { emptyRichTextDocument } from "@vitnode/core/content"; +import { definePluginRoute } from "@vitnode/core/routing"; +import { useContentPublicWrite } from "@vitnode/core/tanstack/content"; +import { fetcher } from "@vitnode/core/tanstack/fetcher"; +import { useHrefNavigate } from "@vitnode/core/tanstack/link"; +import { toast } from "sonner"; +import { useTranslations } from "use-intl"; + +import { manifest } from "@/manifest.gen"; -export default DiscussionNewPage; +import { + ARTICLE_FORM_FIELDS, + ArticleForm, + type ArticleFormSchema, +} from "./article-form"; + +export const route = definePluginRoute({ + head: ({ t }) => ({ + robots: "noindex, nofollow", + title: t("newsroom.articles.new.title"), + }), +}); + +const ArticleNewPage = () => { + const t = useTranslations("newsroom.articles.new"); + const navigate = useHrefNavigate(); + const queryClient = useQueryClient(); + const write = useContentPublicWrite({ + fields: ARTICLE_FORM_FIELDS, + uniqueField: "title", + }); + + const onSubmit: AutoFormOnSubmit = async ( + values, + form, + ) => { + const record = await write.submit( + form, + async () => + await fetcher({ + plugin: manifest.id, + method: "post", + module: "content/news", + path: "/", + args: { body: { values } }, + }), + ); + if (!record) return; + + await queryClient.invalidateQueries({ + queryKey: [manifest.id, "articles"], + }); + + // Waits for a moderator. Its public page answers 404 until approval. + if (record.approval?.status === "pending") { + toast.info(t("pending.title"), { description: t("pending.desc") }); + await navigate(`/news/${record.id}/edit`); + + return; + } + + // A draft nobody has to review. It stays private, so open its edit page. + if (record.status !== "published") { + toast.info(t("draft.title"), { description: t("draft.desc") }); + await navigate(`/news/${record.id}/edit`); + + return; + } + + toast.success(t("success.title"), { description: t("success.desc") }); + await navigate(`/news/${record.slug}`); + }; + + return ( +
+

+ {t("title")} +

+ +
+ ); +}; + +export default ArticleNewPage; ``` -That is the whole page. Here is what each part does: +- **`write.submit`** sends the request and returns the new record, or `null` after showing why the server said no: an issue on a field appears under it, a taken title appears under **Title** (`uniqueField`), and anything else, such as an expired session, is a toast. The [Form helpers](/docs/content-engine/public-write-form-api#how-answers-are-handled) reference lists every case. +- **The owner** comes from the server; `values` never includes `author`. +- **`invalidateQueries`** expires the cached [article list](/docs/content-engine/public-lists), whose query key starts with `[manifest.id, "articles"]`, so the new article shows there at once. +- **Before navigating**, the page reads two fields of the answer, because a new article can end in three states. Only a published one has a public page; the other two answer `404` there, so the author goes to the edit page with a message that says what happened: + + | `status` | `approval` | What the author hears | + | --- | --- | --- | + | `"published"` | `null` | **Article published**, on the article's page | + | `"draft"` | `null` | **Draft saved**. It is private and nobody was asked to review it | + | `"draft"` | `{ status: "pending" }` | **Sent for approval**. A moderator reviews it first | + + Newsroom articles use `create.status: "published"` and have no container, so members always get the first row. `create.status: "draft"` gives the second. The third only happens for a content type filed in a container whose [publishing rules](/docs/content-engine/container-policies) hold new posts for approval; see [Drafts and approval](/docs/content-engine/approvals#drafts-and-approval). + +
+ + + +### Add the route -- **`formSchema`** checks the values in the browser first. Copy the server's limits, such as `minLength: 3`, so members see mistakes before they submit. -- **`write.submit`** sends the request and returns the new record, or `null` when the server said no. It already showed the reason: a server issue on `title` appears under the title, a taken title too (`uniqueField`), and anything else, such as an expired session, is a toast. You write no error handling. -- **The owner** is set by the server. `values` never includes the author. -- **Double posts** cannot happen, because `AutoForm` disables the button while `onSubmit` runs. -- **`invalidateDiscussionLists`** refreshes the cached [discussion list](/docs/content-engine/public-lists), so the new discussion shows up there at once. -- **`format="json"`** makes the editor produce the document a [rich text field](/docs/content-engine/rich-text) stores. +Edit `src/routes.ts` and add the page before `/news/:slug`. This is an excerpt: + +```ts title="plugins/newsroom/src/routes.ts" + // [!code ++:5] + page("/news/new", { + component: lazy(() => import("./pages/article-new-page")), + messages: ["newsroom.articles"], + requires: "authenticated", + }), + + page("/news/:slug", { +``` -This page fits the discussion type from [Public write](/docs/content-engine/public-write#define-the-content-type). The example plugin's own discussions also live in boards, so its page in `plugins/example/src/pages/` adds a board select and sends `board` in `values`; any [container field](/docs/content-engine/containers) in `create.fields` works the same way. +`requires: "authenticated"` sends guests to the sign-in page and brings them back afterwards. The static `/news/new` wins over `/news/:slug`, so an article whose slug is `new` would be hidden behind the form; pick another URL, such as `/news/write/article`, if that can happen in your app. @@ -146,14 +271,25 @@ This page fits the discussion type from [Public write](/docs/content-engine/publ ### Add the labels -```json title="plugins/example/src/locales/en.json" +Add these keys under `newsroom.articles` in `src/locales/en.json`: + +```json title="plugins/newsroom/src/locales/en.json" { - "@vitnode/example": { - "discussions": { - "new": { "submit": "Post discussion" }, - "form": { - "title": { "label": "Title" }, - "body": { "label": "Message" } + "newsroom": { + "articles": { + "form": { "title": "Title", "body": "Article" }, + "new": { + "title": "Write an article", + "submit": "Publish article", + "success": { "title": "Article published", "desc": "Readers can see it now." }, + "draft": { + "title": "Draft saved", + "desc": "It is not public yet, so only you and the site's staff can see it." + }, + "pending": { + "title": "Sent for approval", + "desc": "A moderator reviews it before readers can see it." + } } } } @@ -166,33 +302,36 @@ Error messages come from core and follow the reader's language, so the plugin ne
-## When a post waits for a moderator - -In a container with [publishing policies](/docs/content-engine/container-policies) that ask for approval, a create still succeeds but the record is not public yet. `record.approval?.status` is `"pending"` and its page answers `404`. Send the author somewhere that says so: +## Check the result -```ts -if (record.approval?.status === "pending") { - await navigate(`/discussions/${record.id}/edit`); +Sign in as a member and open `http://localhost:3000/news/new`: - return; -} -``` +1. Click **Publish article** with an empty form. Both fields show an error, and nothing is sent. +2. Enter the title `Welcome to the newsroom`, which an article already has, and a message. **This is already taken. Try something else.** appears under **Title**, and your text stays. +3. Change the title and click **Publish article**. **Article published** appears, you land on `/news/{slug}`, and the article tops `/news`. +4. Open `/news/new` signed out. The app sends you to the sign-in page. +5. Optional: set `create.status` to `"draft"` in `src/content/article.ts` and post again. **Draft saved** appears, you land on the edit page, and the article is missing from `/news` until staff publish it. Set it back to `"published"` afterwards. -The edit page shows the waiting notice, as [Approvals](/docs/content-engine/approvals#show-it-on-your-pages) explains. + ## Captcha -With `create.captcha: true`, pass the captcha config to the form and forward the token it hands to `onSubmit`: +With `create.captcha: true` in `publicWrite`, every create needs a [captcha](/docs/dev/captcha) token. Give `ArticleForm` a `captcha` prop that it passes on to `AutoForm`, then forward the token `AutoForm` hands to `onSubmit`: ```tsx const { data: config } = useMiddlewareConfigQuery(); // from @vitnode/core/tanstack/auth -const onSubmit: AutoFormOnSubmit = async (values, form, { captchaToken }) => { +const onSubmit: AutoFormOnSubmit = async (values, form, { captchaToken }) => { const record = await write.submit(form, async () => await fetcher({ plugin: manifest.id, method: "post", - module: "content/discussions", + module: "content/news", path: "/", captchaToken, args: { body: { values } }, @@ -200,20 +339,8 @@ const onSubmit: AutoFormOnSubmit = async (values, form, { cap ); }; -; +// In ArticleForm: +; ``` -## Check the result - -Sign in as a member and open `/discussions/new`: - -1. Leave the message empty and submit. The form says so without sending anything. -2. Use the title of an existing discussion. **This is already taken** appears under the title. -3. Write a new title and a message, then click **Post discussion**. You land on the new discussion, and it tops `/discussions`. - - +Next, let authors fix their articles with an [Edit form](/docs/content-engine/public-write-edit). diff --git a/apps/web/content/docs/content-engine/public-write-moderator-permission.webp b/apps/web/content/docs/content-engine/public-write-moderator-permission.webp deleted file mode 100644 index 88a677a4a3..0000000000 Binary files a/apps/web/content/docs/content-engine/public-write-moderator-permission.webp and /dev/null differ diff --git a/apps/web/content/docs/content-engine/public-write.mdx b/apps/web/content/docs/content-engine/public-write.mdx index c2429b62cc..1dd4f52c00 100644 --- a/apps/web/content/docs/content-engine/public-write.mdx +++ b/apps/web/content/docs/content-engine/public-write.mdx @@ -1,173 +1,85 @@ --- -title: Public write -description: Let signed-in members create and edit Content Engine records from the public site with publicWrite, with server-stamped ownership, moderator permissions, strict field allowlists and rate limiting. +title: Member writing +description: Let signed-in members create and edit VitNode Content Engine records from the public site with publicWrite, with server-set ownership, version checks, moderator permissions and strict field allowlists. icon: PenLine implementation: objective: Let signed-in members create and edit records of an existing content type from the public site. decisions: - - Which plugin owns the content? Offer plugins in the project that define Content Engine content types. - - Which items do members write? Offer content types from the chosen plugin by their user-facing names. The type needs a public API and one user field that holds the owner; if either is missing, say so and ask whether to add it. - - What can members fill in when they create a record, and what can they change later? Offer the type's top-level fields. Files, users, groups, repeatables and widget fields cannot be written by members. - - Does a new record go live at once or start as a draft? Published or draft; draft is the default. - - Can authors edit their own records afterwards, and should creating a record ask for a captcha? + - Which items can members write? Offer content types from the project's plugins that have `publicApi`. + - Which fields can members fill in? Offer the type's writable fields; the owner field is always set by the server. + - Should new records go live at once or start as drafts? + - May authors edit their own records, or only moderators? constraints: - - Use the content type `publicWrite` option with `ownership.field`. The server stamps the owner, so never accept it from the payload. - - Mount the generated routes through the plugin's `buildContentPublicModule`. Write no create or update route by hand, and add no public publish, hide or delete route. - - Label the generated `can_edit` moderator permission in the plugin's locale, then build and migrate with the project's scripts. + - 'Add a `field.user()` owner, `editorial: true` for version checks, and `publicWrite: { ownership, create, update }`.' + - Never accept the owner from the client; never decide permissions in the browser. related: - - title: Public create form + - title: Create form href: /docs/content-engine/public-write-frontend - - title: Public edit form + - title: Edit form href: /docs/content-engine/public-write-edit - - title: Public write API + - title: Write API href: /docs/content-engine/public-write-api - - title: Public API - href: /docs/content-engine/public-api --- import { Tab, Tabs } from "fumadocs-ui/components/tabs"; -import { ImgDocs } from "@/components/fumadocs/img"; -import adminList from "./public-write-admin-list.webp"; -import moderatorPermission from "./public-write-moderator-permission.webp"; +`publicWrite` lets signed-in members create records from the site and edit their own, while moderators can edit anyone's. The server sets the owner, checks every field against an allowlist and authorizes every edit; your pages only send forms. -`publicWrite` lets any signed-in member create records of a content type, and edit their own, through generated API routes on the public site. Staff keep the full AdminCP. Use it for discussions, reviews, listings or anything else your members write themselves. +This continues the article from [Content page](/docs/content-engine/public-pages). So far only staff write articles, in the AdminCP. After this page, members write them too, at the same public API. -This guide follows `example.discussion` from the example plugin: members start discussions at `/api/@vitnode/example/content/discussions`, edit their own, and moderators can edit anyone's. +## Staff content and member content -## What public writing is +The AdminCP and public writing are two doors into the same table: -The AdminCP and public writing are two doors into the same table. They differ in who may walk through them and what they may carry: - -| | AdminCP routes | Public write routes | -| --------------------- | ----------------------------------------------- | ---------------------------------------------------------------- | -| Who | Staff with the content type's admin permissions | Any signed-in user creates; the owner or a moderator edits | -| Session | AdminCP session | The user's normal session on the public site | -| Fields | Every field | Only the fields listed in `publicWrite` | -| Publish, hide, delete | Yes | No. The server decides the status, and there is no public delete | -| Path | `/api/{pluginId}/admin/content/{type}` | `/api/{pluginId}/content/{publicApi.path}` | - -## Enable public writing - -You need a content type with [publication and a public API](/docs/content-engine/public-api), because the write routes live beside the public read routes and a member has to be able to find what they wrote. +| | AdminCP | Public write | +| --- | --- | --- | +| Who | Staff with the content type's admin permissions | Any signed-in member creates; the owner or a moderator edits | +| Session | AdminCP session | The member's normal session on the site | +| Fields | Every field | Only the fields listed in `publicWrite` | +| Publish, hide, delete | Yes | No. The server decides the status, and there is no public delete | +| Routes | `/api/newsroom/admin/content/article` | `/api/newsroom/content/news` | -### Define the content type +### Add an owner, versions and the write options -Create `src/content/discussion.ts`. The `publicWrite` block is the new part: - -```ts title="plugins/example/src/content/discussion.ts" -import { defineContentType, field } from "@vitnode/core/content"; - -export const discussionContentType = defineContentType({ - id: "example.discussion", - tableName: "example_discussions", +Edit `src/content/article.ts`. Add an `author` field, `editorial` and `publicWrite`: +```ts title="plugins/newsroom/src/content/article.ts" fields: { title: field.text({ required: true, minLength: 3, maxLength: 200 }), slug: field.slug({ source: "title" }), body: field.richText({ required: true }), - author: field.user(), + author: field.user(), // [!code ++] }, publication: true, - visibility: true, - editorial: { revisions: { retention: 20 } }, publicApi: { - path: "discussions", + path: "news", fields: ["id", "title", "slug", "body", "publishedAt"], }, + delivery: true, + + editorial: true, // [!code ++] + // [!code ++:5] publicWrite: { ownership: { field: "author" }, create: { fields: ["title", "body"], status: "published" }, update: { fields: ["title", "body"] }, }, - - admin: { - path: "example/discussions", - titleField: "title", - }, -}); ``` -- `ownership.field` names a single `field.user()`. The server fills it with the signed-in user on create. -- `create.fields` and `update.fields` are allowlists with no wildcard. A field you add later stays private until you list it. `slug` is derived from `title` on the server, so you list `title` instead. -- `create.status` is what a new record starts as. It defaults to `"draft"`; discussions go live straight away. -- `update.own` defaults to `true`, so owners can edit their own records. - -Every option and rule is in the [public write reference](/docs/content-engine/public-write-api#publicwrite-options). +- **`ownership.field`** names the user field the server fills with the signed-in member on create. It can never be in `create.fields` or `update.fields`. +- **`create`** adds `POST /` and lists what a member may send. `status: "published"` makes a new article live at once; the default, `"draft"`, keeps it private until staff publish it. +- **`update`** adds `GET /{id}/edit` and `PATCH /{id}`, and a `can_edit` moderator permission. +- **`editorial`** adds a `version` number to every article, so an edit form can tell when someone else saved first. It also keeps a [revision history](/docs/content-engine/editorial). -The example plugin's finished definition adds more on top: a `board` [container field](#containers) members pick on create, `people: ["author"]` and search options for the [discussion list](/docs/content-engine/public-lists), and `reports: true` for the [Report button](/docs/content-engine/reports). - - - - - -### Create the model - -Create `src/database/discussions.ts` and export the table so it reaches a migration: - -```ts title="plugins/example/src/database/discussions.ts" -import { createContentModel } from "@vitnode/core/content/server"; - -import { discussionContentType } from "@/content/discussion"; - -export const discussionContent = createContentModel(discussionContentType); - -export const example_discussions = discussionContent.table; -``` - - - - - -### Register the public module - -Add the model to `buildContentPublicModule` in your API plugin. The module mounts the read routes and, because of `publicWrite`, the write routes: - -```ts title="plugins/example/src/config.api.ts" -import { discussionContent } from "@/database/discussions"; // [!code ++] - -export const exampleApiPlugin = () => - buildApiPlugin({ - manifest, - modules: [ - adminModule, - buildContentPublicModule({ - contentTypes: [articleContent, discussionContent], // [!code highlight] - }), - ], - }); -``` - -The example plugin also adds `discussionContent` to its `admin` module and its AdminCP navigation, so staff can manage every discussion. That part is the same as in [Create a content type](/docs/content-engine/create-a-content-type#add-the-staff-api-routes). - - - - - -### Label the permissions - -A content type with `publicWrite.update` gets a `can_edit` **moderator** permission next to its usual admin permissions. Both share one label key: - -```json title="plugins/example/src/locales/en.json" -{ - "@vitnode/example:discussion": "Discussions", - "@vitnode/example:discussion:can_view": "View discussions", - "@vitnode/example:discussion:can_create": "Create discussions", - "@vitnode/example:discussion:can_edit": "Edit discussions", - "@vitnode/example:discussion:can_delete": "Delete discussions", - "@vitnode/example:discussion:can_publish": "Publish and unpublish discussions", - "@vitnode/example:discussion:can_restore": "Restore an earlier version of a discussion", - "@vitnode/example:discussion:can_hide": "Hide and unhide discussions" -} -``` +The moderator permission shares the `newsroom:article:can_edit` label you added in [First content type](/docs/content-engine/create-a-content-type#add-the-labels), so there is nothing new to translate. @@ -175,7 +87,7 @@ A content type with `publicWrite.update` gets a `can_edit` **moderator** permiss ### Migrate -Run this from the workspace root while `pnpm dev` is running: +Run this from the workspace root while `pnpm dev` is running, or after `pnpm build:plugins`: @@ -193,43 +105,65 @@ npm run db:migrate -The migration creates `example_discussions` with an `author` column that references `core_users`. [Database](/docs/content-engine/database) covers migrations in detail. +The migration adds two columns. Articles staff already wrote keep an empty `author`, so only moderators can edit them from the site: + +```sql title="apps/api/migrations/_/migration.sql (excerpt)" +ALTER TABLE "newsroom_articles" ADD COLUMN "version" integer DEFAULT 1 NOT NULL; +ALTER TABLE "newsroom_articles" ADD COLUMN "author" integer; +``` -## Ownership +## Check the result -Every publicly written record has exactly one owner, and only the server sets it. On create, the routes take the user from the session and write it into `ownership.field` after validating the payload. The field can never appear in `create.fields` or `update.fields`, and a payload that sends it gets the `not_writable` issue above. +Sign in on the site, copy the value of your `vitnode_auth` cookie from the browser's developer tools, and create an article: -The owner never changes through the public routes, not even when a moderator edits the record. If the owner's account is deleted, the field becomes empty and nobody owns the record, so only moderators can edit it. +```bash +curl -i -X POST http://localhost:3000/api/newsroom/content/news \ + -b "vitnode_auth=" \ + -H "Content-Type: application/json" \ + -d '{"values":{"title":"Our community garden","body":{"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"We planted tomatoes this week."}]}]}}}' +``` -That is why `ownership` is required and not configurable. A client that could choose the owner could write as somebody else or take over their records. +```json title="201" +{ + "approval": null, + "id": 18, + "publishedAt": "2026-10-23T19:22:31.230Z", + "slug": "our-community-garden", + "status": "published", + "values": { + "title": "Our community garden", + "body": { "type": "doc", "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": "We planted tomatoes this week." }] }] } + }, + "version": 2 +} +``` -## Permissions +`values` holds exactly the writable fields, and `version` is `2` because the create and the publish each count. Now try what the server refuses: -Creating needs a signed-in user and nothing else. Editing is allowed for two kinds of people: +| Request | Answer | +| --- | --- | +| The same `POST` without the cookie | `401` with `"code": "CONTENT_UNAUTHENTICATED"` | +| `"title": "Hi"` and `"author": 1` in `values` | `400` with a `too_small` issue on `title` and a `not_writable` issue on `author` | +| The title of an article that exists | `409` with `"code": "CONTENT_UNIQUE_CONFLICT"`, because its slug is taken | -- **The owner**, while `update.own` is `true` (the default) and the record is not [hidden](/docs/content-engine/content-visibility). A record a moderator hid is the moderator's to restore, not the author's to keep editing. Set `update.own: false` to let only moderators edit. -- **Moderators** who hold the content type's `can_edit` moderator permission, directly or through a role. In a container collection with [publishing policies](/docs/content-engine/container-policies), `can_edit` is [scoped to containers](/docs/dev/moderation/scoped-permissions#container-scoped-permissions), and the policy can also limit how long the owner may edit. +The [Write API](/docs/content-engine/public-write-api) lists every route, body and error. Next, give members a form with [Create form](/docs/content-engine/public-write-frontend). -To make someone a discussion moderator: +## Ownership + +Every member-written record has exactly one owner, and only the server sets it. On create, the routes take the user from the session and write it into `ownership.field` after validating the payload. The owner never changes through the public routes, not even when a moderator edits the record. If the owner's account is deleted, the field becomes empty and only moderators can edit the record. -1. Open **AdminCP → Staff → Moderators** and click **Add Moderator**. -2. Pick a **Role** or a **User** and click **Add member**. The permission editor opens. -3. Choose **Restricted**, turn on **Edit discussions** under the example plugin, and click **Save changes**. +## Who may edit - +Creating needs a signed-in member. Editing is allowed for: -A moderator entry set to **Unrestricted**, and every root role, holds all moderator permissions, including this one. +- **The owner**, while `update.own` is `true` (the default) and the record is not [hidden](/docs/content-engine/content-visibility). Set `update.own: false` to let only moderators edit. +- **Moderators** who hold the content type's `can_edit` moderator permission. Give it in **AdminCP → Staff → Moderators**: add a role or a user, choose **Restricted**, and turn on **Edit articles** under Newsroom. A root role and an **Unrestricted** moderator entry hold every moderator permission. -Being staff is not enough on its own. The admin `can_edit` permission only opens the AdminCP routes, and an AdminCP session never adds powers on the public site. The public routes always act as the user's normal session. Moderators edit with the same forms and routes as members, so you build one edit page instead of two. +The admin `can_edit` permission only opens the AdminCP routes, and an AdminCP session never adds powers on the public site. Moderators edit with the same forms as members. [Permissions](/docs/content-engine/public-permissions) shows how a page asks who may edit. ## Security @@ -256,12 +190,12 @@ export const vitNodeApiConfig = buildApiConfig({ }); ``` -## Events, search and revisions +## Side effects Public writes trigger the same side effects as AdminCP writes, so nothing new needs wiring: - [Events](/docs/content-engine/events): `created` on create, then `published` when the record goes live, and `updated` with `changedFields` on an edit that changed something. There are no separate public-write events. A record waiting for [approval](/docs/content-engine/approvals) emits `published` or `updated` when a moderator approves it, plus the `content.approval.*` events. -- [Search](/docs/content-engine/search) is synced after the transaction commits, so a discussion published on create is searchable straight away. +- The Content Engine syncs [Search](/docs/content-engine/search) after the transaction commits, so an article published on create is searchable straight away. - On an [editorial](/docs/content-engine/editorial#revisions) content type, each create and edit stores a revision attributed to the signed-in user, and staff can restore it from the AdminCP. ## Containers @@ -270,84 +204,4 @@ A content type organized into [containers](/docs/content-engine/containers), suc ### Container policies -Whether a new record starts as a draft or goes live is decided on the server, never by the payload. Without container policies, it is `create.status`. When the record's container collection has [publishing policies](/docs/content-engine/container-policies), the container decides instead: who may create, whether new records and edits wait for a moderator, and how long the owner may edit. A new record that waits answers with `status: "draft"`, and every response carries an `approval` object, described in [Approvals](/docs/content-engine/approvals). - -Next, build the pages members use: a [create form](/docs/content-engine/public-write-frontend) and an [edit form](/docs/content-engine/public-write-edit) with its Edit button. - -## Check the result - -Sign in on the site, copy the value of your `vitnode_auth` cookie from the browser's developer tools, and create a discussion. Against the example plugin itself, whose discussions live in boards, also send `"board": 1` in `values`: - -```bash -curl -i -X POST http://localhost:3000/api/@vitnode/example/content/discussions \ - -b "vitnode_auth=" \ - -H "Content-Type: application/json" \ - -d '{"values":{"title":"How do I add a widget zone?","body":{"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"I want one under my article."}]}]}}}' -``` - -The API answers `201` with the new record. `version` is `2` because the create and the publish are each a version: - -```json title="201" -{ - "id": 1, - "slug": "how-do-i-add-a-widget-zone", - "status": "published", - "publishedAt": "2026-10-13T09:12:44.512Z", - "version": 2, - "values": { - "title": "How do I add a widget zone?", - "body": { - "type": "doc", - "content": [ - { - "type": "paragraph", - "content": [ - { "type": "text", "text": "I want one under my article." } - ] - } - ] - } - } -} -``` - -Now try to claim someone else's name. Send a short title and an `author`: - -```bash -curl -i -X POST http://localhost:3000/api/@vitnode/example/content/discussions \ - -b "vitnode_auth=" \ - -H "Content-Type: application/json" \ - -d '{"values":{"title":"Hi","author":2,"body":{"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"Nice try."}]}]}}}' -``` - -Nothing is written, and each problem names its field: - -```json title="400" -{ - "code": "CONTENT_INVALID_INPUT", - "contentTypeId": "example.discussion", - "issues": [ - { - "code": "too_small", - "field": "title", - "message": "Too small: expected string to have >=3 characters" - }, - { - "code": "not_writable", - "field": "author", - "message": "This field cannot be written." - } - ] -} -``` - -Without the cookie, every write route answers `401` with `"code": "CONTENT_UNAUTHENTICATED"` before it reads the body. Validation failures are `400`, the status every VitNode input error uses, not `422`. - -Members' discussions show up in **AdminCP → Example → Discussions** like any other record, with their author filled in by the server: - - +The server decides whether a new record starts as a draft or goes live. The payload never does. Without container policies, `create.status` decides. When the record's container collection has [publishing policies](/docs/content-engine/container-policies), the container decides instead: who may create, whether new records and edits wait for a moderator, and how long the owner may edit. A new record that waits answers with `status: "draft"` and a pending `approval` object, described in [Approvals](/docs/content-engine/approvals). Without container policies, `create.status: "draft"` stores a plain draft, `status: "draft"` with `approval: null`, and no moderator is asked to review it. diff --git a/apps/web/content/docs/content-engine/publication.mdx b/apps/web/content/docs/content-engine/publication.mdx index 996c536270..77821fb8a3 100644 --- a/apps/web/content/docs/content-engine/publication.mdx +++ b/apps/web/content/docs/content-engine/publication.mdx @@ -1,5 +1,5 @@ --- -title: Publication +title: Drafts & publishing description: Give a VitNode Content Engine content type drafts and a Publish button with the publication option, so nothing reaches readers until an editor publishes it. icon: Send implementation: @@ -13,8 +13,8 @@ implementation: related: - title: Public API href: /docs/content-engine/public-api - - title: Editorial - href: /docs/content-engine/editorial + - title: Scheduling + href: /docs/content-engine/editorial-scheduling --- import { Tab, Tabs } from "fumadocs-ui/components/tabs"; @@ -23,39 +23,64 @@ import { ImgDocs } from "@/components/fumadocs/img"; import publishConfirm from "./public-api-publish-confirm.webp"; import publishRow from "./public-api-publish-row.webp"; -`publication` gives every record two states: **Draft** and **Published**. Drafts stay private, and only staff with the publish permission can make a record public. Turn it on before anything else on the public side, because the [public API](/docs/content-engine/public-api) only ever returns published records. +`publication` gives every record two states, **Draft** and **Published**. Drafts stay private, and only staff with the publish permission make a record public. Turn it on before anything reaches readers, because the [public API](/docs/content-engine/public-api) only ever returns published records. + +This continues the `newsroom.article` type from [First content type](/docs/content-engine/create-a-content-type). -### Turn it on +### Turn on publication -Add one line to the definition: +Edit `src/content/article.ts`. Add `publication` and show the state in the list: -```ts title="plugins/example/src/content/article.ts" +```ts title="plugins/newsroom/src/content/article.ts" export const articleContentType = defineContentType({ - id: "example.article", - tableName: "example_articles", + id: "newsroom.article", + tableName: "newsroom_articles", + fields: { - title: field.text({ required: true }), + title: field.text({ required: true, minLength: 3, maxLength: 200 }), slug: field.slug({ source: "title" }), + body: field.richText({ required: true }), }, + publication: true, // [!code ++] + + admin: { + path: "newsroom/articles", + titleField: "title", + list: { + columns: ["status", "title", "slug", "updatedAt"], // [!code highlight] + orderableFields: ["title"], + }, + }, }); ``` -This adds a `status` column (`draft` or `published`), a `publishedAt` date, a `can_publish` permission and **Publish** and **Unpublish** buttons in the AdminCP. +This adds a `status` column (`draft` or `published`), a `publishedAt` date, a `can_publish` staff permission, and **Publish** and **Unpublish** actions in the AdminCP. -### Name the permission +### Label the new permission and column + +Add two keys to `src/locales/en.json`. This excerpt shows only the new keys, in their place: -```json title="plugins/example/src/locales/en.json" +```json title="plugins/newsroom/src/locales/en.json" { - "@vitnode/example:article:can_publish": "Publish and unpublish articles" + "newsroom": { + "content": { + "article": { + "fields": { + "status": "Status" + } + } + } + }, + "newsroom:article:can_publish": "Publish and unpublish articles" } ``` @@ -65,7 +90,7 @@ This adds a `status` column (`draft` or `published`), a `publishedAt` date, a `c ### Migrate -Run this from the workspace root while `pnpm dev` is running: +Run this from the workspace root while `pnpm dev` is running, or after `pnpm build:plugins`: @@ -83,38 +108,46 @@ npm run db:migrate -Existing rows become drafts. `pnpm dev` picks up the new option by itself. +The migration adds `status`, with `draft` as its default, and `publishedAt`. Both articles you already created are now drafts. - - -## What a reader sees - -| The record is | The public API answers | -| -------------------------- | --------------------------------- | -| A draft | `404`, like a slug that never existed | -| Published with a future `publishedAt` | `404` until that moment | -| Published | The record | - -The API never says whether a draft exists. To publish later on a schedule, or to share a draft with someone before it goes live, add [`editorial`](/docs/content-engine/editorial). + -## Check the result +### Publish an article -Open **AdminCP → Example → Articles**. New articles show a **Draft** badge. Click the paper plane **Publish** button at the end of a row: +Open **AdminCP → Newsroom → Articles**. Both rows show **Draft**. Click the paper plane button in the `Welcome to the newsroom` row: -Confirm with **Yes, publish it**, and the badge turns into **Published**. The create dialog also gets **Save as draft** and **Publish** buttons, and [bulk actions](/docs/content-engine/admincp#bulk-actions) publish many rows at once. +Confirm with **Yes, publish it**: + +Leave `Release notes for October` as a draft. The next tutorial checks that readers cannot see it. + + + + + +## Check the result + +The list shows **Published** for one article and **Draft** for the other, and a **Status** filter appears above it. The create dialog now has **Save as draft** and **Publish** buttons, and [bulk actions](/docs/content-engine/admincp#bulk-actions) publish many rows at once. + +| The record is | Readers get | +| --- | --- | +| A draft | Nothing. The public API answers `404`, the same as a slug that never existed | +| Published with a future `publishedAt` | `404` until that moment | +| Published | The record | + +To publish on a schedule, or share a draft before it goes live, see [Scheduling](/docs/content-engine/editorial-scheduling) and [Previews](/docs/content-engine/editorial-previews). + +Next, let readers fetch published articles with the [Public API](/docs/content-engine/public-api). diff --git a/apps/web/content/docs/content-engine/reference.mdx b/apps/web/content/docs/content-engine/reference.mdx index 6e9345e4a9..2baca75c4b 100644 --- a/apps/web/content/docs/content-engine/reference.mdx +++ b/apps/web/content/docs/content-engine/reference.mdx @@ -8,14 +8,14 @@ icon: BookOpen ## Minimal example -This is `example.article` from the example plugin, trimmed to the options most content types start with: +This is `newsroom.story` from the newsroom plugin, trimmed to the options most content types start with: -```ts title="plugins/example/src/content/article.ts" +```ts title="plugins/newsroom/src/content/story.ts" import { defineContentType, field } from '@vitnode/core/content' -export const articleContentType = defineContentType({ - id: 'example.article', - tableName: 'example_articles', +export const storyContentType = defineContentType({ + id: 'newsroom.story', + tableName: 'newsroom_stories', fields: { title: field.text({ required: true, minLength: 3, maxLength: 200 }), slug: field.slug({ source: 'title' }), @@ -23,11 +23,11 @@ export const articleContentType = defineContentType({ }, publication: true, publicApi: { - path: 'articles', + path: 'stories', fields: ['title', 'slug', 'excerpt', 'publishedAt'], }, admin: { - path: 'example/articles', + path: 'newsroom/stories', titleField: 'title', }, }) @@ -43,7 +43,7 @@ Validation happens in two places, and both stop the app from starting: Every message starts with `[Content Engine] :` and names the option at fault: ```txt -[Content Engine] example.article: publicApi needs `publication: true`. A public API without a draft state would put every row on the internet the moment it is created. +[Content Engine] newsroom.story: publicApi needs `publication: true`. A public API without a draft state would put every row on the internet the moment it is created. ``` Many of these rules are type errors too, so your editor often complains before the app does. @@ -52,8 +52,8 @@ Many of these rules are type errors too, so your editor often complains before t | Name | Type | Required | Default | Description | | -------------- | --------------------------------------- | -------- | ---------- | --------------------------------------------------------------------------------- | -| `id` | `string` | Yes | | `plugin.entity`, such as `example.article`. See [Names](#names) | -| `tableName` | `string` | Yes | | Postgres table name, such as `example_articles` | +| `id` | `string` | Yes | | `plugin.entity`, such as `newsroom.story`. See [Names](#names) | +| `tableName` | `string` | Yes | | Postgres table name, such as `newsroom_stories` | | `fields` | `Record` | Yes | | At least one field built with a [`field.*` helper](/docs/content-engine/fields) | | `idStrategy` | `"serial"` \| `"uuid"` \| `"bigint"` | No | `"serial"` | Type of the primary key. See [`idStrategy`](#idstrategy) | | `admin` | [`admin`](#admin) | No | `{}` | AdminCP path, list, form and title | @@ -86,8 +86,8 @@ A feature block is on when it is present. `publicApi`, `publicWrite`, `localizat Two names are derived from the id: -- The **entity key** is the id without its first segment, with further dots turned into `_`. `example.article` becomes `article`. Strings live under `{pluginId}.content.{entityKey}`, for example `@vitnode/example.content.article`. -- The **permission module** is the entity key with anything outside `a-z0-9` turned into `_`, so `example.localized-article` becomes `localized_article`. Permissions are named `@vitnode/example:article:can_view`. Override it with `admin.permissionModule`. +- The **entity key** is the id without its first segment, with further dots turned into `_`. `newsroom.story` becomes `story`. Strings live under `{pluginId}.content.{entityKey}`, for example `newsroom.content.story`. +- The **permission module** is the entity key with anything outside `a-z0-9` turned into `_`, so an id such as `newsroom.breaking-story` gets `breaking_story`. Permissions are named `newsroom:story:can_view`. Override it with `admin.permissionModule`. Every content type gets `can_view`, `can_create`, `can_edit` and `can_delete`. `publication` adds `can_publish`, `editorial` adds `can_restore` and `visibility` adds `can_hide`. Duplicating needs `can_create` and `can_view`, so `duplication` adds no permission. @@ -97,7 +97,7 @@ Controls the generated AdminCP screens. Guide: [Customize the AdminCP](/docs/con | Name | Type | Default | Description | | ----------------------- | -------------------------------------- | -------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `path` | `string` | The id with dots as slashes (`example/article`) | Screen URL under `/admin/content/`. Lowercase segments of letters, digits and dashes, starting with a letter, joined by `/`. The last segment cannot be `create` or `edit` | +| `path` | `string` | The id with dots as slashes (`newsroom/story`) | Screen URL under `/admin/content/`. Lowercase segments of letters, digits and dashes, starting with a letter, joined by `/`. The last segment cannot be `create` or `edit` | | `permissionModule` | `string` | Derived from the id, see [Names](#names) | Middle part of the permission names. snake_case | | `titleField` | field name \| `null` | First shared text or textarea field, then first localized one | Names a record in headings, pickers and toasts. A localized field is shown in the reader's language. `null` means no title | | `colorField` | field name \| `null` | `null` | A shared field holding a color, shown as a swatch beside the title | @@ -136,7 +136,7 @@ admin: { Publication on its own never makes anything public. Only [`publicApi`](#publicapi) does. Guide: [Publish content with a public API](/docs/content-engine/public-api). -```ts title="plugins/example/src/content/article.ts" +```ts title="plugins/newsroom/src/content/story.ts" publication: true, ``` @@ -165,14 +165,14 @@ What `fields` accepts: `people` is the explicit opt-in for a user. Each named field comes back under its own name as `{ id, name, nameCode, avatarColor, avatarUrl, role: { color, prefix } }`, or `null` when nobody is set or the account was deleted. A `multiple: true` field comes back as a list of those summaries in the order it stores them, skipping deleted accounts - handy for co-authors. The summaries for a whole page are loaded in one query. `people` refuses a field that is not a `field.user()`, a localized user field, and a name listed twice. -`listFields` keeps a public list light: leave a long `body` or a gallery to the detail route, and the list neither selects nor loads them. The list route's OpenAPI schema and the `ContentPublicListRow` type carry exactly these fields, so a field you left out is a compile error rather than a surprise `undefined`. `people` and, on a localized content type, `locale` are always included. [Public lists](/docs/content-engine/public-lists) shows `listFields` on the blog. +`listFields` keeps a public list light: leave a long `body` or a gallery to the detail route, and the list neither selects nor loads them. The list route's OpenAPI schema and the `ContentPublicListRow` type carry exactly these fields, so a field you left out is a compile error rather than a surprise `undefined`. `people` and, on a localized content type, `locale` are always included. [Content list](/docs/content-engine/public-lists) shows `listFields` on the blog. `searchableFields`, `filterableFields` and `orderableFields` must all be in `fields`. Repeatable leaves fit none of them. `orderableFields` also refuses file fields, to-many relations and localized fields. -```ts title="plugins/example/src/content/article.ts" +```ts title="plugins/newsroom/src/content/story.ts" publicApi: { - path: "articles", - fields: ["title", "slug", "excerpt", "featured", "category", "noIndex", "publishedAt"], + path: "stories", + fields: ["title", "slug", "excerpt", "featured", "category", "animation", "gallery", "noIndex", "publishedAt"], searchableFields: ["title", "excerpt"], orderableFields: ["publishedAt", "title"], filterableFields: ["category", "featured"], @@ -181,21 +181,21 @@ publicApi: { ## `publicWrite` -Adds create and edit routes for signed-in users beside the `publicApi` routes, with an owner stamped by the server and a `can_edit` moderator permission. Guide: [Public write](/docs/content-engine/public-write). Every option, route and error: [Public write API](/docs/content-engine/public-write-api). +Adds create and edit routes for signed-in users beside the `publicApi` routes, with an owner stamped by the server and a `can_edit` moderator permission. Guide: [Member writing](/docs/content-engine/public-write). Every option, route and error: [Write API](/docs/content-engine/public-write-api). -```ts title="plugins/example/src/content/discussion.ts" +```ts title="plugins/newsroom/src/content/letter.ts" publicWrite: { ownership: { field: "author" }, - create: { fields: ["title", "body", "board"], status: "published" }, + create: { fields: ["title", "body", "desk"], status: "published" }, update: { fields: ["title", "body"] }, }, ``` -A [container field](/docs/content-engine/fields#container), such as `board`, may be in `create.fields` only. Moving a record to another container is a staff edit. +A [container field](/docs/content-engine/fields#container), such as `desk`, may be in `create.fields` only. Moving a record to another container is a staff edit. ## `delivery` -Gives each published record a public page: canonical URL, SEO metadata, redirects from old slugs and a sitemap. Guides: [Public pages](/docs/content-engine/public-pages) and [SEO](/docs/content-engine/seo). +Gives each published record a public page: canonical URL, SEO metadata, redirects from old slugs and a sitemap. Guides: [Content page](/docs/content-engine/public-pages) and [SEO](/docs/content-engine/seo). | Name | Type | Default | Description | | -------------------------------- | --------------------------------------------------------------------------------------------- | ------------------------- | --------------------------------------------------------------------------------------------------------------------- | @@ -215,7 +215,7 @@ Gives each published record a public page: canonical URL, SEO metadata, redirect Every SEO field must be in `publicApi.fields`, may be a group leaf such as `"seo.title"`, and cannot be a repeatable leaf. `noIndexField` must be shared, not localized. -```ts title="plugins/example/src/content/article.ts" +```ts title="plugins/newsroom/src/content/story.ts" delivery: { redirects: true, seo: { @@ -230,17 +230,17 @@ delivery: { ## `editorial` -Adds a `version` column, revision history and the `can_restore` permission. [`liveEditing`](#liveediting) builds on it. Guide: [Revisions, previews and scheduling](/docs/content-engine/editorial). +Adds a `version` column, revision history and the `can_restore` permission. [`liveEditing`](#liveediting) builds on it. Guides: [Revisions](/docs/content-engine/editorial), [Previews](/docs/content-engine/editorial-previews) and [Scheduling](/docs/content-engine/editorial-scheduling). | Name | Type | Default | Description | | -------------------------- | ---------------------------------------------- | ------- | ----------------------------------------------------------------------------------------------------------------------- | | `revisions.retention` | `number` | `50` | Newest revisions kept per record. A whole number from 1 to 500 | -| `preview` | `true` \| `{ expiresInMinutes, pathTemplate }` | off | Signed [preview links](/docs/content-engine/editorial#preview-links) for drafts | +| `preview` | `true` \| `{ expiresInMinutes, pathTemplate }` | off | Signed [preview links](/docs/content-engine/editorial-previews) for drafts | | `preview.expiresInMinutes` | `number` | `15` | Link lifetime. A whole number from 1 to 1440 | | `preview.pathTemplate` | `string` | | Your own preview URL. Starts with `/`, contains `{token}` exactly once and no other placeholder, at most 512 characters | | `scheduling` | `boolean` | off | Publish or unpublish at a chosen time | -```ts title="plugins/example/src/content/article.ts" +```ts title="plugins/newsroom/src/content/story.ts" editorial: { revisions: { retention: 20 }, preview: { expiresInMinutes: 30 }, @@ -259,7 +259,7 @@ Creates a `{tableName}_translations` table for fields marked `localized: true`. Only text, textarea, richText, slug, blocks and group fields can be localized. A localized slug needs a localized source field, and a shared slug needs a shared one. -```ts title="plugins/example/src/content/localized-article.ts" +```ts title="plugins/newsroom/src/content/bulletin.ts" localization: { defaultLocale: "en", fallback: "default", @@ -282,19 +282,19 @@ Keeps published records in the [site search index](/docs/dev/search). Drafts nev Every field except `authorField` must be in `publicApi.fields`, or a result snippet would leak a private value. Leave the language out of `pathTemplate`, because your [i18n settings](/docs/dev/i18n) add the locale prefix. -```ts title="plugins/example/src/content/article.ts" +```ts title="plugins/newsroom/src/content/story.ts" search: { titleField: "title", descriptionField: "excerpt", contentFields: ["title", "excerpt"], - pathTemplate: "/articles/{slug}", + pathTemplate: "/stories/{slug}", authorField: "author", }, ``` ## `visibility` -`visibility: true` adds the nullable `hiddenAt` and `hiddenBy` columns, the `can_hide` permission, the `POST /{id}/hide` and `POST /{id}/unhide` staff routes and a **Visibility** filter in the AdminCP list. A hidden record is left out of every public read while it keeps its `status` and `publishedAt`. It has no other options. Guide: [Hide content](/docs/content-engine/content-visibility). +`visibility: true` adds the nullable `hiddenAt` and `hiddenBy` columns, the `can_hide` permission, the `POST /{id}/hide` and `POST /{id}/unhide` staff routes and a **Visibility** filter in the AdminCP list. A hidden record is left out of every public read while it keeps its `status` and `publishedAt`. It has no other options. Guide: [Visibility](/docs/content-engine/content-visibility). ```ts title="plugins/blog/src/content/post.ts" publication: true, @@ -312,7 +312,7 @@ reports: true, ## `thread` -Makes records of one content type answer each other under a root record, like forum comments under a topic. It adds the public `GET /{path}/thread` route and checks every placement on the server. Guide: [Threaded replies](/docs/content-engine/threads). +Makes records of one content type answer each other under a root record, like forum comments under a topic. It adds the public `GET /{path}/thread` route and checks every placement on the server. Guide: [Comments](/docs/content-engine/threads). ```ts title="plugins/forum/src/content/comment.ts" thread: { parent: "parent", root: "topic", maxDepth: 6 }, @@ -339,7 +339,7 @@ Both fields must be in `publicApi.fields` and neither may be in `publicWrite.upd ## `duplication` -Adds a **Duplicate** action and the `POST /{id}/duplicate` staff route, which copy a record and its translations into a new draft. Pass `true`, or an object to change the title suffix. Guide: [Duplicate content](/docs/content-engine/duplicate-content). +Adds a **Duplicate** action and the `POST /{id}/duplicate` staff route, which copy a record and its translations into a new draft. Pass `true`, or an object to change the title suffix. Guide: [Duplication](/docs/content-engine/duplicate-content). | Name | Type | Default | Description | | ------------- | --------- | ---------------------------------------------- | --------------------------------------------------------------------------------------- | @@ -351,7 +351,7 @@ duplication: true, ## `idStrategy` -The type of the primary key. Every foreign key, junction column and translation `itemId` that points at the record takes the same type. Guide: [Record ID strategies](/docs/content-engine/id-strategy). +The type of the primary key. Every foreign key, junction column and translation `itemId` that points at the record takes the same type. Guide: [Record IDs](/docs/content-engine/id-strategy). | Value | Postgres key | In JSON | | -------------------- | ----------------------------------------- | ------------------ | @@ -361,7 +361,7 @@ The type of the primary key. Every foreign key, junction column and translation Any other value throws. Changing the strategy of a content type that already has records needs a [hand-written data migration](/docs/content-engine/id-strategy#change-the-strategy-later). -```ts title="plugins/example/src/content/event.ts" +```ts title="plugins/newsroom/src/content/event.ts" idStrategy: "bigint", ``` @@ -377,7 +377,7 @@ Extra database indexes on the base table. The engine already indexes slugs, `uni `on` accepts shared fields, group leaves such as `"syndication.priority"`, and system columns (`id`, `createdAt`, `updatedAt`, plus `status`, `publishedAt` and `version` when their feature is on). It refuses localized fields, `field.blocks()`, repeatable leaves, to-many relations and file collections, and whole groups. Two indexes on the same columns throw. -```ts title="plugins/example/src/content/advanced-article.ts" +```ts title="plugins/newsroom/src/content/explainer.ts" indexes: [{ on: ["syndication.priority"] }], ``` @@ -385,8 +385,12 @@ indexes: [{ on: ["syndication.priority"] }], `liveEditing: true` lets several editors work on one record at once in the AdminCP edit form: field locks, an autosaved shared draft, presence and rich text co-editing. It needs `editorial`, because locks and drafts are measured against the record version. It adds the `/{id}/locks` and `/{id}/draft` staff routes. Guide: [Live editing](/docs/content-engine/live-editing). -```ts title="plugins/example/src/content/article.ts" -editorial: { revisions: { retention: 20 } }, +```ts title="plugins/newsroom/src/content/story.ts" +editorial: { + revisions: { retention: 20 }, + preview: { expiresInMinutes: 30 }, + scheduling: true, +}, liveEditing: true, ``` diff --git a/apps/web/content/docs/content-engine/relations-advanced-article-page.webp b/apps/web/content/docs/content-engine/relations-advanced-article-page.webp index db5f5e3eca..9276014984 100644 Binary files a/apps/web/content/docs/content-engine/relations-advanced-article-page.webp and b/apps/web/content/docs/content-engine/relations-advanced-article-page.webp differ diff --git a/apps/web/content/docs/content-engine/relations-advanced.mdx b/apps/web/content/docs/content-engine/relations-advanced.mdx new file mode 100644 index 0000000000..bd62382b06 --- /dev/null +++ b/apps/web/content/docs/content-engine/relations-advanced.mdx @@ -0,0 +1,224 @@ +--- +title: Advanced fields +description: Store to-many relations, grouped fields and repeatable rows in real VitNode Content Engine tables, link records across id strategies, and write those values through the API. +icon: Network +--- + +import { ImgDocs } from "@/components/fumadocs/img"; +import advancedPage from "./relations-advanced-article-page.webp"; +import manyPicker from "./relations-many-picker.webp"; + +[Relations](/docs/content-engine/relations) links one record to one other record. This page covers the shapes that need more than one column: lists of links, groups of fields and repeatable rows. Each one still lands in real Postgres tables, not a JSON blob. + +The examples are excerpts of `newsroom.explainer` in the newsroom plugin, `plugins/newsroom/src/content/explainer.ts`, which uses all of them. Its targets, `newsroom.category` and `newsroom.tag`, live next to it. + +## Link to many records + +Add `multiple: true` to a relation. The explainer has a set of categories, an ordered list of tags, and an ordered "read next" list that points back at explainers: + +```ts title="plugins/newsroom/src/content/explainer.ts (excerpt)" +categories: field.relation({ + multiple: true, + onDelete: "restrict", + target: () => categoryContentType, +}), +tags: field.relation({ + multiple: true, + ordered: true, + target: () => tagContentType, +}), +relatedExplainers: field.relation({ + multiple: true, + onDelete: "cascade", + ordered: true, + self: true, +}), +``` + +- `ordered: true` keeps the order editors arrange. Without it, the engine stores the set in ascending id order, so `[9, 2]` and `[2, 9]` are the same value. +- On a to-many relation, `onDelete: "cascade"` drops the link when the target is deleted, and `"restrict"` refuses the delete. +- A to-many relation cannot be `required` or `nullable`. Use `min: 1` to demand at least one target. + +Each to-many field gets a junction table with `itemId`, `relatedItemId`, `position` and `createdAt`. Give every one except the `self: true` relation a `references` entry, and export every junction table, or Drizzle Kit leaves it out of the migration: + +```ts title="plugins/newsroom/src/database/explainers.ts (excerpt)" +export const explainerContent = createContentModel(explainerContentType, { + references: { + categories: () => newsroom_categories.id, + tags: () => newsroom_tags.id, + }, +}); + +export const newsroom_explainers = explainerContent.table; +export const newsroom_explainers_categories = + explainerContent.advancedTables.junctions.categories; +export const newsroom_explainers_tags = + explainerContent.advancedTables.junctions.tags; +export const newsroom_explainers_related_explainers = + explainerContent.advancedTables.junctions.relatedExplainers; +``` + +In the AdminCP form, a to-many relation shows each target as a chip you can remove. This is the blog plugin's **Categories** field: + + + +## Group related fields + +`field.group` keeps fields that belong together under one key: + +```ts title="plugins/newsroom/src/content/explainer.ts (excerpt)" +syndication: field.group({ + fields: { + indexable: field.boolean({ defaultValue: true }), + + noIndex: field.boolean({ defaultValue: false }), + priority: field.number({ + integer: true, + min: 0, + max: 10, + defaultValue: 5, + }), + }, +}), +``` + +A group adds one column per leaf to the main table: `syndicationIndexable`, `syndicationNoIndex` and `syndicationPriority`. The API reads and writes it as `{ syndication: { indexable, noIndex, priority } }`, and other options refer to a leaf by path, such as `indexes: [{ on: ["syndication.priority"] }]`. Leaves can be `text`, `textarea`, `number`, `boolean`, `enum` or `dateTime` fields. An optional group needs leaves that are nullable or have a default. In the AdminCP, a group renders as a fieldset with its leaf controls. + +## Add repeatable rows + +`field.repeatable` stores an ordered list of small rows, such as FAQ entries: + +```ts title="plugins/newsroom/src/content/explainer.ts (excerpt)" +faq: field.repeatable({ + max: 20, + fields: { + question: field.text({ required: true, minLength: 3, maxLength: 200 }), + answer: field.textarea({ required: true }), + }, +}), +``` + +The rows go to a child table, `newsroom_explainers_faq`, with `id`, `itemId`, `position`, `createdAt`, `updatedAt` and one column per leaf. Deleting the explainer deletes its rows. Export the child table too: + +```ts title="plugins/newsroom/src/database/explainers.ts (excerpt)" +export const newsroom_explainers_faq = + explainerContent.advancedTables.repeatables.faq; +``` + +In the AdminCP, a repeatable is a list of rows with move up, move down and remove buttons, plus an **Add** button. + +After adding any of these fields, build and migrate as in [Relations](/docs/content-engine/relations#build-and-migrate). The migration creates one table per junction and repeatable field. + +## Relations across id strategies + +A relation can point at a content type with a different [`idStrategy`](/docs/content-engine/id-strategy). The foreign key always takes the type of the key it references. The newsroom plugin's `newsroom.event` uses `bigint` and links to `uuid` tags and `serial` categories: + +```ts title="plugins/newsroom/src/content/event.ts (excerpt)" +import type { ContentRelationTarget } from "@vitnode/core/content"; + +import { defineContentType, field } from "@vitnode/core/content"; + +import { categoryContentType } from "./category"; +import { tagContentType } from "./tag"; + +fields: { + primaryTag: field.relation({ + nullable: true, + onDelete: "set null", + target: (): ContentRelationTarget<"uuid"> => tagContentType, + }), + category: field.relation({ + nullable: true, + onDelete: "set null", + target: () => categoryContentType, + }), + tags: field.relation({ + multiple: true, + ordered: true, + target: () => tagContentType, + }), + relatedEvents: field.relation({ + multiple: true, + onDelete: "cascade", + self: true, + }), +}, +``` + +| Field | Column or junction table | +| --------------- | -------------------------------------------------------------------- | +| `primaryTag` | `uuid` column referencing `newsroom_tags.id` | +| `category` | `integer` column referencing `newsroom_categories.id` | +| `tags` | `newsroom_events_tags` with `itemId bigint` and `relatedItemId uuid` | +| `relatedEvents` | `newsroom_events_related_events` with both columns `bigint` | + +`field.relation()` reads the target's strategy from `target`, so the API takes and returns tag ids as strings and category ids as numbers. When two content type modules import each other, TypeScript cannot infer a type through the cycle and falls back to serial. Annotate the thunk with `ContentRelationTarget<"uuid">` or `ContentRelationTarget<"bigint">`, as `primaryTag` does. At runtime, a `references` entry whose column type disagrees with the target's strategy throws and names the field. + +## Write related values + +A relation takes ids. A to-many relation and a repeatable take the whole list, which replaces what the record had: + +```json +{ + "categories": [2, 4], + "syndication": { "priority": 7 }, + "faq": [ + { + "id": 1, + "question": "When does construction start?", + "answer": "In spring, once the council signs the contracts." + }, + { "question": "Will the old bus route stay?", "answer": "Yes, until the trams run on the whole line." } + ] +} +``` + +The API updates a repeatable row that has an `id`, creates a row without one, and deletes the rows you leave out. The API refuses an `id` that belongs to another record with `CONTENT_REPEATABLE_UNKNOWN_CHILD`. To change one link or one row without sending the list, use the [content service](/docs/content-engine/services): `relations.categories.add()`, `.remove()`, `.set()`, `.reorder()` and `.get()`, or `repeatable.faq.create()`, `.update()`, `.delete()`, `.reorder()`, `.set()` and `.list()`. + +## Check the result + +The newsroom plugin gives the explainer API routes but no AdminCP screen, so check it through the API while `pnpm dev` runs. For a published explainer with the slug `how-the-tram-vote-works`, the public API returns the related values like this: + +```bash +curl "http://localhost:3000/api/newsroom/content/explainers/how-the-tram-vote-works?locale=en" +``` + +```json title="200 (excerpt)" +{ + "id": 1, + "title": "How the tram vote works", + "slug": "how-the-tram-vote-works", + "categories": [1], + "syndication": { "priority": 7, "noIndex": false }, + "faq": [ + { + "id": 1, + "question": "When does construction start?", + "answer": "In spring, once the council signs the contracts." + }, + { + "id": 2, + "question": "Will the old bus route stay?", + "answer": "Yes, until the trams run on the whole line." + } + ], + "publishedAt": "2026-10-24T17:39:32.786Z", + "locale": "en" +} +``` + +A to-many relation comes back as a list of ids, and a single relation as `{ "id": 2 }`. Only the leaves listed in `publicApi.fields`, such as `"faq.question"`, are public. The newsroom plugin's page at `/explainers/how-the-tram-vote-works` renders the FAQ rows: + + + +The [Fields](/docs/content-engine/fields#relation) reference lists every option of these helpers. diff --git a/apps/web/content/docs/content-engine/relations-articles-list.webp b/apps/web/content/docs/content-engine/relations-articles-list.webp new file mode 100644 index 0000000000..d14ba071e4 Binary files /dev/null and b/apps/web/content/docs/content-engine/relations-articles-list.webp differ diff --git a/apps/web/content/docs/content-engine/relations-category-field.webp b/apps/web/content/docs/content-engine/relations-category-field.webp new file mode 100644 index 0000000000..692c904cbf Binary files /dev/null and b/apps/web/content/docs/content-engine/relations-category-field.webp differ diff --git a/apps/web/content/docs/content-engine/relations-category-picker.webp b/apps/web/content/docs/content-engine/relations-category-picker.webp deleted file mode 100644 index 3649bb91a3..0000000000 Binary files a/apps/web/content/docs/content-engine/relations-category-picker.webp and /dev/null differ diff --git a/apps/web/content/docs/content-engine/relations-create-category.webp b/apps/web/content/docs/content-engine/relations-create-category.webp new file mode 100644 index 0000000000..9ae18f2f66 Binary files /dev/null and b/apps/web/content/docs/content-engine/relations-create-category.webp differ diff --git a/apps/web/content/docs/content-engine/relations.mdx b/apps/web/content/docs/content-engine/relations.mdx index 8954c9dbd8..14a8675cfd 100644 --- a/apps/web/content/docs/content-engine/relations.mdx +++ b/apps/web/content/docs/content-engine/relations.mdx @@ -1,137 +1,218 @@ --- title: Relations -description: Link Content Engine records with field.relation, group related fields with field.group and store repeatable rows with field.repeatable, all in real Postgres tables. +description: Link one VitNode Content Engine record to another with field.relation and the references map. File each newsroom article under a category, migrate, and pick the category in the AdminCP. icon: Network +implementation: + objective: Link the records of an existing content type to records of another content type with a single relation. + decisions: + - Which items get the link, and what do they point at? Offer the project's content types, and create the target type when it does not exist. + - Must every record have a target? Recommend nullable when the table already has rows. + - What happens when a target is deleted? Offer `set null`, `restrict` and `cascade`. + constraints: + - 'Declare the link with `field.relation({ target: () => otherContentType })` and map it in `createContentModel(..., { references })`.' + - Export every table from `src/database/*.ts`, register a new type in the plugin's admin module, then build and migrate. + related: + - title: Advanced fields + href: /docs/content-engine/relations-advanced + - title: Containers + href: /docs/content-engine/containers + - title: relation reference + href: /docs/content-engine/fields#relation --- import { Tab, Tabs } from "fumadocs-ui/components/tabs"; + import { ImgDocs } from "@/components/fumadocs/img"; -import categoryPicker from "./relations-category-picker.webp"; -import manyPicker from "./relations-many-picker.webp"; -import advancedPage from "./relations-advanced-article-page.webp"; +import articlesList from "./relations-articles-list.webp"; +import categoryField from "./relations-category-field.webp"; +import createCategory from "./relations-create-category.webp"; -Relations connect one record to others, and two related helpers shape a record's own data. Everything lands in real tables, not a JSON blob: a relation is a foreign key, a to-many relation is a junction table, a group is a set of columns and a repeatable field is a child table. This guide walks through `example.advanced-article` from the example plugin, which uses all four. +A relation stores another record's id in a real foreign key column. This guide adds a `newsroom.category` content type and files each article under one category, which staff pick in the AdminCP. ## Before you begin -You need a working content type to link to, such as `example.category` from [Create a content type](/docs/content-engine/create-a-content-type). +You need the `newsroom.article` type from [First content type](/docs/content-engine/create-a-content-type), with its plugin in `plugins/newsroom/`, and an AdminCP account. Nothing else is required. The steps and the check below work in the AdminCP alone. If you followed later guides, keep their additions. This guide only adds files, one field and one list column. -### Link to one record +### Define the category type + +Create `src/content/category.ts`. This is the complete file: + +```ts title="plugins/newsroom/src/content/category.ts" +import { defineContentType, field } from "@vitnode/core/content"; -Point an article at a category with `field.relation`. `target` is a function, so two content types can refer to each other without an import cycle: +export const categoryContentType = defineContentType({ + id: "newsroom.category", + tableName: "newsroom_categories", -```ts title="plugins/example/src/content/article.ts" -import { categoryContentType } from "./category"; + fields: { + name: field.text({ required: true, minLength: 2, maxLength: 80 }), + slug: field.slug({ source: "name" }), + }, -fields: { - category: field.relation({ // [!code ++:5] - required: true, - onDelete: "restrict", - target: () => categoryContentType, - }), -}, + admin: { + path: "newsroom/categories", + titleField: "name", + list: { columns: ["name", "slug", "updatedAt"] }, + }, +}); ``` -Then tell `createContentModel` which column the foreign key references: +A category is a small content type of its own, managed in the AdminCP. `titleField: "name"` is also what the article's category picker shows. -```ts title="plugins/example/src/database/articles.ts" -import { example_categories } from "./categories"; + -export const articleContent = createContentModel(articleContentType, { - references: { category: () => example_categories.id }, // [!code ++] -}); + + +### Create its model and export the table + +Create `src/database/categories.ts`. This is the complete file: + +```ts title="plugins/newsroom/src/database/categories.ts" +import { createContentModel } from "@vitnode/core/content/server"; + +import { categoryContentType } from "@/content/category"; + +export const categoryContent = createContentModel(categoryContentType); + +export const newsroom_categories = categoryContent.table; ``` -`references` needs one entry per relation field. A missing or extra key is a type error. `field.user()`, `field.file()` and `self: true` relations find their table on their own and need no entry. +The export matters, because migrations find tables through the exports of the plugin's `src/database/*.ts` files. -### Link to many records - -Add `multiple: true`. The advanced article has two to-many relations: a set of categories and an ordered "read next" list that points back at articles: - -```ts title="plugins/example/src/content/advanced-article.ts" -fields: { - categories: field.relation({ - multiple: true, - onDelete: "restrict", - target: () => categoryContentType, - }), - relatedArticles: field.relation({ - multiple: true, - onDelete: "cascade", - ordered: true, - self: true, - }), -}, +### Register the category type + +Add `categoryContent` to the staff API in `src/api/modules/admin/admin.module.ts`. This is an excerpt: + +```ts title="plugins/newsroom/src/api/modules/admin/admin.module.ts" +import { articleContent } from "@/database/articles"; +import { categoryContent } from "@/database/categories"; // [!code ++] + + modules: [ + buildContentAdminModule({ + contentTypes: [articleContent, categoryContent], // [!code highlight] + }), + ], ``` -`ordered: true` keeps the order editors arrange. Without it, the set is stored in ascending id order. `onDelete: "cascade"` on a to-many relation drops the link when the target is deleted, and `"restrict"` refuses the delete. A to-many relation cannot be `required` or `nullable`; use `min: 1` to demand at least one target. +Then add its link to `contentTypes` in `src/admin/nav.tsx`. This is an excerpt. `src/admin/content.tsx` re-exports the same list, so the screen needs no change: -Each to-many field gets a junction table with `itemId`, `relatedItemId`, `position` and `createdAt`. Give `categories` its reference and export both junction tables, or Drizzle Kit leaves them out of the migration: +```tsx title="plugins/newsroom/src/admin/nav.tsx" +import { FolderIcon, NewspaperIcon } from "lucide-react"; // [!code highlight] -```ts title="plugins/example/src/database/advanced-articles.ts" -export const advancedArticleContent = createContentModel( - advancedArticleContentType, - { references: { categories: () => example_categories.id } }, -); +import { categoryContentType } from "@/content/category"; // [!code ++] -export const example_advanced_articles = advancedArticleContent.table; -export const example_advanced_articles_categories = // [!code ++:4] - advancedArticleContent.advancedTables.junctions.categories; -export const example_advanced_articles_related_articles = - advancedArticleContent.advancedTables.junctions.relatedArticles; + contentTypes: [ + { definition: articleContentType, icon: }, + { definition: categoryContentType, icon: }, // [!code ++] + ], ``` +If you followed later guides, keep the comment type in both lists too. + -### Group related fields +### Add the relation to the article -`field.group` keeps fields that belong together under one key: +Edit `src/content/article.ts`. Import the category type and add a `category` field. This is an excerpt: + +```ts title="plugins/newsroom/src/content/article.ts" +import { defineContentType, field } from "@vitnode/core/content"; + +import { categoryContentType } from "@/content/category"; // [!code ++] -```ts title="plugins/example/src/content/advanced-article.ts" -syndication: field.group({ fields: { - indexable: field.boolean({ defaultValue: true }), - noIndex: field.boolean({ defaultValue: false }), - priority: field.number({ integer: true, min: 0, max: 10, defaultValue: 5 }), + title: field.text({ required: true, minLength: 3, maxLength: 200 }), + slug: field.slug({ source: "title" }), + body: field.richText({ required: true }), + // [!code ++:5] + category: field.relation({ + target: () => categoryContentType, + nullable: true, + onDelete: "set null", + }), }, -}), ``` -A group adds one column per leaf to the main table: `syndicationIndexable`, `syndicationNoIndex` and `syndicationPriority`. The API reads and writes it as `{ syndication: { indexable, noIndex, priority } }`, and other options refer to a leaf by path, such as `indexes: [{ on: ["syndication.priority"] }]`. Leaves can be `text`, `textarea`, `number`, `boolean`, `enum` or `dateTime` fields. An optional group needs leaves that are nullable or have a default. +- **`target`** is a function, so two content types can point at each other without an import cycle. +- **`nullable: true`** lets the column start empty. Your articles already exist and have no category, so you could not add a `required` relation to the table. +- **`onDelete: "set null"`** clears the link when a category is deleted. `"restrict"` refuses to delete a category that still has articles, and `"cascade"` deletes them with it. + +Then show the category in the AdminCP list by adding `"category"` to `admin.list.columns` in the same file. This is an excerpt, so keep any other columns you added, such as `"status"`: + +```ts title="plugins/newsroom/src/content/article.ts" + admin: { + path: "newsroom/articles", + titleField: "title", + list: { + columns: ["title", "slug", "category", "updatedAt"], // [!code highlight] + orderableFields: ["title"], + }, + }, +``` -### Add repeatable rows +### Map the reference -`field.repeatable` stores an ordered list of small rows, such as FAQ entries: +Edit `src/database/articles.ts`. This is the complete file: -```ts title="plugins/example/src/content/advanced-article.ts" -faq: field.repeatable({ - max: 20, - fields: { - question: field.text({ required: true, minLength: 3, maxLength: 200 }), - answer: field.textarea({ required: true }), - }, -}), +```ts title="plugins/newsroom/src/database/articles.ts" +import { createContentModel } from "@vitnode/core/content/server"; + +import { articleContentType } from "@/content/article"; + +import { newsroom_categories } from "./categories"; + +export const articleContent = createContentModel(articleContentType, { + references: { category: () => newsroom_categories.id }, +}); + +export const newsroom_articles = articleContent.table; ``` -The rows go to a child table, `example_advanced_articles_faq`, with `id`, `itemId`, `position`, `createdAt`, `updatedAt` and one column per leaf. Deleting the article deletes its rows. Export the child table too: +The definition names the content type a relation points at, and `references` names the column its foreign key uses. It needs exactly one entry per relation field, so a missing or extra key is a type error. `field.user()`, `field.file()` and `self: true` relations find their table on their own and need no entry. + + + + + +### Label the new fields + +Add the category's labels and permissions to `src/locales/en.json`, and a `category` label to the article's fields. This excerpt shows only the new keys, in their place: -```ts title="plugins/example/src/database/advanced-articles.ts" -export const example_advanced_articles_faq = - advancedArticleContent.advancedTables.repeatables.faq; +```json title="plugins/newsroom/src/locales/en.json" +{ + "newsroom": { + "content": { + "article": { + "fields": { "category": "Category" } + }, + "category": { + "label": "{count, plural, one {Category} other {Categories}}", + "title": "Categories", + "desc": "Sections that group the articles.", + "fields": { "name": "Name", "slug": "Slug", "updatedAt": "Updated" } + } + } + }, + "newsroom:category": "Categories", + "newsroom:category:can_view": "View categories", + "newsroom:category:can_create": "Create categories", + "newsroom:category:can_edit": "Edit categories", + "newsroom:category:can_delete": "Delete categories" +} ``` @@ -140,145 +221,107 @@ export const example_advanced_articles_faq = ### Build and migrate - +Run these from the workspace root. While `pnpm dev` runs, `db:migrate` alone is enough: + + ```bash tab="bun" +bun run build:plugins bun run db:migrate ``` ```bash tab="pnpm" +pnpm build:plugins pnpm db:migrate ``` ```bash tab="npm" +npm run build:plugins npm run db:migrate ``` -The generated migration creates one table per junction and repeatable field. +The migration creates the categories table and gives articles a nullable `category` column with a foreign key and an index: - - - - -## Relations across id strategies - -A relation can point at a content type with a different [`idStrategy`](/docs/content-engine/id-strategy). The foreign key always takes the type of the key it references. The example plugin's `example.event` uses `bigint` and links to `uuid` tags and `serial` categories: - -```ts title="plugins/example/src/content/event.ts" -import type { ContentRelationTarget } from "@vitnode/core/content"; - -import { categoryContentType } from "./category"; -import { tagContentType } from "./tag"; - -fields: { - primaryTag: field.relation({ - nullable: true, - onDelete: "set null", - target: (): ContentRelationTarget<"uuid"> => tagContentType, - }), - category: field.relation({ - nullable: true, - onDelete: "set null", - target: () => categoryContentType, - }), - tags: field.relation({ multiple: true, ordered: true, target: () => tagContentType }), - relatedEvents: field.relation({ multiple: true, onDelete: "cascade", self: true }), -}, +```sql title="apps/api/migrations/_/migration.sql (excerpt)" +CREATE TABLE "newsroom_categories" ( + "id" serial PRIMARY KEY, + "createdAt" timestamp DEFAULT now() NOT NULL, + "updatedAt" timestamp DEFAULT now() NOT NULL, + "name" varchar(80) NOT NULL, + "slug" varchar(160) NOT NULL +); +ALTER TABLE "newsroom_articles" ADD COLUMN "category" integer; +CREATE INDEX "newsroom_articles_category_idx" ON "newsroom_articles" ("category"); +ALTER TABLE "newsroom_articles" ADD CONSTRAINT "newsroom_articles_category_newsroom_categories_id_fkey" FOREIGN KEY ("category") REFERENCES "newsroom_categories"("id") ON DELETE SET NULL ON UPDATE CASCADE; ``` -| Field | Column or junction table | -| --------------- | ------------------------------------------------------------------- | -| `primaryTag` | `uuid` column referencing `example_tags.id` | -| `category` | `integer` column referencing `example_categories.id` | -| `tags` | `example_events_tags` with `itemId bigint` and `relatedItemId uuid` | -| `relatedEvents` | `example_events_related_events` with both columns `bigint` | - -`field.relation()` reads the target's strategy from `target`, so the API takes and returns tag ids as strings and category ids as numbers. When two content type modules import each other, TypeScript cannot infer a type through the cycle and falls back to serial. Annotate the thunk with `ContentRelationTarget<"uuid">` or `ContentRelationTarget<"bigint">`, as `primaryTag` does. At runtime, a `references` entry whose column type disagrees with the target's strategy throws and names the field. - -## Write related values + -A relation takes ids. A to-many relation and a repeatable take the whole list, which replaces what the record had: + -```json -{ - "categories": [2, 4], - "syndication": { "priority": 7 }, - "faq": [ - { - "id": 1, - "question": "Can I freeze the batter?", - "answer": "Yes, for up to a month." - }, - { "question": "Do I need buttermilk?", "answer": "No, regular milk works." } - ] -} -``` +## Check the result -A repeatable row with an `id` updates that row, a row without one is created, and rows you leave out are deleted. The API refuses an `id` that belongs to another record with `CONTENT_REPEATABLE_UNKNOWN_CHILD`. To change one link or one row without sending the list, use the [content service](/docs/content-engine/services): `relations.categories.add()`, `.remove()`, `.set()`, `.reorder()` and `.get()`, or `repeatable.faq.create()`, `.update()`, `.delete()`, `.reorder()`, `.set()` and `.list()`. +Open **AdminCP → Newsroom → Categories**, click **Create Category**, enter `Announcements` and click **Create**: -## Check the result + -In the AdminCP form, a single relation is a search picker. This is the article's **Category** field: +Open **Newsroom → Articles** and click the pencil on `Welcome to the newsroom`. The dialog now ends with a **Category** picker. Choose **Announcements** and click **Save changes**: -A to-many relation shows each target as a chip you can remove. Here is the blog plugin's **Categories** field, a `field.relation({ multiple: true })`: +The list shows the category's name in the new column, and the other articles show a dash: -A group renders as a fieldset with its leaf controls, and a repeatable as a list of rows with move up, move down and remove buttons plus an **Add** button. The example plugin gives the advanced article API routes but no AdminCP screen, so test it through the API. Its public API returns the related values like this: +Now delete the category. In **Newsroom → Categories**, click the trash button in the `Announcements` row and confirm with **Yes, delete it**. Back in **Newsroom → Articles**, `Welcome to the newsroom` shows a dash again. The article stays, and only its `category` became `null`, because the relation says `onDelete: "set null"`. + +## Read the category through the public API + +This optional part needs the article's public API from [Public API](/docs/content-engine/public-api), which builds on [Drafts & publishing](/docs/content-engine/publication), and a published article with a category. Assign a category again if you deleted it above. + +Add `"category"` to `publicApi.fields` in `src/content/article.ts`. Change only that list, and keep every other option you configured, such as `listFields`, `searchableFields` and `orderableFields`: + +```ts title="plugins/newsroom/src/content/article.ts" + publicApi: { + path: "news", + fields: ["id", "title", "slug", "body", "category", "publishedAt"], // [!code highlight] + // listFields, searchableFields, orderableFields and the rest stay as they are + }, +``` + +`listFields` decides what the list route returns. Add `"category"` there too only if list rows need it. Then request the article: ```bash -curl "http://localhost:3000/api/@vitnode/example/content/advanced-articles/weekend-pancakes?locale=en" +curl "http://localhost:3000/api/newsroom/content/news/welcome-to-the-newsroom" ``` -```json +```json title="200 (excerpt)" { "id": 1, - "title": "Weekend pancakes", - "slug": "weekend-pancakes", - "categories": [2, 4], - "seo": { - "title": "Fluffy weekend pancakes", - "description": "Ready in 20 minutes." - }, - "syndication": { "priority": 7, "noIndex": false }, - "faq": [ - { - "id": 1, - "question": "Can I freeze the batter?", - "answer": "Yes, for up to a month." - }, - { - "id": 2, - "question": "Do I need buttermilk?", - "answer": "No, regular milk works." - } - ], - "publishedAt": "2026-10-10T10:57:42.767Z", - "locale": "en" + "title": "Welcome to the newsroom", + "slug": "welcome-to-the-newsroom", + "category": { "id": 1 }, + "publishedAt": "2026-10-23T18:57:51.156Z" } ``` -A to-many relation comes back as a list of ids, and a single relation as `{ "id": 2 }`. Only the leaves listed in `publicApi.fields`, such as `"faq.question"`, are public. The example plugin's page at `/advanced-articles/weekend-pancakes` renders the FAQ rows: +A relation exposes only the related record's id, here the category's. The category's name is not part of the article's public answer. The AdminCP can show it because its staff list route adds a `labels` object with the names, and staff routes are not public. - +## Go further -Every option of these helpers is in the [Fields](/docs/content-engine/fields#relation) reference. +- **Show the category name to readers.** Give the category type `publication: true` and its own [public API](/docs/content-engine/public-api), then read the category by id or list categories next to the articles. Or model categories as [containers](/docs/content-engine/containers), which bring their own pages and filters. +- **Let members choose a category.** Add `category` to `publicWrite.create.fields`. Members may only link to records readers can see, so the category type then needs `publication: true` and its own `publicApi`. Without them, the write route stops with an error that names the field. +- **Many categories, grouped fields, repeatable rows and mixed id types.** See [Advanced fields](/docs/content-engine/relations-advanced). diff --git a/apps/web/content/docs/content-engine/reports-article-page.webp b/apps/web/content/docs/content-engine/reports-article-page.webp new file mode 100644 index 0000000000..b20ae07a06 Binary files /dev/null and b/apps/web/content/docs/content-engine/reports-article-page.webp differ diff --git a/apps/web/content/docs/content-engine/reports-dialog.webp b/apps/web/content/docs/content-engine/reports-dialog.webp index d6e181cce2..5631518183 100644 Binary files a/apps/web/content/docs/content-engine/reports-dialog.webp and b/apps/web/content/docs/content-engine/reports-dialog.webp differ diff --git a/apps/web/content/docs/content-engine/reports-discussion-page.webp b/apps/web/content/docs/content-engine/reports-discussion-page.webp deleted file mode 100644 index 05d669f4e6..0000000000 Binary files a/apps/web/content/docs/content-engine/reports-discussion-page.webp and /dev/null differ diff --git a/apps/web/content/docs/content-engine/reports-modcp-queue.webp b/apps/web/content/docs/content-engine/reports-modcp-queue.webp new file mode 100644 index 0000000000..85882291ee Binary files /dev/null and b/apps/web/content/docs/content-engine/reports-modcp-queue.webp differ diff --git a/apps/web/content/docs/content-engine/reports-modcp-report.webp b/apps/web/content/docs/content-engine/reports-modcp-report.webp new file mode 100644 index 0000000000..cba4bbe031 Binary files /dev/null and b/apps/web/content/docs/content-engine/reports-modcp-report.webp differ diff --git a/apps/web/content/docs/content-engine/reports.mdx b/apps/web/content/docs/content-engine/reports.mdx index f99cb8d1c4..8331354621 100644 --- a/apps/web/content/docs/content-engine/reports.mdx +++ b/apps/web/content/docs/content-engine/reports.mdx @@ -1,17 +1,16 @@ --- -title: Report content -description: Let readers report a VitNode Content Engine record to moderators with the reports option and ReportButton, on a record's page and on every row of a list. +title: Reports +description: Let readers report a VitNode Content Engine record to moderators with the reports option, useContentPermissions and ReportButton, then find the report in the ModCP and open the reported content. icon: Flag implementation: objective: Let readers report records of an existing content type to moderators. decisions: - Which items can readers report? Offer content types with a public API that do not have `reports` yet. - Where does the Report button appear? Offer the plugin's public pages and lists that show the chosen items. - - What should moderators see in a report? Recommend the default snapshot (the title and the public text, rich text, choice and file fields), or pick fields. constraints: - 'Set `reports: true` or `{ titleField, snapshotFields }` on the content type. Nothing needs migrating.' - Show the button when `useContentPermissions` answers `canReport`, and build its target with `contentTarget`. - - Enabling reports grants nothing by itself. Label the generated moderator permissions in the plugin's locale. + - Enabling reports grants nothing by itself. Moderators need `can_view_reports`. related: - title: Process reports href: /docs/dev/moderation/process-reports @@ -22,13 +21,16 @@ implementation: --- import { ImgDocs } from "@/components/fumadocs/img"; -import reportDetails from "../dev/moderation/moderation-report-details.webp"; import reportDialog from "./reports-dialog.webp"; -import detail from "./reports-discussion-page.webp"; +import detail from "./reports-article-page.webp"; +import queue from "./reports-modcp-queue.webp"; +import report from "./reports-modcp-report.webp"; -`reports` adds a **Report** button to your content. A signed-in reader picks a reason, and the report lands in the moderators' queue with a copy of what they saw. This guide adds it to the example plugin's discussions: on each discussion's page and on every row of `/discussions`. +`reports` adds a **Report** button to your content. A reader picks a reason, and the report lands in the moderators' queue with a copy of what they saw. This page adds it to the newsroom's articles and follows one report to the ModCP. -You need a [public API](/docs/content-engine/public-api), because a reader can only report what they can read. +## Before you begin + +You need a content type with a [public API](/docs/content-engine/public-api) and a public record page, such as the article page from [Content page](/docs/content-engine/public-pages). Members do not have to write articles, because readers can report staff-written articles too. Sign in with a member account to report, and use an account with moderator permissions, such as a root administrator, to read the report. @@ -36,39 +38,43 @@ You need a [public API](/docs/content-engine/public-api), because a reader can o ### Turn on reports -```ts title="plugins/example/src/content/discussion.ts" -export const discussionContentType = defineContentType({ - id: "example.discussion", - // ... +Edit `src/content/article.ts` and add `reports` after `publicApi`. This is an excerpt: + +```ts title="plugins/newsroom/src/content/article.ts" + publicApi: { + path: "news", + fields: ["id", "title", "slug", "body", "publishedAt"], + }, + reports: true, // [!code ++] -}); ``` -Reports live in core tables, so there is nothing to migrate. The content type gets moderator permissions such as `can_view_reports` and `can_manage_reports`; give them to someone in **AdminCP → Staff → Moderators** before you test. See [Configure moderators](/docs/dev/moderation/configure-moderators). +Reports live in core tables, so there is nothing to migrate. The article type gets the moderator permissions `can_view_reports` and `can_manage_reports`, and a new public route, `GET /api/newsroom/content/news/permissions`, starts answering `canReport`. -### Ask who may report what +### Ask whether the visitor may report -`useContentPermissions` asks the Content Engine what the visitor may do with the records on screen, in one request. `canReport` is `true` for a public record the visitor did not write, guests included; their dialog asks them to sign in. +The button should only show where a report would be accepted, and that depends on who is looking. Create `src/pages/article-permissions.ts`. This is the complete file: -```ts title="plugins/example/src/pages/discussion-list.ts" +```ts title="plugins/newsroom/src/pages/article-permissions.ts" import { useContentPermissions } from "@vitnode/core/tanstack/content"; import { fetcher } from "@vitnode/core/tanstack/fetcher"; import { manifest } from "@/manifest.gen"; -export const useDiscussionPermissions = (ids: readonly number[]) => +/** What the visitor may do with each article on screen: `canEdit`, `canReport`. */ +export const useArticlePermissions = (ids: readonly number[]) => useContentPermissions({ ids, - queryKey: [manifest.id, "discussions"], + queryKey: [manifest.id, "articles"], fetch: async request => { const res = await fetcher({ plugin: manifest.id, method: "get", - module: "content/discussions", + module: "content/news", path: "/permissions", args: { query: { ids: request.join(",") } }, }); @@ -79,106 +85,150 @@ export const useDiscussionPermissions = (ids: readonly number[]) => }); ``` -The same hook answers `canEdit`, used by the [edit form](/docs/content-engine/public-write-edit#show-the-edit-button). +If you followed [Permissions](/docs/content-engine/public-permissions), this file already exists, so reuse it. The same route answers both `canEdit` and `canReport`. + +`canReport` is `true` for a public article the visitor did not write, guests included. The dialog asks a guest to sign in. An article staff wrote in the AdminCP has no author, so every reader may report it. -### Add the button to the page +### Add the Report button -```tsx title="plugins/example/src/pages/discussion-page.tsx" -import { contentTarget } from "@vitnode/core/content"; -import { ReportButton } from "@vitnode/core/tanstack/moderation"; +Edit `src/pages/article-page.tsx`. Add `contentTarget` to the existing `@vitnode/core/content` import, the other imports, and an `ArticleActions` component above `ArticlePage`. These are excerpts: -import { discussionContentType } from "@/content/discussion"; -import { manifest } from "@/manifest.gen"; +```tsx title="plugins/newsroom/src/pages/article-page.tsx" +import { contentTarget, zodRichTextDocument } from "@vitnode/core/content"; // [!code highlight] +import { ReportButton } from "@vitnode/core/tanstack/moderation"; +import { useTranslations } from "use-intl"; + +import { articleContentType } from "@/content/article"; + +import { useArticlePermissions } from "./article-permissions"; + +/** Asked in the browser, so the cached page stays the same for everyone. */ +const ArticleActions = ({ id, title }: { id: number; title: string }) => { + const t = useTranslations("newsroom.articles"); + const { canReport } = useArticlePermissions([id]).of(id); + + return ( +
+ {canReport ? ( + + ) : null} +
+ ); +}; +``` -const { canReport } = useDiscussionPermissions([item.id]).of(item.id); +Then render it at the end of the article's `
`, below the date: -{canReport ? ( - -) : null} +```tsx title="plugins/newsroom/src/pages/article-page.tsx" +
+ {/* title and date as before */} + {/* [!code ++] */} +
``` - `contentTarget` names the record from the content type, so the type id always matches what the API registered. -- `typeLabel` finishes the dialog title, "What's wrong with this discussion?". Add `"report_type": "discussion"` to the plugin's locale file. -- `preview` shows the reader what they are reporting at the top of the dialog. +- `preview` shows the article's title in the dialog, and `typeLabel` finishes its title, "What's wrong with this article?". +- The button loads its own messages, and its form only when the dialog opens. -The button loads its own messages and its form only when the dialog opens. +If `ArticleActions` already exists from [Permissions](/docs/content-engine/public-permissions), add the `canReport` branch next to its **Edit** link, read both flags with `const { canEdit, canReport } = useArticlePermissions([id]).of(id)`, and pass `title={item.title}` where the header renders it. -### Add it to every row of a list - -Ask once for the whole page, and point each button at its row's title with `describedBy`, so a screen reader hears "Report discussion, Captcha on public forms" instead of ten identical buttons: - -```tsx title="plugins/example/src/pages/discussions-page.tsx" -const permissions = useDiscussionPermissions( - discussions?.edges.map(discussion => discussion.id) ?? [], -); - -renderItem={discussion => ( - <> - - {discussion.title} - - {permissions.of(discussion.id).canReport ? ( - - ) : null} - -)} -``` +### Add the label - +Add `report_type` under `newsroom.articles` in `src/locales/en.json`: - +```json title="plugins/newsroom/src/locales/en.json" +{ + "newsroom": { + "articles": { + "report_type": "article" + } + } +} +``` -## Choose what moderators see +The dialog's own labels, such as the reasons, come from core. -By default a report keeps the record's title and its public text, rich text, choice and file fields, frozen at the moment of the report. Pick the fields yourself when you want something else; they may even be private: + -```ts title="plugins/example/src/content/discussion.ts" -reports: { titleField: "title", snapshotFields: ["body"] }, -``` + -## What the server checks +### Grant the moderator permissions -The button is only a hint. The report route checks again: +Moderators need `can_view_reports` on articles to see a report, and `can_manage_reports` to handle it. A root administrator holds every moderator permission. For anyone else, open **AdminCP → Staff → Moderators**, add the user or role, and turn on the Newsroom report permissions, as [Configure moderators](/docs/dev/moderation/configure-moderators) shows. -- The reader is signed in. A guest gets the sign-in prompt. -- The reader can read the record through the public API. A draft or a hidden record is `404`. -- One reader holds one open report per record, and files at most 20 reports an hour. + + + ## Check the result -Sign in as a member and open someone else's discussion. Click **Report** and pick a reason: +Sign in as a member and open `/news/welcome-to-the-newsroom`, which staff wrote. **Report** shows below the date: +Click **Report**, pick **Spam**, add details and click **Send report**: + -The dialog moves on to details, then says **Report sent**. A moderator with `can_view_reports` now sees it in the [ModCP](/docs/dev/moderation/process-reports). +Now sign in as the moderator and open `/modcp/reports`. The report is in the queue with its reason and the article: + +Open it. The report shows the reason, the reporter's details and a snapshot of the article as it was when reported. A staff-written article shows no author. **Open current content** links to `/news/welcome-to-the-newsroom`, the live article: + + + +[Process reports](/docs/dev/moderation/process-reports) covers assigning, resolving and hiding. + +## Choose what moderators see + +By default a report keeps the record's title and its public text, rich text, choice and file fields, frozen at the moment of the report. Pick the fields yourself when you want something else. They may even be private: + +```ts title="plugins/newsroom/src/content/article.ts" +reports: { titleField: "title", snapshotFields: ["body"] }, +``` + +## What the server checks + +The button is only a hint. The report route checks again: + +- The reader is signed in. A guest gets the sign-in prompt. +- The reader can read the record through the public API. A draft or a hidden record is `404`. +- One reader holds one open report per record, and files at most 20 reports an hour. + +## Report from a list or a comment + +- **Every row of a list.** Call the permissions hook once with every id on the page, and point each button at its row's title with `describedBy`, so a screen reader hears which record each **Report** belongs to. The newsroom plugin's letters list does this in `plugins/newsroom/src/pages/letters-page.tsx`. +- **Comments.** Add `reports: true` to the [comment type](/docs/content-engine/threads), read `canReport` from its own permissions hook, and pass `contentTarget(manifest.id, commentContentType, comment.id)`. `snapshotFields: ["body"]` gives moderators the comment's text, since a comment has no title. + +Next, let readers answer an article with [Comments](/docs/content-engine/threads). diff --git a/apps/web/content/docs/content-engine/search-admincp-collection.webp b/apps/web/content/docs/content-engine/search-admincp-collection.webp index f80c016f38..ca2b26a133 100644 Binary files a/apps/web/content/docs/content-engine/search-admincp-collection.webp and b/apps/web/content/docs/content-engine/search-admincp-collection.webp differ diff --git a/apps/web/content/docs/content-engine/search-results.webp b/apps/web/content/docs/content-engine/search-results.webp index bed8d602df..e06ab5b03a 100644 Binary files a/apps/web/content/docs/content-engine/search-results.webp and b/apps/web/content/docs/content-engine/search-results.webp differ diff --git a/apps/web/content/docs/content-engine/search.mdx b/apps/web/content/docs/content-engine/search.mdx index a58eb592f7..2059e6e1d4 100644 --- a/apps/web/content/docs/content-engine/search.mdx +++ b/apps/web/content/docs/content-engine/search.mdx @@ -15,7 +15,7 @@ implementation: - Every indexed field except `authorField` must be in `publicApi.fields`. - Records saved before the change need a Reindex in Debug Panel → Search. Own code that writes records must use the editorial service with `contentEditorialEffects` to keep the index current. related: - - title: Public pages + - title: Content page href: /docs/content-engine/public-pages - title: Search href: /docs/dev/search @@ -27,13 +27,13 @@ import { ImgDocs } from "@/components/fumadocs/img"; import searchAdmincpCollection from "./search-admincp-collection.webp"; import searchResults from "./search-results.webp"; -Add a `search` block to a content type and its published records show up on the site search page at `/search`. VitNode writes a search document after every save, publish and delete, so you never call the search engine yourself. This guide turns search on for `example.article` from the example plugin. +Add a `search` block to a content type and its published records show up on the site search page at `/search`. VitNode writes a search document after every save, publish and delete, so you never call the search engine yourself. This guide turns search on for `newsroom.story` from the newsroom plugin. ## Before you begin The content type needs [publication and a public API](/docs/content-engine/public-api), because a search result is a link to a public page and its text has to be public already. Without them, the definition throws when it loads: ``search needs `publication: true`.`` or ``search needs `publicApi: { path, fields }`.`` -It also needs a page that serves the result URL. [Public pages](/docs/content-engine/public-pages) shows how to add one. +It also needs a page that serves the result URL. [Content page](/docs/content-engine/public-pages) shows how to add one. @@ -43,29 +43,53 @@ It also needs a page that serves the result URL. [Public pages](/docs/content-en Add `search` to the definition. This excerpt leaves out the fields and blocks search does not read: -```ts title="plugins/example/src/content/article.ts" -export const articleContentType = defineContentType({ - id: "example.article", - tableName: "example_articles", +```ts title="plugins/newsroom/src/content/story.ts (excerpt)" +export const storyContentType = defineContentType({ + id: "newsroom.story", + tableName: "newsroom_stories", fields: { title: field.text({ required: true, minLength: 3, maxLength: 200 }), slug: field.slug({ source: "title" }), - excerpt: field.textarea({ maxLength: 500, nullable: true }), + // ... + excerpt: field.textarea({ + ai: { + action: "newsroom:story.excerpt", + mode: "suggestion", + sourceFields: ["title", "code"], + }, + maxLength: 500, + nullable: true, + }), + // ... author: field.user(), + // ... }, publication: true, + // ... publicApi: { - path: "articles", - fields: ["title", "slug", "excerpt", "publishedAt"], + path: "stories", + fields: [ + "title", + "slug", + "excerpt", + "featured", + "category", + "animation", + "gallery", + "noIndex", + "publishedAt", + ], + // ... }, // [!code ++:7] search: { titleField: "title", descriptionField: "excerpt", contentFields: ["title", "excerpt"], - pathTemplate: "/articles/{slug}", + pathTemplate: "/stories/{slug}", authorField: "author", }, + // ... }); ``` @@ -84,13 +108,13 @@ Every field except `authorField` must be in `publicApi.fields`. Otherwise a resu ### Index the records you already have -New writes are indexed from now on, but records saved before the `search` block existed are not. Open **Debug Panel → Search** in the AdminCP (`/admin/core/debug/search`), type `example.article` into **Search collections…**, and click **Reindex** on that row. **Rebuild index** at the top rebuilds every collection. +VitNode indexes new writes from now on, but not the records saved before the `search` block existed. Open **Debug Panel → Search** in the AdminCP (`/admin/core/debug/search`), type `newsroom.story` into **Search collections…**, and click **Reindex** on that row. **Rebuild index** at the top rebuilds every collection. Both buttons need the **Rebuild and clear the search index** staff permission (`system` → `can_manage_search`). A rebuild clears the collection and refills it in pages of 200 records. It is a queued job, so it only runs while [cron](/docs/dev/cron) is running. @@ -223,7 +247,7 @@ If the search engine fails, the save still succeeds. The failure is logged with ## Localized content types -A [localized](/docs/content-engine/localization) content type such as `example.localized-article` gets one document per translation, with the language stored on it. A translation is public only when both the record and that translation are published. Deleting a translation removes only that language's document. Deleting the record removes all of them. +A [localized](/docs/content-engine/localization) content type such as `newsroom.bulletin` gets one document per translation, with the language stored on it. A translation is public only when both the record and that translation are published. Deleting a translation removes only that language's document. Deleting the record removes all of them. `/search` shows documents in the visitor's language. Documents of content types that are not localized match every language. With `localization.fallback: "default"`, a visitor whose language has no published translation sees the default language's document instead. @@ -231,39 +255,39 @@ Each document gets the URL of its own language. `pathTemplate` stays the English ## Check the result -1. Publish an article in **AdminCP → Example → Articles**. -2. Open `/search` and type a whole word or two from its title or excerpt. The result links to `/articles/{slug}`. +1. Publish a story in **AdminCP → Newsroom → Stories**. +2. Open `/search` and type a whole word or two from its title or excerpt. The result links to `/stories/{slug}`. The same results come from the public search API. `types` limits them to one content type: ```bash -curl "http://localhost:3000/api/@vitnode/core/search?search=hello&types=example.article" +curl "http://localhost:3000/api/@vitnode/core/search?search=hello&types=newsroom.story" ``` ```json { "edges": [ { - "pluginId": "@vitnode/example", - "itemType": "example.article", + "pluginId": "newsroom", + "itemType": "newsroom.story", "itemId": 3, "languageCode": "", "title": "Hello Content Engine", - "content": "Hello Content Engine A first article from the example plugin, served by the public API and rendered on its own page.", - "url": "/articles/hello-content-engine", + "content": "Hello Content Engine A first story from the newsroom plugin, served by the public API and rendered on its own page.", + "url": "/stories/hello-content-engine", "isPublic": true } ] } ``` -Unpublish the article and run the request again. This time `edges` is empty. If a record you published before adding `search` is missing, reindex the collection. +Unpublish the story and run the request again. This time `edges` is empty. If a record you published before adding `search` is missing, reindex the collection. The index runs on Postgres full-text search by default. To switch engines, or to index data that is not a content type, see [Search & Discovery](/docs/dev/search) and [Elasticsearch](/docs/dev/search-elasticsearch). diff --git a/apps/web/content/docs/content-engine/seo.mdx b/apps/web/content/docs/content-engine/seo.mdx index 6167ef14c0..be8317087f 100644 --- a/apps/web/content/docs/content-engine/seo.mdx +++ b/apps/web/content/docs/content-engine/seo.mdx @@ -9,22 +9,44 @@ import blogSeoSheet from "./seo-blog-sheet.webp"; Once a content type has [public pages](/docs/content-engine/public-pages), `delivery.seo` decides which fields fill each page's ``, meta description and `robots` tag, and `delivery.sitemap` lists the pages for crawlers. You name fields; the Content Engine reads them from every published record, so editors never fill in a separate SEO form unless you give them one. -The examples use `example.article` from the example plugin and `blog.post` from the blog plugin, which is localized. +The examples use `newsroom.story` from the newsroom plugin and `blog.post` from the blog plugin, which is localized. ## Pick the SEO fields -`example.article` uses its title and excerpt, plus a boolean that hides a record from search engines: +`newsroom.story` uses its title and excerpt, plus a boolean that hides a record from search engines: -```ts title="plugins/example/src/content/article.ts" +```ts title="plugins/newsroom/src/content/story.ts (excerpt)" fields: { title: field.text({ required: true, minLength: 3, maxLength: 200 }), - excerpt: field.textarea({ maxLength: 500, nullable: true }), + // ... + excerpt: field.textarea({ + ai: { + action: "newsroom:story.excerpt", + mode: "suggestion", + sourceFields: ["title", "code"], + }, + maxLength: 500, + nullable: true, + }), + // ... noIndex: field.boolean({ nullable: true }), + // ... }, publicApi: { - path: "articles", - fields: ["title", "slug", "excerpt", "noIndex", "publishedAt"], + path: "stories", + fields: [ + "title", + "slug", + "excerpt", + "featured", + "category", + "animation", + "gallery", + "noIndex", + "publishedAt", + ], + // ... }, delivery: { @@ -86,7 +108,7 @@ The page's `head` calls `contentDeliveryPageHead` with the resolve route's answe | `og:title`, `og:description` | No | <Callout type="warn"> - The resolve route returns `openGraph` and `canonicalPath` for every record, but nothing turns them into tags yet. A nonlocalized type such as `example.article` gets no canonical link, and no content type gets Open Graph tags. A plugin page cannot add them either, because its `head` only accepts `title`, `description`, `robots` and `alternates`. Keep `openGraph` in your definition so the data is ready, and read it from the resolve route if you render pages outside VitNode. + The resolve route returns `openGraph` and `canonicalPath` for every record, but nothing turns them into tags yet. A nonlocalized type such as `newsroom.story` gets no canonical link, and no content type gets Open Graph tags. A plugin page cannot add them either, because its `head` only accepts `title`, `description`, `robots` and `alternates`. Keep `openGraph` in your definition so the data is ready, and read it from the resolve route if you render pages outside VitNode. </Callout> ## Hide a record from search engines @@ -96,7 +118,7 @@ The page's `head` calls `contentDeliveryPageHead` with the resolve route's answe - the page gets `<meta content="noindex, nofollow" name="robots"/>`; - the record leaves the [sitemap](#sitemap). -Only `true` hides a record. `null` and `false` both mean "index it", which is why `example.article` can make the field nullable without emptying its sitemap. The resolve route reports the decision as `"robots": { "follow": true, "index": false }`. +Only `true` hides a record. `null` and `false` both mean "index it", which is why `newsroom.story` can make the field nullable without emptying its sitemap. The resolve route reports the decision as `"robots": { "follow": true, "index": false }`. The field must be shared, not localized. A record has one indexing decision, and a per-language value would let the Polish page say `noindex` while the sitemap lists the English one. The definition refuses a localized `noIndexField` for that reason. @@ -130,7 +152,7 @@ delivery: { `delivery.sitemap` adds a public route that lists every published URL of the content type, oldest record first. Pass `true` or `{ changeFrequency, priority }`. It needs `publication: true`, since a sitemap lists only what readers can reach. ```bash -curl "http://localhost:3000/api/@vitnode/example/content/articles/delivery/sitemap?cursor=1&limit=1" +curl "http://localhost:3000/api/newsroom/content/stories/delivery/sitemap?cursor=1&limit=1" ``` ```json @@ -141,7 +163,7 @@ curl "http://localhost:3000/api/@vitnode/example/content/articles/delivery/sitem "itemId": 3, "lastModified": "2026-10-10T11:06:30.518Z", "locale": null, - "path": "/articles/hello-content-engine", + "path": "/stories/hello-content-engine", "priority": 0.7 } ], @@ -182,10 +204,16 @@ import { contentSitemapXml, isContentSitemapChangeFrequency, } from "@vitnode/core/content"; - -const response = await fetch( - "http://localhost:3000/api/@vitnode/example/content/articles/delivery/sitemap", -); +import { fetcher } from "@vitnode/core/tanstack/fetcher"; + +const response = await fetcher({ + plugin: "newsroom", + method: "get", + module: "content/stories", + path: "/delivery/sitemap", + args: { query: { limit: 1000 } }, +}); +if (!response.ok) throw new Error("Could not load the story sitemap."); const { entries } = await response.json(); const xml = contentSitemapXml({ @@ -200,19 +228,19 @@ const xml = contentSitemapXml({ }); ``` -With the example plugin's two published articles, `xml` is: +With the newsroom plugin's two published stories, `xml` is: ```xml <?xml version="1.0" encoding="UTF-8"?> <urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"> <url> - <loc>https://example.com/articles/jbbjk-1</loc> + <loc>https://example.com/stories/jbbjk-1</loc> <lastmod>2026-08-30T09:18:25.398Z</lastmod> <changefreq>weekly</changefreq> <priority>0.7</priority> </url> <url> - <loc>https://example.com/articles/hello-content-engine</loc> + <loc>https://example.com/stories/hello-content-engine</loc> <lastmod>2026-10-10T11:06:30.518Z</lastmod> <changefreq>weekly</changefreq> <priority>0.7</priority> @@ -224,19 +252,19 @@ With the example plugin's two published articles, `xml` is: ## Retired slugs -With `delivery.redirects`, an old slug answers a permanent `308` redirect to the record's current URL, so links and search results that still use the old address land on the new page. See [Keep old URLs working](/docs/content-engine/public-pages#keep-old-urls-working). +With `delivery.redirects`, an old slug answers a permanent `308` redirect to the record's current URL, so links and search results that still use the old address land on the new page. See [Keep old URLs working](/docs/content-engine/public-pages#optional-next-steps). ## Check the result -Print the SEO tags of an article and of a localized blog post: +Print the SEO tags of a story and of a localized blog post: ```bash -curl -s http://localhost:3000/articles/hello-content-engine | grep -oE '<title>[^<]*|]*name="(description|robots)"[^>]*>|]*rel="(canonical|alternate)"[^>]*>' +curl -s http://localhost:3000/stories/hello-content-engine | grep -oE '[^<]*|]*name="(description|robots)"[^>]*>|]*rel="(canonical|alternate)"[^>]*>' ``` ```html Hello Content Engine - VitNode - + ``` Run the same command for `http://localhost:3000/blog/article/english-title` and you get its title, description and the four links from [Localized alternates](#localized-alternates-and-hreflang). The SVG logo's `Logo VitNode` may show up too; it is not page metadata. @@ -244,7 +272,7 @@ Run the same command for `http://localhost:3000/blog/article/english-title` and Then ask the resolve route what the page was given, `openGraph` and `robots` included: ```bash -curl http://localhost:3000/api/@vitnode/example/content/articles/delivery/resolve/hello-content-engine +curl http://localhost:3000/api/newsroom/content/stories/delivery/resolve/hello-content-engine ``` -Set `noIndex` on a test article, publish it, and run both commands again. The page gains the `robots` tag, and the [sitemap](#sitemap) route stops listing it. +Set `noIndex` on a test story, publish it, and run both commands again. The page gains the `robots` tag, and the [sitemap](#sitemap) route stops listing it. diff --git a/apps/web/content/docs/content-engine/services.mdx b/apps/web/content/docs/content-engine/services.mdx index 22ed651594..0a459f2168 100644 --- a/apps/web/content/docs/content-engine/services.mdx +++ b/apps/web/content/docs/content-engine/services.mdx @@ -4,31 +4,31 @@ description: Read and write Content Engine records from your own Hono routes, cr icon: Server --- -The generated routes cover the usual reads and writes. When you need something they don't do, such as "the three newest articles in a category" or "publish a digest every Monday", call a **service** from your own code. It is a typed object you get from the model: +The generated routes cover the usual reads and writes. When you need something they don't do, such as "the three newest stories in a category" or "publish a digest every Monday", call a **service** from your own code. It is a typed object you get from the model: ```ts -const article = await articleContent.service(c).findById(1); -article?.title; // string, and a typo is a compile error +const story = await storyContent.service(c).findById(1); +story?.title; // string, and a typo is a compile error ``` -`articleContent` is the model from `createContentModel`, and `c` is the Hono context of your route, cron job or queue task. +`storyContent` is the model from `createContentModel`, and `c` is the Hono context of your route, cron job or queue task. ## Pick a service | You want to | Use | It checks permissions | It emits events and writes history | | ------------------------------------------------------------- | ------------------------------------------ | --------------------- | ---------------------------------- | -| Show content to visitors: published records, public fields | `articleContent.publicService(c)` | Only public data | Read only | -| Read or write anything from a job or a staff route | `articleContent.service(c)` | No | No | -| Save a change the way the AdminCP does | `articleContent.editorialService(c, opts)` | No | Revisions, and events through the effects helpers | +| Show content to visitors: published records, public fields | `storyContent.publicService(c)` | Only public data | Read only | +| Read or write anything from a job or a staff route | `storyContent.service(c)` | No | No | +| Save a change the way the AdminCP does | `storyContent.editorialService(c, opts)` | No | Revisions, and events through the effects helpers | ## Read published records `publicService` returns exactly what the [public API](/docs/content-engine/public-api) would, so a custom public route can never leak a draft: -```ts title="plugins/example/src/api/modules/articles/by-category.route.ts" +```ts title="plugins/newsroom/src/api/modules/stories/by-category.route.ts" handler: async c => { - const { publicService } = articleContent; - if (!publicService) throw new Error("Articles have no public API."); + const { publicService } = storyContent; + if (!publicService) throw new Error("Stories have no public API."); const { edges } = await publicService(c).findMany({ filters: { category: c.req.valid("param").categoryId }, @@ -46,19 +46,19 @@ handler: async c => { The plain `service` sees every record and checks nothing, which suits a [cron job](/docs/dev/cron): -```ts title="plugins/example/src/api/cron/weekly-digest.cron.ts" +```ts title="plugins/newsroom/src/api/cron/weekly-digest.cron.ts" export const weeklyDigestCron = buildCron({ name: "weekly-digest", schedule: "0 8 * * 1", handler: async c => { - const service = articleContent.service(c); - const article = await service.create({ + const service = storyContent.service(c); + const story = await service.create({ title: "Weekly digest", code: `digest-${Date.now()}`, category: 1, gallery: [21], }); - await service.publish(article.id); + await service.publish(story.id); }, }); ``` @@ -73,15 +73,15 @@ export const weeklyDigestCron = buildCron({ `editorialService` (with [`editorial`](/docs/content-engine/editorial)) writes a revision and refuses to overwrite a newer save. Run the effects after it, so events, search and URLs follow: -```ts title="plugins/example/src/api/modules/admin/routes/feature.route.ts" +```ts title="plugins/newsroom/src/api/modules/admin/routes/feature.route.ts" import { contentEditorialEffects, resolveContentActor, withHttpErrors, } from "@vitnode/core/content/server"; -const { editorialService } = articleContent; -if (!editorialService) throw new Error("Articles have no editorial."); +const { editorialService } = storyContent; +if (!editorialService) throw new Error("Stories have no editorial."); const outcome = await withHttpErrors( "update", @@ -91,12 +91,12 @@ const outcome = await withHttpErrors( { featured: true }, { actor: resolveContentActor(c), expectedVersion }, ), - { contentTypeId: articleContent.definition.id, itemId: id, structured: true }, + { contentTypeId: storyContent.definition.id, itemId: id, structured: true }, ); if (outcome) { - await contentEditorialEffects(c, articleContent.definition, outcome, { - model: articleContent, + await contentEditorialEffects(c, storyContent.definition, outcome, { + model: storyContent, pluginId: manifest.id, }); } @@ -104,7 +104,7 @@ if (outcome) { - `expectedVersion` is the version the page loaded. A newer save turns the call into a `409 CONTENT_VERSION_CONFLICT`, which `withHttpErrors` answers for you. - `resolveContentActor(c)` names the signed-in user on the revision. A job passes `CONTENT_SYSTEM_ACTOR`. -- Protect a staff route with `adminStaffPermission: { module: "article", permission: "can_edit" }`. +- Protect a staff route with `adminStaffPermission: { module: "story", permission: "can_edit" }`. Hiding and duplicating work the same way, with `hide`, `unhide` and `duplicate` and the `contentVisibilityEffects` and `contentDuplicateEffects` helpers. They exist only when `visibility` or `duplication` is on. @@ -141,4 +141,4 @@ A [localized](/docs/content-engine/localization) content type also has `localize ### Schemas -`articleContent.schemas` holds the Zod schemas the generated routes use. Reuse them so a custom route accepts and returns the same shapes: `create`, `update`, `updateEnvelope`, `select`, `publicSelect`, `filters`, `publicFilters`, `order`, `params`, `publicParams` and `form`. +`storyContent.schemas` holds the Zod schemas the generated routes use. Reuse them so a custom route accepts and returns the same shapes: `create`, `update`, `updateEnvelope`, `select`, `publicSelect`, `filters`, `publicFilters`, `order`, `params`, `publicParams` and `form`. diff --git a/apps/web/content/docs/content-engine/threads-comments.webp b/apps/web/content/docs/content-engine/threads-comments.webp new file mode 100644 index 0000000000..07f20dd42e Binary files /dev/null and b/apps/web/content/docs/content-engine/threads-comments.webp differ diff --git a/apps/web/content/docs/content-engine/threads-editing-form.webp b/apps/web/content/docs/content-engine/threads-editing-form.webp new file mode 100644 index 0000000000..0c21691c34 Binary files /dev/null and b/apps/web/content/docs/content-engine/threads-editing-form.webp differ diff --git a/apps/web/content/docs/content-engine/threads-editing.mdx b/apps/web/content/docs/content-engine/threads-editing.mdx new file mode 100644 index 0000000000..329b1b86f7 --- /dev/null +++ b/apps/web/content/docs/content-engine/threads-editing.mdx @@ -0,0 +1,683 @@ +--- +title: Comment editing +description: Let VitNode Content Engine comment authors fix their comments. Ask useContentPermissions who may edit, load the editable text, save it with PATCH and refresh the thread, with the concurrency behavior that applies without editorial. +icon: MessageSquareText +implementation: + objective: Let members edit their own comments in an existing comment section. + decisions: + - Should two saves of the same comment be checked against each other? That needs `editorial` on the comment type; without it, the last save wins. + constraints: + - Show Edit only when `useContentPermissions` answers `canEdit`, with one hook call for every comment on screen. + - Load the text with `GET /{id}/edit` before editing, save with `PATCH /{id}` through `useContentPublicWrite`, then invalidate the thread query. + related: + - title: Comments + href: /docs/content-engine/threads + - title: Replies + href: /docs/content-engine/threads-frontend + - title: Permissions API + href: /docs/content-engine/public-permissions-api +--- + +import { Accordion, Accordions } from "fumadocs-ui/components/accordion"; + +import { ImgDocs } from "@/components/fumadocs/img"; +import editing from "./threads-editing-form.webp"; + +This page adds **Edit** to the comments a visitor may change, opens an editor in place, and refreshes the thread after a save. Authors edit their own comments; moderators with `can_edit` edit anyone's. + +This continues the working comment section from [Comments](/docs/content-engine/threads). Its comment type already has `update: { fields: ["body"] }`, so `PATCH /comments/{id}` and `GET /comments/{id}/edit` exist. + + + + + +### Ask who may edit + +Create `src/pages/comment-permissions.ts`. This is the complete file, like the article's in [Permissions](/docs/content-engine/public-permissions): + +```ts title="plugins/newsroom/src/pages/comment-permissions.ts" +import { useContentPermissions } from "@vitnode/core/tanstack/content"; +import { fetcher } from "@vitnode/core/tanstack/fetcher"; + +import { manifest } from "@/manifest.gen"; + +/** What the visitor may do with each comment on screen: `canEdit`, `canReport`. */ +export const useCommentPermissions = (ids: readonly number[]) => + useContentPermissions({ + ids, + queryKey: [manifest.id, "comments"], + fetch: async request => { + const res = await fetcher({ + plugin: manifest.id, + method: "get", + module: "content/comments", + path: "/permissions", + args: { query: { ids: request.join(",") } }, + }); + if (res.status !== 200) throw new Error("Loading permissions failed."); + + return await res.json(); + }, + }); +``` + +`GET /comments/permissions` answers `canEdit` per comment for the visitor who asks. It is `true` for the author while the rules allow it, and for a moderator with `can_edit`. `queryKey` starts with the comments' prefix, so invalidating `[manifest.id, "comments"]` refreshes the answers too. + + + + + +### Write the editor + +Create `src/pages/comment-editor.tsx`. This is the complete file. It edits one comment's body and reports whether the save waits for a moderator: + +```tsx title="plugins/newsroom/src/pages/comment-editor.tsx" +import { + AutoForm, + type AutoFormOnSubmit, + AutoFormSubmitButton, +} from "@vitnode/core/components/form/auto-form"; +import { AutoFormEditor } from "@vitnode/core/components/form/fields/editor"; +import { Button } from "@vitnode/core/components/ui/button"; +import { + isRichTextEmpty, + type RichTextDocument, + zodRichTextDocument, +} from "@vitnode/core/content"; +import { useContentPublicWrite } from "@vitnode/core/tanstack/content"; +import { fetcher } from "@vitnode/core/tanstack/fetcher"; +import { useTranslations } from "use-intl"; +import { z } from "zod"; + +import { manifest } from "@/manifest.gen"; + +export interface CommentEditorProps { + /** The comment's latest text, from `GET /comments/{id}/edit`. */ + body: RichTextDocument; + commentId: number; + label: string; + onCancel: () => void; + /** Called after a save, with whether it waits for a moderator. */ + onSaved: (pending: boolean) => Promise; +} + +const createSchema = (body: RichTextDocument, required: string) => + z.object({ + body: zodRichTextDocument + .refine(doc => !isRichTextEmpty(doc), required) + .default(body), + }); + +const CommentEditor = ({ + body, + commentId, + label, + onCancel, + onSaved, +}: CommentEditorProps) => { + const t = useTranslations("newsroom.comments.form"); + const write = useContentPublicWrite({ fields: ["body"] }); + + const onSubmit: AutoFormOnSubmit> = async ( + values, + form, + ) => { + const saved = await write.submit( + form, + async () => + await fetcher({ + plugin: manifest.id, + method: "patch", + module: "content/comments", + path: "/{id}", + args: { params: { id: commentId }, body: { values } }, + }), + ); + if (!saved) return; + + await onSaved(saved.approval?.status === "pending"); + }; + + return ( + ( + + ), + }, + ]} + formSchema={createSchema(body, t("required"))} + layout={fields => <>{fields.body}} + onSubmit={onSubmit} + > +
+ {t("save")} + +
+
+ ); +}; + +export default CommentEditor; +``` + +- **The request** sends only `body` to `PATCH /comments/{id}`. Members can never move a comment, because `article` and `parent` are not in `update.fields`. +- **`write.submit`** shows a refusal as a toast and returns `null`, so the editor stays open with the text. +- **`onSaved(pending)`** is `true` only when the comment lives in a container that holds edits for approval. For newsroom comments it is always `false`. + +
+ + + +### Show Edit and load the comment + +Edit `src/pages/comment-section.tsx`. Add the imports, load the editor lazily next to the composer, and add a `CommentItem` component above `CommentSection`. These are excerpts: + +```tsx title="plugins/newsroom/src/pages/comment-section.tsx (excerpt)" +import { type RichTextDocument } from "@vitnode/core/content"; // [!code ++] +import { fetcher } from "@vitnode/core/tanstack/fetcher"; // [!code ++] + +import { manifest } from "@/manifest.gen"; // [!code ++] + +import type { CommentEditorProps } from "./comment-editor"; // [!code ++] + +import { useCommentPermissions } from "./comment-permissions"; // [!code ++] + +const CommentComposer = lazy(async () => await import("./comment-composer")); +const CommentEditor = lazy(async () => await import("./comment-editor")); // [!code ++] +``` + +```tsx title="plugins/newsroom/src/pages/comment-section.tsx (excerpt)" +/** A comment, with Edit for the visitors who may use it. */ +const CommentItem = ({ + canEdit, + comment, + onSaved, +}: { + canEdit: boolean; + comment: ArticleComment; + onSaved: CommentEditorProps["onSaved"]; +}) => { + const t = useTranslations("newsroom.comments"); + const tWrite = useTranslations("core.global.content_write"); + const [editing, setEditing] = useState(null); + const [isOpening, setIsOpening] = useState(false); + + // Opens the editor on the comment's latest text, not on the cached copy. + const openEditor = async () => { + setIsOpening(true); + try { + const res = await fetcher({ + plugin: manifest.id, + method: "get", + module: "content/comments", + path: "/{id}/edit", + args: { params: { id: comment.id } }, + }); + if (res.status !== 200) { + const reason = + res.status === 401 + ? "unauthenticated" + : res.status === 403 + ? "forbidden" + : res.status === 404 + ? "not_found" + : "unknown"; + toast.error(tWrite("title"), { description: tWrite(reason) }); + + return; + } + + setEditing((await res.json()).values.body); + } finally { + setIsOpening(false); + } + }; + + if (editing) { + return ( +
+

+ {comment.author?.name ?? t("someone")} +

+ }> + { + setEditing(null); + }} + onSaved={async pending => { + setEditing(null); + await onSaved(pending); + }} + /> + +
+ ); + } + + return ( +
+ + {canEdit ? ( + + ) : null} +
+ ); +}; +``` + +- **Edit** first loads `GET /comments/{id}/edit`, so the editor opens on the latest saved text, not on the copy the thread cached. The button shows a spinner while it loads, so a second click cannot open it twice. +- A refusal, such as a session that expired in the meantime, becomes a toast with core's message, and the comment stays as it was. +- While editing, the comment's text is replaced by the editor, with **Save** and **Cancel**. + +
+ + + +### Save and refresh the section + +In `CommentSection`, ask for the permissions of every comment on screen, add a handler for saved edits, and render `CommentItem` instead of `CommentBody`. These are excerpts: + +```tsx title="plugins/newsroom/src/pages/comment-section.tsx (excerpt)" + const items = useMemo( + () => pages?.flatMap(page => page.items) ?? [], + [pages], + ); + // [!code ++:4] + // One request for every comment on screen, batched by the hook. + const permissions = useCommentPermissions( + items.flatMap(item => (item.record ? [item.id] : [])), + ); + + // [!code ++:12] + const onEdited = async (pending: boolean) => { + if (pending) { + toast.info(t("edit_pending.title"), { + description: t("edit_pending.desc"), + }); + } else { + toast.success(t("edited.title"), { description: t("edited.desc") }); + } + await queryClient.invalidateQueries({ + queryKey: commentsQueryKey(articleId), + }); + }; +``` + +```tsx title="plugins/newsroom/src/pages/comment-section.tsx (excerpt)" + {items.map(item => + item.record ? ( +
  • + {/* [!code ++:5] */} + +
  • + ) : null, + )} +``` + +- **`useCommentPermissions`** gets every comment with a record in one call. It sends at most 50 ids per request and keeps earlier answers when **Load more comments** adds ids, as [Permissions API](/docs/content-engine/public-permissions-api#batches) describes. `of(id)` is all `false` until the answer arrives, so **Edit** appears a moment after the comments. +- **`onEdited`** invalidates the article's comment query, which refetches every loaded page, so the new text shows in place. + +
    + + + +### Add the labels + +Add these keys to the `comments` group in `src/locales/en.json`: + +```json title="plugins/newsroom/src/locales/en.json" +{ + "newsroom": { + "comments": { + "form": { "edit_label": "Edit your comment", "save": "Save", "cancel": "Cancel" }, + "edit": "Edit", + "edited": { "title": "Comment saved", "desc": "Your changes are live." }, + "edit_pending": { + "title": "Your changes are waiting for a moderator", + "desc": "Readers see the earlier text until a moderator approves." + } + } + } +} +``` + + + +
    + + + + +```tsx title="plugins/newsroom/src/pages/comment-section.tsx" +import { useInfiniteQuery, useQueryClient } from "@tanstack/react-query"; +import { RichTextContent } from "@vitnode/core/components/rich-text"; +import { Button, buttonVariants } from "@vitnode/core/components/ui/button"; +import { Skeleton } from "@vitnode/core/components/ui/skeleton"; +import { type RichTextDocument } from "@vitnode/core/content"; +import { useSessionQuery } from "@vitnode/core/tanstack/auth"; +import { fetcher } from "@vitnode/core/tanstack/fetcher"; +import { HrefLink } from "@vitnode/core/tanstack/link"; +import { lazy, Suspense, useMemo, useState } from "react"; +import { toast } from "sonner"; +import { useTranslations } from "use-intl"; + +import { manifest } from "@/manifest.gen"; + +import type { CommentComposerProps, CommentOutcome } from "./comment-composer"; +import type { ArticleComment } from "./comment-data"; +import type { CommentEditorProps } from "./comment-editor"; + +import { commentsQueryKey, commentsQueryOptions } from "./comment-data"; +import { useCommentPermissions } from "./comment-permissions"; + +// The editor is heavy and most readers never write, so it loads on demand. +const CommentComposer = lazy(async () => await import("./comment-composer")); +const CommentEditor = lazy(async () => await import("./comment-editor")); + +const Composer = (props: CommentComposerProps) => ( + }> + + +); + +/** Who wrote a comment, and what they said. */ +const CommentBody = ({ comment }: { comment: ArticleComment }) => { + const t = useTranslations("newsroom.comments"); + + return ( +
    +

    + {comment.author?.name ?? t("someone")} +

    + +
    + ); +}; + +/** A comment, with Edit for the visitors who may use it. */ +const CommentItem = ({ + canEdit, + comment, + onSaved, +}: { + canEdit: boolean; + comment: ArticleComment; + onSaved: CommentEditorProps["onSaved"]; +}) => { + const t = useTranslations("newsroom.comments"); + const tWrite = useTranslations("core.global.content_write"); + const [editing, setEditing] = useState(null); + const [isOpening, setIsOpening] = useState(false); + + // Opens the editor on the comment's latest text, not on the cached copy. + const openEditor = async () => { + setIsOpening(true); + try { + const res = await fetcher({ + plugin: manifest.id, + method: "get", + module: "content/comments", + path: "/{id}/edit", + args: { params: { id: comment.id } }, + }); + if (res.status !== 200) { + const reason = + res.status === 401 + ? "unauthenticated" + : res.status === 403 + ? "forbidden" + : res.status === 404 + ? "not_found" + : "unknown"; + toast.error(tWrite("title"), { description: tWrite(reason) }); + + return; + } + + setEditing((await res.json()).values.body); + } finally { + setIsOpening(false); + } + }; + + if (editing) { + return ( +
    +

    + {comment.author?.name ?? t("someone")} +

    + }> + { + setEditing(null); + }} + onSaved={async pending => { + setEditing(null); + await onSaved(pending); + }} + /> + +
    + ); + } + + return ( +
    + + {canEdit ? ( + + ) : null} +
    + ); +}; + +export const CommentSection = ({ + articleId, + articleSlug, +}: { + articleId: number; + articleSlug: string; +}) => { + const t = useTranslations("newsroom.comments"); + const queryClient = useQueryClient(); + const session = useSessionQuery(); + const [composerKey, setComposerKey] = useState(0); + const comments = useInfiniteQuery(commentsQueryOptions(articleId)); + + const pages = comments.data?.pages; + const items = useMemo( + () => pages?.flatMap(page => page.items) ?? [], + [pages], + ); + // One request for every comment on screen, batched by the hook. + const permissions = useCommentPermissions( + items.flatMap(item => (item.record ? [item.id] : [])), + ); + + const onPosted = async (outcome: CommentOutcome) => { + if (outcome !== "published") { + // Not in the public thread, so there is nothing to refresh. + toast.info(t(`${outcome}.title`), { description: t(`${outcome}.desc`) }); + + return; + } + + toast.success(t("created.title"), { description: t("created.desc") }); + // Refetches every loaded page in order, with the new count. + await queryClient.invalidateQueries({ + queryKey: commentsQueryKey(articleId), + }); + }; + + const onEdited = async (pending: boolean) => { + if (pending) { + toast.info(t("edit_pending.title"), { + description: t("edit_pending.desc"), + }); + } else { + toast.success(t("edited.title"), { description: t("edited.desc") }); + } + await queryClient.invalidateQueries({ + queryKey: commentsQueryKey(articleId), + }); + }; + + return ( +
    +

    + {t("title", { count: pages?.[0]?.replyCount ?? 0 })} +

    + + {comments.isPending ? ( +
    + + {t("loading")} + + {[0, 1].map(key => ( +
    + + +
    + ))} +
    + ) : comments.isError && items.length === 0 ? ( +
    +

    + {t("load_failed.desc")} +

    + +
    + ) : items.length === 0 ? ( +

    {t("empty")}

    + ) : ( +
      + {items.map(item => + item.record ? ( +
    1. + +
    2. + ) : null, + )} +
    + )} + + {comments.hasNextPage ? ( + + ) : null} + + {session.data?.user ? ( + { + setComposerKey(key => key + 1); + await onPosted(outcome); + }} + parentId={null} + /> + ) : ( + + {t("sign_in")} + + )} +
    + ); +}; +``` + +
    +
    + +## Check the result + +Open the article with comments from two members, `/news/welcome-to-the-newsroom` from [Comments](/docs/content-engine/threads#check-the-result): + +1. Sign in as one of them. **Edit** shows under your own comment only. +2. Click **Edit**, change the text and click **Save**. **Comment saved** appears and the new text shows in place. +3. Sign in as the other member, or open the page signed out. There is no **Edit** on comments you did not write. + + + +## Two saves of the same comment + +Newsroom comments have no `editorial`, so they have no `version`. `PATCH /comments/{id}` takes `{ "values": { "body": ... } }` and nothing else. When two tabs save the same comment, both answer `200` and the last save wins. Sending an `expectedVersion` anyway answers `400` with `Unrecognized key: "expectedVersion"`. + +For short comments that is usually fine. To stop silent overwrites, add `editorial: true` to the comment type and migrate. Every comment then gets a `version`, `GET /{id}/edit` returns it, and a stale save answers `409 CONTENT_VERSION_CONFLICT`. Pass `version` and `latestVersion` to `useContentPublicWrite`, send `expectedVersion` from `write.submit`, and render `write.conflict` above the editor, as the article's [Edit form](/docs/content-engine/public-write-edit#handle-version-conflicts) does. + +Next, [Replies](/docs/content-engine/threads-frontend) lets comments answer each other. diff --git a/apps/web/content/docs/content-engine/threads-frontend-tree.webp b/apps/web/content/docs/content-engine/threads-frontend-tree.webp index 0ef68dedc4..95b1eb4e3f 100644 Binary files a/apps/web/content/docs/content-engine/threads-frontend-tree.webp and b/apps/web/content/docs/content-engine/threads-frontend-tree.webp differ diff --git a/apps/web/content/docs/content-engine/threads-frontend.mdx b/apps/web/content/docs/content-engine/threads-frontend.mdx new file mode 100644 index 0000000000..c6dd0a0b67 --- /dev/null +++ b/apps/web/content/docs/content-engine/threads-frontend.mdx @@ -0,0 +1,702 @@ +--- +title: Replies +description: Let VitNode Content Engine comments answer each other. Raise thread.maxDepth, build the reply tree from the flat thread, add Reply with the parent id, keep removed comments as placeholders, and handle pagination, truncation and the depth limit. +icon: MessagesSquare +implementation: + objective: Turn flat comments into threaded replies on an existing comment section. + decisions: + - How deep can replies nest? From 1 (flat) to 32 levels; recommend 4 to 6. + constraints: + - 'Raise `thread.maxDepth` and build the tree from `parentId`; render `removed: true` items as placeholders that keep their replies.' + - Send `parent` with each reply; the server checks the parent and the depth. + related: + - title: Comments + href: /docs/content-engine/threads + - title: Comment editing + href: /docs/content-engine/threads-editing + - title: thread reference + href: /docs/content-engine/reference#thread +--- + +import { Accordion, Accordions } from "fumadocs-ui/components/accordion"; + +import { ImgDocs } from "@/components/fumadocs/img"; +import tree from "./threads-frontend-tree.webp"; + +Replies let a comment answer another comment, so a conversation can branch. The thread route already returns each top-level comment followed by every answer below it; this page draws that as a tree, adds **Reply**, and keeps the shape when a comment in the middle is removed. + +This continues the flat comment section from [Comments](/docs/content-engine/threads), with **Edit** from [Comment editing](/docs/content-engine/threads-editing). If you skipped editing, render `CommentBody` where this page renders `CommentItem`, and leave `onEdited` and `permissions` out. + + + + + +### Raise the depth + +Edit `src/content/comment.ts`. Export the limit, so the section can hide **Reply** where the server would refuse it. These are excerpts: + +```ts title="plugins/newsroom/src/content/comment.ts" +/** How deep replies nest. The section hides **Reply** at this depth. */ // [!code ++:2] +export const COMMENT_MAX_DEPTH = 4; + + thread: { parent: "parent", root: "article", maxDepth: COMMENT_MAX_DEPTH }, // [!code highlight] +``` + +`maxDepth` only changes what the server accepts, so there is nothing to migrate. Depth 1 is a top-level comment; with `4`, a reply to a reply to a reply is the deepest. + + + + + +### Build the tree + +Add a tree builder to the end of `src/pages/comment-data.ts`. This is an excerpt: + +```ts title="plugins/newsroom/src/pages/comment-data.ts" +export interface CommentNode extends CommentItem { + children: CommentNode[]; +} + +/** The flat, depth-first answer as a tree: a parent always comes before its replies. */ +export const buildCommentTree = ( + items: readonly CommentItem[], +): CommentNode[] => { + const nodes = new Map(); + const roots: CommentNode[] = []; + + for (const item of items) { + const node: CommentNode = { ...item, children: [] }; + nodes.set(item.id, node); + const parent = + item.parentId === null ? undefined : nodes.get(item.parentId); + if (parent) parent.children.push(node); + else roots.push(node); + } + + return roots; +}; +``` + +The thread route returns comments depth-first, so every reply comes after its parent. One pass with a `Map` is enough. If a reply's parent were ever missing from the page, the reply would fall back to the top level instead of disappearing. + + + + + +### Render a branch with Reply + +Edit `src/pages/comment-section.tsx`. Import the limit and the tree, then add `CommentBranch` above `CommentSection`. These are excerpts: + +```tsx title="plugins/newsroom/src/pages/comment-section.tsx (excerpt)" +import { COMMENT_MAX_DEPTH } from "@/content/comment"; // [!code ++] + +import type { ArticleComment, CommentNode } from "./comment-data"; // [!code highlight] + +import { + buildCommentTree, // [!code ++] + commentsQueryKey, + commentsQueryOptions, +} from "./comment-data"; +``` + +```tsx title="plugins/newsroom/src/pages/comment-section.tsx (excerpt)" +/** What every comment in the tree needs from the section. */ +interface CommentThread { + articleId: number; + canReply: boolean; + onEdited: CommentEditorProps["onSaved"]; + onPosted: CommentComposerProps["onPosted"]; + permissions: ReturnType; +} + +/** A comment with its Reply form, followed by every answer below it. */ +const CommentBranch = ({ + node, + thread, +}: { + node: CommentNode; + thread: CommentThread; +}) => { + const t = useTranslations("newsroom.comments"); + const [replying, setReplying] = useState(false); + + return ( +
  • + {node.record ? ( + <> + + {thread.canReply && node.depth < COMMENT_MAX_DEPTH ? ( + + ) : null} + {replying ? ( +
    + { + setReplying(false); + await thread.onPosted(outcome); + }} + parentId={node.id} + /> + +
    + ) : null} + + ) : ( + // Hidden, but its public answers still need a parent to hang from. +

    {t("removed")}

    + )} + + {node.children.length > 0 ? ( +
      + {node.children.map(child => ( + + ))} +
    + ) : null} +
  • + ); +}; +``` + +- **Reply** opens a composer for that comment with `parentId={node.id}`, the same composer that posts top-level comments. `aria-expanded` tells screen readers whether the form is open, and **Cancel** closes it. +- **Reply is hidden at `COMMENT_MAX_DEPTH`**, where the server would answer `400`. It is also hidden for guests. +- **Removed comments.** A hidden comment that still has public answers comes back as `removed: true` with `record: null`. It renders as **This comment was removed.**, keeps its answers below it, and offers no **Reply** or **Edit**. +- **Answers** are a nested `
      ` with a left border, indented less on phones. + + + + + +### Render the tree in the section + +In `CommentSection`, build the tree, collect what each branch needs, render the branches instead of the flat list, and say when a page was cut short. These are excerpts: + +```tsx title="plugins/newsroom/src/pages/comment-section.tsx (excerpt)" + const tree = useMemo(() => buildCommentTree(items), [items]); // [!code ++] + + // [!code ++:7] + const thread: CommentThread = { + articleId, + canReply: Boolean(session.data?.user), + onEdited, + onPosted, + permissions, + }; +``` + +```tsx title="plugins/newsroom/src/pages/comment-section.tsx (excerpt)" +
        + {/* [!code ++:3] */} + {tree.map(node => ( + + ))} +
      + )} + + {/* [!code ++:3] */} + {pages?.some(page => page.truncated) ? ( +

      {t("truncated")}

      + ) : null} +``` + +A posted reply goes through the same `onPosted` as a top-level comment. A published reply refreshes the thread, and a draft or pending one only shows its message. The top-level composer keeps its text, because only its own `onPosted` resets its key. + +
      + + + +### Add the labels + +Add these keys to the `comments` group in `src/locales/en.json`. `form.cancel` is the same key [Comment editing](/docs/content-engine/threads-editing) added: + +```json title="plugins/newsroom/src/locales/en.json" +{ + "newsroom": { + "comments": { + "form": { "reply_label": "Reply to {name}", "cancel": "Cancel" }, + "reply": "Reply", + "removed": "This comment was removed.", + "truncated": "This conversation is long, so some deeper replies are not shown." + } + } +} +``` + + + + + + + + +```tsx title="plugins/newsroom/src/pages/comment-section.tsx" +import { useInfiniteQuery, useQueryClient } from "@tanstack/react-query"; +import { RichTextContent } from "@vitnode/core/components/rich-text"; +import { Button, buttonVariants } from "@vitnode/core/components/ui/button"; +import { Skeleton } from "@vitnode/core/components/ui/skeleton"; +import { type RichTextDocument } from "@vitnode/core/content"; +import { useSessionQuery } from "@vitnode/core/tanstack/auth"; +import { fetcher } from "@vitnode/core/tanstack/fetcher"; +import { HrefLink } from "@vitnode/core/tanstack/link"; +import { lazy, Suspense, useMemo, useState } from "react"; +import { toast } from "sonner"; +import { useTranslations } from "use-intl"; + +import { COMMENT_MAX_DEPTH } from "@/content/comment"; +import { manifest } from "@/manifest.gen"; + +import type { CommentComposerProps, CommentOutcome } from "./comment-composer"; +import type { ArticleComment, CommentNode } from "./comment-data"; +import type { CommentEditorProps } from "./comment-editor"; + +import { + buildCommentTree, + commentsQueryKey, + commentsQueryOptions, +} from "./comment-data"; +import { useCommentPermissions } from "./comment-permissions"; + +// The editor is heavy and most readers never write, so it loads on demand. +const CommentComposer = lazy(async () => await import("./comment-composer")); +const CommentEditor = lazy(async () => await import("./comment-editor")); + +const Composer = (props: CommentComposerProps) => ( + }> + + +); + +/** Who wrote a comment, and what they said. */ +const CommentBody = ({ comment }: { comment: ArticleComment }) => { + const t = useTranslations("newsroom.comments"); + + return ( +
      +

      + {comment.author?.name ?? t("someone")} +

      + +
      + ); +}; + +/** A comment, with Edit for the visitors who may use it. */ +const CommentItem = ({ + canEdit, + comment, + onSaved, +}: { + canEdit: boolean; + comment: ArticleComment; + onSaved: CommentEditorProps["onSaved"]; +}) => { + const t = useTranslations("newsroom.comments"); + const tWrite = useTranslations("core.global.content_write"); + const [editing, setEditing] = useState(null); + const [isOpening, setIsOpening] = useState(false); + + // Opens the editor on the comment's latest text, not on the cached copy. + const openEditor = async () => { + setIsOpening(true); + try { + const res = await fetcher({ + plugin: manifest.id, + method: "get", + module: "content/comments", + path: "/{id}/edit", + args: { params: { id: comment.id } }, + }); + if (res.status !== 200) { + const reason = + res.status === 401 + ? "unauthenticated" + : res.status === 403 + ? "forbidden" + : res.status === 404 + ? "not_found" + : "unknown"; + toast.error(tWrite("title"), { description: tWrite(reason) }); + + return; + } + + setEditing((await res.json()).values.body); + } finally { + setIsOpening(false); + } + }; + + if (editing) { + return ( +
      +

      + {comment.author?.name ?? t("someone")} +

      + }> + { + setEditing(null); + }} + onSaved={async pending => { + setEditing(null); + await onSaved(pending); + }} + /> + +
      + ); + } + + return ( +
      + + {canEdit ? ( + + ) : null} +
      + ); +}; + +/** What every comment in the tree needs from the section. */ +interface CommentThread { + articleId: number; + canReply: boolean; + onEdited: CommentEditorProps["onSaved"]; + onPosted: CommentComposerProps["onPosted"]; + permissions: ReturnType; +} + +/** A comment with its Reply form, followed by every answer below it. */ +const CommentBranch = ({ + node, + thread, +}: { + node: CommentNode; + thread: CommentThread; +}) => { + const t = useTranslations("newsroom.comments"); + const [replying, setReplying] = useState(false); + + return ( +
    1. + {node.record ? ( + <> + + {thread.canReply && node.depth < COMMENT_MAX_DEPTH ? ( + + ) : null} + {replying ? ( +
      + { + setReplying(false); + await thread.onPosted(outcome); + }} + parentId={node.id} + /> + +
      + ) : null} + + ) : ( + // Hidden, but its public answers still need a parent to hang from. +

      {t("removed")}

      + )} + + {node.children.length > 0 ? ( +
        + {node.children.map(child => ( + + ))} +
      + ) : null} +
    2. + ); +}; + +export const CommentSection = ({ + articleId, + articleSlug, +}: { + articleId: number; + articleSlug: string; +}) => { + const t = useTranslations("newsroom.comments"); + const queryClient = useQueryClient(); + const session = useSessionQuery(); + const [composerKey, setComposerKey] = useState(0); + const comments = useInfiniteQuery(commentsQueryOptions(articleId)); + + const pages = comments.data?.pages; + const items = useMemo( + () => pages?.flatMap(page => page.items) ?? [], + [pages], + ); + const tree = useMemo(() => buildCommentTree(items), [items]); + // One request for every comment on screen, batched by the hook. + const permissions = useCommentPermissions( + items.flatMap(item => (item.record ? [item.id] : [])), + ); + + const onPosted = async (outcome: CommentOutcome) => { + if (outcome !== "published") { + // Not in the public thread, so there is nothing to refresh. + toast.info(t(`${outcome}.title`), { description: t(`${outcome}.desc`) }); + + return; + } + + toast.success(t("created.title"), { description: t("created.desc") }); + // Refetches every loaded page in order, with the new count. + await queryClient.invalidateQueries({ + queryKey: commentsQueryKey(articleId), + }); + }; + + const onEdited = async (pending: boolean) => { + if (pending) { + toast.info(t("edit_pending.title"), { + description: t("edit_pending.desc"), + }); + } else { + toast.success(t("edited.title"), { description: t("edited.desc") }); + } + await queryClient.invalidateQueries({ + queryKey: commentsQueryKey(articleId), + }); + }; + + const thread: CommentThread = { + articleId, + canReply: Boolean(session.data?.user), + onEdited, + onPosted, + permissions, + }; + + return ( +
      +

      + {t("title", { count: pages?.[0]?.replyCount ?? 0 })} +

      + + {comments.isPending ? ( +
      + + {t("loading")} + + {[0, 1].map(key => ( +
      + + +
      + ))} +
      + ) : comments.isError && items.length === 0 ? ( +
      +

      + {t("load_failed.desc")} +

      + +
      + ) : items.length === 0 ? ( +

      {t("empty")}

      + ) : ( +
        + {tree.map(node => ( + + ))} +
      + )} + + {pages?.some(page => page.truncated) ? ( +

      {t("truncated")}

      + ) : null} + + {comments.hasNextPage ? ( + + ) : null} + + {session.data?.user ? ( + { + setComposerKey(key => key + 1); + await onPosted(outcome); + }} + parentId={null} + /> + ) : ( + + {t("sign_in")} + + )} +
      + ); +}; +``` + +
      +
      + +## Check the result + +Open `/news/welcome-to-the-newsroom` as a member with a comment from another member below it: + +1. Click **Reply** under it, write an answer and click **Post comment**. The answer shows indented under the comment. +2. Reply to the answer, and to that answer. At depth 4, **Reply** is gone. +3. In **AdminCP → Newsroom → Comments**, hide a comment that has answers. Its place shows **This comment was removed.**, with its answers still below it, and it has no **Reply**. + + + +The thread route shows the same shape, with comment 3 hidden: + +```bash +curl "http://localhost:3000/api/newsroom/content/comments/thread?root=1" +``` + +```json title="200 (trimmed)" +{ + "items": [ + { "depth": 1, "id": 1, "parentId": null, "removed": false, "record": { "id": 1 } }, + { "depth": 2, "id": 10, "parentId": 1, "removed": false, "record": { "id": 10 } }, + { "depth": 1, "id": 2, "parentId": null, "removed": false, "record": { "id": 2 } }, + { "depth": 2, "id": 3, "parentId": 2, "removed": true, "record": null }, + { "depth": 3, "id": 4, "parentId": 3, "removed": false, "record": { "id": 4 } }, + { "depth": 4, "id": 5, "parentId": 4, "removed": false, "record": { "id": 5 } } + ], + "pageInfo": { "endCursor": "WyIyMDI2LTEwLTIzIDE5OjExOjIyLjAwMTIxOSIsMl0", "hasNextPage": false, "totalCount": 2 }, + "replyCount": 5, + "truncated": false +} +``` + +`totalCount` counts top-level comments; `replyCount` counts every public comment, so the heading reads **5 comments**. Comment 10 is the reply from step 1, listed right after its parent. A reply to comment 5, at depth 4, answers: + +```json title="400" +{ + "code": "CONTENT_INVALID_INPUT", + "contentTypeId": "newsroom.comment", + "issues": [ + { + "code": "unavailable", + "field": "parent", + "message": "Replies nest at most 4 levels deep. Answer a reply higher up." + } + ] +} +``` + +## Pages and long conversations + +- **Pages are top-level comments.** `first` counts top-level comments, and each comes with every reply below it. **Load more comments** adds the next ones; replies never split across pages. +- **Truncated pages.** One page holds at most 500 comments in total. When deep replies do not fit, `truncated` is `true` and the section says so instead of pretending the conversation ends there. +- **New top-level comments** land at the end, because comments are oldest first. While pages remain, **Load more comments** reaches them. +- **Permissions** are still one hook call for every loaded comment. It splits more than 50 ids into batches, so a long thread keeps its **Edit** buttons. + +The server keeps the tree valid on every write, in the AdminCP too: the parent must exist, be public and belong to the same article; a comment never answers itself or one of its own answers; and nothing nests deeper than `maxDepth`. The Forum plugin's `plugins/forum/src/pages/thread.tsx` builds the same thread with collapsible branches and permalinks. diff --git a/apps/web/content/docs/content-engine/threads.mdx b/apps/web/content/docs/content-engine/threads.mdx index 664fc25d09..fb262823a7 100644 --- a/apps/web/content/docs/content-engine/threads.mdx +++ b/apps/web/content/docs/content-engine/threads.mdx @@ -1,110 +1,204 @@ --- -title: Threaded replies -description: Add nested replies, such as comments under a forum topic, with the VitNode Content Engine thread option, from the content type to a comment section with replying, editing, tombstones and reporting. +title: Comments +description: Add comments under each VitNode Content Engine record with a comment content type and the thread option. Load them on the record's page, let signed-in members post, and tell published, draft and pending comments apart. icon: MessagesSquare implementation: - objective: Add threaded replies, such as comments, under an existing content type and show them on its page. + objective: Let signed-in members comment under the records of an existing content type, on its public page. decisions: - - Which plugin owns the replies? Offer plugins in the project that define Content Engine content types. - - Which items get replies? Offer content types from the chosen plugin with a public API and a public page. - - Who writes replies? Signed-in members from the public site (uses Public write), or only staff in the AdminCP. - - How deep can replies nest? From 1 (a flat list) to 32 levels; recommend 6. + - Which items get comments? Offer content types from the project's plugins that have a public API and a public page. + - 'Should comments go live at once? Recommend `create.status: "published"`; `"draft"` keeps them private until staff publish them.' constraints: - - Use the content type `thread` option. Do not build a separate comments table, tree query or placement checks. - - 'The root relation is required, to-one and `container: true`; `parent` is a nullable to-one self relation. Both belong in `publicApi.fields` and neither in `publicWrite.update.fields`.' - - 'Read replies with `GET /{publicApi.path}/thread` through `fetcher`, render tombstones (`removed: true`) so the tree keeps its shape, and show Edit and Report from `useContentPermissions`.' + - Define the comments as their own content type with the `thread` option, a required relation to the commented record as `root`, and a nullable self relation as `parent`. + - Read them with `GET /{publicApi.path}/thread` and post with `useContentPublicWrite`; check `approval.status` and `status` before saying a comment is posted. related: - - title: Public write - href: /docs/content-engine/public-write - - title: Report content - href: /docs/content-engine/reports - - title: Thread reference + - title: Comment editing + href: /docs/content-engine/threads-editing + - title: Replies + href: /docs/content-engine/threads-frontend + - title: thread reference href: /docs/content-engine/reference#thread --- +import { Accordion, Accordions } from "fumadocs-ui/components/accordion"; import { Tab, Tabs } from "fumadocs-ui/components/tabs"; import { ImgDocs } from "@/components/fumadocs/img"; -import tree from "./threads-frontend-tree.webp"; +import section from "./threads-comments.webp"; -The `thread` option turns a content type into replies that answer each other under one record, like comments under a forum topic. The server keeps the tree valid and returns it a page at a time; your page draws it. This guide follows the Forum plugin's comments: the content type first, then the comment section in `plugins/forum/src/pages/`. +Comments are records of their own, in a comment content type that points at the article it answers. The `thread` option gives that type a route that returns an article's comments a page at a time, and the server checks where every comment may go. This page adds a comment list and a composer under each article. -You need the root content type, here `forum.topic`, with a [public API](/docs/content-engine/public-api) and a page. For members to reply, read [Public write](/docs/content-engine/public-write) too. +## Before you begin + +You need: + +- **The article type with its public API and public page**, from the beginner path up to [Content page](/docs/content-engine/public-pages). The article itself needs no `author` and no `publicWrite`. Staff can keep writing every article in the AdminCP. The comment type below has its own `author` field and its own `publicWrite`, which is what lets members comment. +- **Member accounts.** Signing in is part of every VitNode app; it does not depend on members writing articles. +- **`@tanstack/react-query` in the plugin.** Skip this if [Embedded list](/docs/content-engine/public-lists-embed#add-tanstack-query-to-the-plugin) or [Create form](/docs/content-engine/public-write-frontend#add-tanstack-query-to-the-plugin) already added it; otherwise run this from the workspace root: + + + +```bash tab="bun" +bun add @tanstack/react-query --cwd plugins/newsroom +``` + +```bash tab="pnpm" +pnpm --filter newsroom add @tanstack/react-query +``` + +```bash tab="npm" +npm install @tanstack/react-query --workspace newsroom +``` + + -### Define the reply +### Define the comment type -```ts title="plugins/forum/src/content/comment.ts" +Create `src/content/comment.ts`. This is the complete file: + +```ts title="plugins/newsroom/src/content/comment.ts" import { defineContentType, field } from "@vitnode/core/content"; -import { topicContentType } from "@/content/topic"; +import { articleContentType } from "@/content/article"; export const commentContentType = defineContentType({ - id: "forum.comment", - tableName: "forum_comments", + id: "newsroom.comment", + tableName: "newsroom_comments", fields: { slug: field.slug({ generated: true }), body: field.richText({ required: true }), author: field.user(), - topic: field.relation({ - target: () => topicContentType, + article: field.relation({ + target: () => articleContentType, required: true, - container: true, onDelete: "cascade", }), - parent: field.relation({ self: true, nullable: true, onDelete: "set null" }), + parent: field.relation({ + self: true, + nullable: true, + onDelete: "set null", + }), }, publication: true, visibility: true, - reports: { snapshotFields: ["body"] }, publicApi: { path: "comments", - fields: ["id", "slug", "body", "topic", "parent", "publishedAt", "createdAt"], + fields: ["id", "slug", "body", "article", "parent", "publishedAt"], people: ["author"], }, publicWrite: { ownership: { field: "author" }, - create: { fields: ["body", "topic", "parent"], status: "published" }, + create: { fields: ["body", "article", "parent"], status: "published" }, update: { fields: ["body"] }, }, - thread: { parent: "parent", root: "topic", maxDepth: 6 }, + thread: { parent: "parent", root: "article", maxDepth: 1 }, + + admin: { path: "newsroom/comments", titleField: "slug" }, }); ``` -- `parent` is the reply being answered, or `null` for a direct answer to the topic. -- `topic` is the **root**. `container: true` puts the comment in the topic's forum, so the forum's [publishing policies](/docs/content-engine/container-policies) and moderators apply to comments too. -- `slug` with `generated: true` gets a random token such as `k3v9q2m7xa`, because a comment has no title. -- A comment's place is fixed when it is written. `parent` and `topic` cannot be edited. +- **`article`** is the comment's **root**, the record it belongs to. It must be a required relation to another content type. `onDelete: "cascade"` removes the comments with their article. +- **`parent`** is the comment being answered, or `null`. `thread` requires it even for flat comments; `maxDepth: 1` keeps every comment top level until [Replies](/docs/content-engine/threads-frontend). +- **`slug`** with `generated: true` gets a random token, because a comment has no title. The public API needs exactly one slug. +- **`publicWrite`** lets members post `body` under an `article`, and `create.status: "published"` makes a new comment public at once. `update` lets authors fix their text later, in [Comment editing](/docs/content-engine/threads-editing). A comment's place is fixed when it is written, so `parent` and `article` are never in `update.fields`. +- **`visibility`** lets moderators hide a comment, and **`admin`** gives staff a list of comments in the AdminCP. -### Create the model and migrate +### Create the model and register it + +Create `src/database/comments.ts`. This is the complete file; `references` points the `article` relation at the articles table: -```ts title="plugins/forum/src/database/comments.ts" +```ts title="plugins/newsroom/src/database/comments.ts" import { createContentModel } from "@vitnode/core/content/server"; import { commentContentType } from "@/content/comment"; -import { forum_topics } from "./topics"; +import { newsroom_articles } from "./articles"; export const commentContent = createContentModel(commentContentType, { - references: { topic: () => forum_topics.id }, + references: { article: () => newsroom_articles.id }, }); -export const forum_comments = commentContent.table; +export const newsroom_comments = commentContent.table; ``` -Add `commentContent` to `buildContentPublicModule` next to the topics, then migrate: +Add `commentContent` next to `articleContent` in the public module in `src/config.api.ts`. This is an excerpt: + +```ts title="plugins/newsroom/src/config.api.ts" +import { commentContent } from "@/database/comments"; // [!code ++] + +buildContentPublicModule({ + contentTypes: [articleContent, commentContent], // [!code highlight] +}), +``` + +Then give staff the comment list. Add `commentContent` to `buildContentAdminModule` in `src/api/modules/admin/admin.module.ts`, and its link to `src/admin/nav.tsx`. These are excerpts: + +```ts title="plugins/newsroom/src/api/modules/admin/admin.module.ts" +import { commentContent } from "@/database/comments"; // [!code ++] + +buildContentAdminModule({ contentTypes: [articleContent, commentContent] }), +``` + +```tsx title="plugins/newsroom/src/admin/nav.tsx" +import { MessageSquareIcon, NewspaperIcon } from "lucide-react"; + +import { commentContentType } from "@/content/comment"; // [!code ++] + + contentTypes: [ + { definition: articleContentType, icon: }, + { definition: commentContentType, icon: }, // [!code ++] + ], +``` + + + + + +### Label and migrate + +Add the comment type's labels to `src/locales/en.json`, next to `article` under `newsroom.content`, and its permission labels at the top level: + +```json title="plugins/newsroom/src/locales/en.json" +{ + "newsroom": { + "content": { + "comment": { + "label": "{count, plural, one {Comment} other {Comments}}", + "title": "Comments", + "desc": "What readers say under the articles.", + "fields": { + "slug": "Link", + "body": "Comment", + "author": "Author", + "article": "Article", + "parent": "Answers" + } + } + } + }, + "newsroom:comment": "Comments", + "newsroom:comment:can_view": "View comments", + "newsroom:comment:can_create": "Create comments", + "newsroom:comment:can_edit": "Edit comments", + "newsroom:comment:can_delete": "Delete comments", + "newsroom:comment:can_publish": "Publish and unpublish comments", + "newsroom:comment:can_hide": "Hide and unhide comments" +} +``` + +Then migrate from the workspace root: @@ -122,151 +216,556 @@ npm run db:migrate +The migration creates `newsroom_comments` with a foreign key to `newsroom_articles`. The thread route now answers: + +```bash +curl "http://localhost:3000/api/newsroom/content/comments/thread?root=1" +``` + +```json title="200" +{ "items": [], "pageInfo": { "endCursor": null, "hasNextPage": false, "totalCount": 0 }, "replyCount": 0, "truncated": false } +``` + -### Read the thread +### Read the comments -`GET /comments/thread?root={topicId}` returns a page of top-level replies, oldest first, each followed by every public answer below it: +Create `src/pages/comment-data.ts`. This is the complete file. All loaded pages of one article's comments live in one infinite query, so refreshing it reloads every page in order: -```ts title="plugins/forum/src/pages/thread-data.ts" -const res = await fetcher({ - plugin: manifest.id, - method: "get", - module: "content/comments", - path: "/thread", - args: { query: { root: String(topicId), first: 20, ...(after ? { after } : {}) } }, +```ts title="plugins/newsroom/src/pages/comment-data.ts" +import { infiniteQueryOptions } from "@tanstack/react-query"; +import { + zodContentPublicPerson, + zodRichTextDocument, +} from "@vitnode/core/content"; +import { fetcher } from "@vitnode/core/tanstack/fetcher"; +import { z } from "zod"; + +import { manifest } from "@/manifest.gen"; + +/** Top-level comments per page. The thread route accepts 1 to 50. */ +const COMMENTS_PAGE_SIZE = 20; + +/** One comment as `publicApi.fields` and `people` return it. */ +const zodComment = z.object({ + author: zodContentPublicPerson.nullable().catch(null), + body: zodRichTextDocument, + id: z.number(), + publishedAt: z.string().nullable(), +}); + +const zodCommentPage = z.object({ + items: z.array( + z.object({ + depth: z.number(), + id: z.number(), + parentId: z.number().nullable(), + record: zodComment.nullable(), + removed: z.boolean(), + }), + ), + pageInfo: z.object({ + endCursor: z.string().nullable(), + hasNextPage: z.boolean(), + totalCount: z.number(), + }), + replyCount: z.number(), + truncated: z.boolean(), }); -``` -The answer is flat, so a parent always comes before its replies: +export type CommentPage = z.infer; + +export type CommentItem = CommentPage["items"][number]; + +export type ArticleComment = NonNullable; + +const fetchCommentPage = async (articleId: number, after?: string) => { + const res = await fetcher({ + plugin: manifest.id, + method: "get", + module: "content/comments", + path: "/thread", + args: { + query: { + root: String(articleId), + first: COMMENTS_PAGE_SIZE, + ...(after ? { after } : {}), + }, + }, + }); + if (res.status !== 200) throw new Error("Loading the comments failed."); + + // The thread route types `record` loosely, so parse it once here. + return zodCommentPage.parse(await res.json()); +}; -```json -{ - "items": [ - { "depth": 1, "id": 17, "parentId": null, "removed": false, "record": { "id": 17, "body": {}, "author": {} } }, - { "depth": 2, "id": 18, "parentId": 17, "removed": true, "record": null }, - { "depth": 3, "id": 21, "parentId": 18, "removed": false, "record": { "id": 21 } } - ], - "pageInfo": { "endCursor": "WyIyMDI2...", "hasNextPage": false, "totalCount": 1 }, - "replyCount": 2, - "truncated": false -} +export const commentsQueryKey = (articleId: number) => + [manifest.id, "comments", articleId] as const; + +/** Every loaded page under one key, so a refetch reloads them all in order. */ +export const commentsQueryOptions = (articleId: number) => + infiniteQueryOptions({ + getNextPageParam: (last: CommentPage) => + last.pageInfo.hasNextPage + ? (last.pageInfo.endCursor ?? undefined) + : undefined, + initialPageParam: undefined as string | undefined, + queryFn: async ({ pageParam }) => + await fetchCommentPage(articleId, pageParam), + queryKey: commentsQueryKey(articleId), + }); ``` -Warm the first page in the topic page's loader, so comments are in the server-rendered HTML. +- `GET /comments/thread?root={articleId}` returns up to `first` top-level comments, oldest first. `endCursor` asks for the next page. +- `items` is a flat list, and each comment in it has `depth`, `parentId` and `record`. With `maxDepth: 1`, every item is top level. +- The thread route types `record` loosely, so `zodCommentPage` checks the fields `publicApi` returns once, here. `author` comes from `people` as a small public profile, or `null` for a deleted account. +- `replyCount` counts every public comment, for the "2 comments" heading. -### Draw the tree - -Turn the flat list into a tree in one pass, then render nested `
        ` lists: - -```tsx title="plugins/forum/src/pages/thread.tsx" -const buildThreadTree = (items: ThreadNode[]) => { - const nodes = new Map(); - const roots: ThreadTreeNode[] = []; - for (const item of items) { - const node = { ...item, children: [] }; - nodes.set(item.id, node); - const parent = item.parentId === null ? undefined : nodes.get(item.parentId); - if (parent) parent.children.push(node); - else roots.push(node); - } +### Write the composer + +Create `src/pages/comment-composer.tsx`. This is the complete file. It posts one comment and reports where it ended up: + +```tsx title="plugins/newsroom/src/pages/comment-composer.tsx" +import { + AutoForm, + type AutoFormOnSubmit, + AutoFormSubmitButton, +} from "@vitnode/core/components/form/auto-form"; +import { AutoFormEditor } from "@vitnode/core/components/form/fields/editor"; +import { + emptyRichTextDocument, + isRichTextEmpty, + zodRichTextDocument, +} from "@vitnode/core/content"; +import { useContentPublicWrite } from "@vitnode/core/tanstack/content"; +import { fetcher } from "@vitnode/core/tanstack/fetcher"; +import { useTranslations } from "use-intl"; +import { z } from "zod"; + +import { manifest } from "@/manifest.gen"; + +/** Where a new comment ended up. Only `published` is in the public thread. */ +export type CommentOutcome = "draft" | "pending" | "published"; + +export interface CommentComposerProps { + articleId: number; + label: string; + /** Called after the server stored the comment. */ + onPosted: (outcome: CommentOutcome) => Promise; + /** The comment being answered, or `null` for the article itself. */ + parentId: null | number; +} - return roots; +const createSchema = (required: string) => + z.object({ + body: zodRichTextDocument + .refine(doc => !isRichTextEmpty(doc), required) + .default(emptyRichTextDocument()), + }); + +const CommentComposer = ({ + articleId, + label, + onPosted, + parentId, +}: CommentComposerProps) => { + const t = useTranslations("newsroom.comments.form"); + const write = useContentPublicWrite({ fields: ["body"] }); + + const onSubmit: AutoFormOnSubmit> = async ( + { body }, + form, + ) => { + const record = await write.submit( + form, + async () => + await fetcher({ + plugin: manifest.id, + method: "post", + module: "content/comments", + path: "/", + args: { + body: { values: { article: articleId, body, parent: parentId } }, + }, + }), + ); + if (!record) return; + + // A pending comment is a draft too, so ask about approval first. + await onPosted( + record.approval?.status === "pending" + ? "pending" + : record.status === "published" + ? "published" + : "draft", + ); + }; + + return ( + ( + + ), + }, + ]} + formSchema={createSchema(t("required"))} + layout={fields => <>{fields.body}} + onSubmit={onSubmit} + > + + {t("submit")} + + + ); }; -const ThreadNodeView = ({ node }: { node: ThreadTreeNode }) => ( -
      1. - {node.record ? ( - - ) : ( -

        {t("removed")}

        - )} - {node.children.length > 0 ? ( -
          - {node.children.map(child => )} -
        - ) : null} -
      2. -); +export default CommentComposer; ``` -**Tombstones.** `removed: true` is a reply readers can't see, such as a hidden one, that still has public answers. Show a short placeholder and keep its children, so the conversation keeps its shape. A hidden reply with no answers is left out. +- **The request** posts `{ values: { article, body, parent } }`; `parent: null` answers the article itself. The server fills `author` from the session. +- **`write.submit`** returns the stored comment, or `null` after showing why the server said no, such as an expired session. +- **The outcome** reads both `approval` and `status`. A comment waiting for a moderator is also a draft, so `approval` is checked first. +- The file has a default export because the section loads it with `lazy`. The editor is heavy, and most readers never write. -### Load more pages +### Render the section + +Create `src/pages/comment-section.tsx`. This is the complete file: + +```tsx title="plugins/newsroom/src/pages/comment-section.tsx" +import { useInfiniteQuery, useQueryClient } from "@tanstack/react-query"; +import { RichTextContent } from "@vitnode/core/components/rich-text"; +import { Button, buttonVariants } from "@vitnode/core/components/ui/button"; +import { Skeleton } from "@vitnode/core/components/ui/skeleton"; +import { useSessionQuery } from "@vitnode/core/tanstack/auth"; +import { HrefLink } from "@vitnode/core/tanstack/link"; +import { lazy, Suspense, useMemo, useState } from "react"; +import { toast } from "sonner"; +import { useTranslations } from "use-intl"; + +import type { CommentComposerProps, CommentOutcome } from "./comment-composer"; +import type { ArticleComment } from "./comment-data"; + +import { commentsQueryKey, commentsQueryOptions } from "./comment-data"; -Keep the pages you loaded and pass the last `endCursor` as `after`: +// The editor is heavy and most readers never write, so it loads on demand. +const CommentComposer = lazy(async () => await import("./comment-composer")); -```tsx title="plugins/forum/src/pages/thread.tsx" -{last.pageInfo.hasNextPage ? ( - -) : null} -{last.truncated ?

        {t("truncated")}

        : null} +const Composer = (props: CommentComposerProps) => ( + }> + + +); + +/** Who wrote a comment, and what they said. */ +const CommentBody = ({ comment }: { comment: ArticleComment }) => { + const t = useTranslations("newsroom.comments"); + + return ( +
        +

        + {comment.author?.name ?? t("someone")} +

        + +
        + ); +}; + +export const CommentSection = ({ + articleId, + articleSlug, +}: { + articleId: number; + articleSlug: string; +}) => { + const t = useTranslations("newsroom.comments"); + const queryClient = useQueryClient(); + const session = useSessionQuery(); + const [composerKey, setComposerKey] = useState(0); + const comments = useInfiniteQuery(commentsQueryOptions(articleId)); + + const pages = comments.data?.pages; + const items = useMemo( + () => pages?.flatMap(page => page.items) ?? [], + [pages], + ); + + const onPosted = async (outcome: CommentOutcome) => { + if (outcome !== "published") { + // Not in the public thread, so there is nothing to refresh. + toast.info(t(`${outcome}.title`), { description: t(`${outcome}.desc`) }); + + return; + } + + toast.success(t("created.title"), { description: t("created.desc") }); + // Refetches every loaded page in order, with the new count. + await queryClient.invalidateQueries({ + queryKey: commentsQueryKey(articleId), + }); + }; + + return ( +
        +

        + {t("title", { count: pages?.[0]?.replyCount ?? 0 })} +

        + + {comments.isPending ? ( +
        + + {t("loading")} + + {[0, 1].map(key => ( +
        + + +
        + ))} +
        + ) : comments.isError && items.length === 0 ? ( +
        +

        + {t("load_failed.desc")} +

        + +
        + ) : items.length === 0 ? ( +

        {t("empty")}

        + ) : ( +
          + {items.map(item => + item.record ? ( +
        1. + +
        2. + ) : null, + )} +
        + )} + + {comments.hasNextPage ? ( + + ) : null} + + {session.data?.user ? ( + { + setComposerKey(key => key + 1); + await onPosted(outcome); + }} + parentId={null} + /> + ) : ( + + {t("sign_in")} + + )} +
        + ); +}; ``` -One answer holds at most 500 replies. `truncated: true` means some deep answers did not fit; say so instead of pretending the thread ends there. `replyCount` counts every public reply, the number for a "5 comments" heading. +- **`useInfiniteQuery`** reads the pages the article's loader warms in the next step, so the comments are in the server-rendered HTML. When that warm-up failed, it loads them in the browser. +- **One state at a time.** `isPending` shows a skeleton with one screen reader label, an error with no comments shows **Try again**, an empty thread says so, and anything else is the list. A failed refetch keeps the comments already on screen. +- **`onPosted`** refreshes the thread only for a published comment. TanStack Query refetches every loaded page from the first, so the new comment and the heading's count update together. Drafts and pending comments only get their message; see [What a new comment can become](#what-a-new-comment-can-become). +- **Load more comments** calls `fetchNextPage` with the last cursor. It is disabled during any fetch, so a double click never loads a page twice. +- **Guests** see a sign-in link that brings them back to the article.
        -### Reply, edit and report +### Show it below the article -Ask what the visitor may do with every reply on screen, in one request: +Edit `src/pages/article-page.tsx`. The loader warms the first page of comments, which needs the query client from `defineRoute`. This is an excerpt: -```tsx title="plugins/forum/src/pages/thread.tsx" -const permissions = useCommentPermissions( - items.flatMap(item => (item.record ? [item.id] : [])), -); +```tsx title="plugins/newsroom/src/pages/article-page.tsx" +import type { PluginRoutePageProps } from "@vitnode/core/routing"; +import { defineRoute } from "@vitnode/core/tanstack/plugin-routes"; + +import { commentsQueryOptions } from "./comment-data"; // [!code ++] +import { CommentSection } from "./comment-section"; // [!code ++] -const { canEdit, canReport } = permissions.of(comment.id); +export const route = defineRoute({ + load: async ({ context, params }) => { + const page = await loadArticle(params.slug); + // [!code ++:5] + // The first page of comments arrives with the article's HTML. A failure + // leaves the section to show its own error instead of breaking the page. + await context.queryClient + .infiniteQuery(commentsQueryOptions(page.item.id)) + .catch(() => null); + + return page; + }, + + head: ({ loaderData }) => + contentDeliveryPageHead(loaderData?.metadata, { + title: loaderData?.item.title, + }), +}); ``` -`useCommentPermissions` wraps `useContentPermissions` with the `content/comments` route, like [`useDiscussionPermissions`](/docs/content-engine/reports#ask-who-may-report-what). Then: +- If the route still uses `definePluginRoute` from [Content page](/docs/content-engine/public-pages), switch to `defineRoute` as shown; `definePluginRoute` has no query client. +- If [Embedded list](/docs/content-engine/public-lists-embed) already warms **Latest articles** here, run both with `Promise.all`, so neither waits for the other. -- **Reply** posts `{ values: { body, parent: comment.id, topic: topicId } }` with `useContentPublicWrite`, like the [create form](/docs/content-engine/public-write-frontend). Show it to signed-in members while the reply sits above `maxDepth`. -- **Edit** appears when `canEdit`. It loads `GET /comments/{id}/edit` and saves with `PATCH`, like the [edit form](/docs/content-engine/public-write-edit). -- **Report** appears when `canReport`, with `contentTarget(manifest.id, commentContentType, comment.id)`. See [Report content](/docs/content-engine/reports). +Render the section at the end of `
        `, after the body: -When the forum's policy holds new comments for a moderator, the post answers `approval.status: "pending"`. Tell the author it is waiting; the comment appears in the thread after approval. +```tsx title="plugins/newsroom/src/pages/article-page.tsx" + + + {/* [!code ++] */} +
        +``` + +Then load the comment labels on the article's route in `src/routes.ts`: + +```ts title="plugins/newsroom/src/routes.ts" + page("/news/:slug", { + component: lazy(() => import("./pages/article-page")), + messages: ["newsroom.articles", "newsroom.comments"], // [!code highlight] + }), +```
        - + -## What the server checks +### Add the labels -Every reply is placed on the server, in the AdminCP too. A broken rule answers `400` with an issue on the field: +Add a `comments` group under `newsroom` in `src/locales/en.json`: -| Rule | Message | -| ---------------------------------------------------- | --------------------------------------------------------------- | -| The parent exists and is public | `The reply you are answering does not exist.` | -| The parent is under the same topic | `The reply you are answering belongs to another conversation.` | -| A reply never answers itself or one of its answers | `A reply cannot answer itself or one of its answers.` | -| Nothing sits deeper than `maxDepth` | `Replies nest at most 6 levels deep. Answer a reply higher up.` | +```json title="plugins/newsroom/src/locales/en.json" +{ + "newsroom": { + "comments": { + "title": "{count, plural, =0 {Comments} one {# comment} other {# comments}}", + "empty": "No comments yet. Be the first.", + "loading": "Loading comments", + "retry": "Try again", + "load_more": "Load more comments", + "load_failed": { "title": "Could not load comments", "desc": "Check your connection and try again." }, + "sign_in": "Sign in to comment", + "form": { "label": "Your comment", "submit": "Post comment", "required": "Write a comment first." }, + "created": { "title": "Comment posted", "desc": "Your comment is below the article." }, + "draft": { + "title": "Comment saved as a draft", + "desc": "It is not public yet, so it does not appear below the article." + }, + "pending": { + "title": "Your comment is waiting for a moderator", + "desc": "A moderator reviews it before readers can see it." + }, + "someone": "Deleted user" + } + } +} +``` -A missing, draft or hidden topic makes `GET /thread` answer `404`. Every option is in the [`thread` reference](/docs/content-engine/reference#thread). +The error toasts of `useContentPublicWrite` come from core and need no labels. -## Use it in another plugin + -The forum's `thread-data.ts`, `thread.tsx` and `comment-composer.tsx` are a reference to copy, not an export. Change the module to `content/` plus your reply type's path, `topic` to your `thread.root` field, and the depth limit to your `maxDepth`. The tree, tombstones, paging and buttons need nothing else. + ## Check the result -Open a topic with replies, such as `/forum/topics/show-us-your-first-content-engine-type`. Replies nest under the one they answer, a hidden reply leaves **This comment was removed.** with its answer still below it, your own replies show **Edit**, and the others show **Report**. +Open `/news/welcome-to-the-newsroom`, the article staff created in [First content type](/docs/content-engine/create-a-content-type) and published in [Drafts & publishing](/docs/content-engine/publication): + +1. Signed out, the section shows **No comments yet** and **Sign in to comment**. +2. Sign in as a member, write a comment and click **Post comment**. **Comment posted** appears, the comment shows with your name, the heading reads **1 comment**, and the editor is empty again. +3. Sign in as another member and comment too. The heading reads **2 comments**. + +To try a draft, set `create.status` to `"draft"` in `src/content/comment.ts` and post again. **Comment saved as a draft** appears, and the list and the count stay as they were, because a draft is not public. Set it back to `"published"` afterwards. + +## What a new comment can become + +`POST /comments` answers `201` with the stored comment. Its `status` and `approval` say whether readers can see it: + +| `status` | `approval` | When | What the section does | +| --- | --- | --- | --- | +| `"published"` | `null` | `create.status: "published"`, as here | **Comment posted**, and the thread refreshes | +| `"draft"` | `null` | `create.status: "draft"` | **Comment saved as a draft**; staff publish it in the AdminCP | +| `"draft"` | `{ status: "pending" }` | The article is filed in a container that holds new comments for approval | **Your comment is waiting for a moderator** | + +A newsroom comment can never be pending, because articles are not filed in a container. Pending comments need two more settings: mark the root relation `container: true`, and give its target a `field.container()` whose definition has `policies`. The Forum plugin's comments are set up this way: + +```ts title="plugins/forum/src/content/comment.ts (excerpt)" +topic: field.relation({ + target: () => topicContentType, + required: true, + container: true, // the comment follows its topic's forum and its rules + onDelete: "cascade", +}), +``` + +A moderator then sets **New posts** to **Wait for approval** for comments in a forum's [publishing rules](/docs/content-engine/container-policies). [Approvals](/docs/content-engine/approvals#drafts-and-approval) explains the difference between a draft and a pending submission. + +## What the server checks + +- **Signed out.** `POST /comments` answers `401`; the section never offers the composer to guests. +- **Placement.** The article must be public, the parent must belong to the same article, and nothing may nest deeper than `maxDepth`. A broken rule answers `400` with an issue on `parent`, which the composer shows as a toast. +- **Hidden articles.** A comment is only as public as its article. Once the article is unpublished or hidden, its comments drop out of the thread and their own routes answer `404`. + +Next, [Comment editing](/docs/content-engine/threads-editing) lets authors fix their comments. diff --git a/apps/web/content/docs/dev/advanced/auth.mdx b/apps/web/content/docs/dev/advanced/auth.mdx index 1029ceddc4..8edffcda39 100644 --- a/apps/web/content/docs/dev/advanced/auth.mdx +++ b/apps/web/content/docs/dev/advanced/auth.mdx @@ -1,12 +1,12 @@ --- -title: Authorization & Sessions +title: Auth and sessions description: How VitNode manages public and admin sessions, HttpOnly cookies, device fingerprinting, and session expiration. icon: Cookie --- import { TypeTable } from "fumadocs-ui/components/type-table" -VitNode implements two isolated sessions: a **public session** for the community site and a separate **admin session** for the AdminCP. Both use opaque random tokens stored as SHA-256 hashes in PostgreSQL. +VitNode keeps two separate sessions: a **public session** for the community site and an **admin session** for the AdminCP. Both use random tokens kept in `HttpOnly` cookies and stored only as SHA-256 hashes in PostgreSQL. Read this page to change how long sessions last, share them across subdomains, or switch password sign-in off. ## Quick start @@ -27,21 +27,21 @@ export const vitNodeApiConfig = buildApiConfig({ --- -## The Two-Session Model +## The two-session model -| Session Type | Cookie Name | Default Lifetime | Storage Table | Purpose | +| Session | Cookie name | Default lifetime | Table | Purpose | | :--- | :--- | :--- | :--- | :--- | -| **Public Session** | `vitnode_auth` | 90 days | `core_sessions` | Frontend member authentication | -| **Admin Session** | `vitnode_auth_admin` | 1 hour of inactivity | `core_admin_sessions` | High-privilege AdminCP access | -| **Known Device** | `vitnode_device` | 1 year | `core_sessions_known_devices` | Device authorization tracking | +| **Public session** | `vitnode_auth` | 90 days | `core_sessions` | Frontend member authentication | +| **Admin session** | `vitnode_auth_admin` | 1 hour of inactivity | `core_admin_sessions` | High-privilege AdminCP access | +| **Known device** | `vitnode_device` | 1 year | `core_sessions_known_devices` | Device authorization tracking | - + Signing out of the AdminCP does not terminate the user's public session, and vice versa. An administrator compromised in a public context cannot access the AdminCP without re-authenticating with staff credentials - a password or a user-verified passkey, checked against live staff access. Neither a public session nor social SSO ever becomes an AdminCP session. --- -## AdminCP Session Timeout +## AdminCP session timeout The AdminCP is the keys to the kingdom, so its session is deliberately short-lived. Staff are asked to sign in again when: @@ -55,17 +55,17 @@ The AdminCP is the keys to the kingdom, so its session is deliberately short-liv --- -## Security Guarantees +## Security guarantees -- **SHA-256 Token Storage**: The raw token is stored only in the user's `HttpOnly` cookie. The database stores only its cryptographic hash. Sign-in, SSO and passkey answers carry the user id only - page scripts never see the token. -- **Device Pinning**: Tokens are bound to a verified device ID. Tokens copied to another device without the matching device cookie are rejected. -- **Fast 60-Second Caching**: Active sessions are cached in Redis for up to 60 seconds, eliminating database query overhead on repeated requests. -- **Confirmed Email Addresses**: With an email adapter, members confirm their address before their first sign-in. See [Email Verification](/docs/dev/working-with-users/email-verification). -- **Canonical Email Identity**: Addresses are stored in their canonical form, so Gmail dot, `googlemail.com` and `+tag` variants cannot become a second account. See [One Mailbox, One Account](#one-mailbox-one-account). +- **Hashed tokens.** The raw token is stored only in the user's `HttpOnly` cookie. The database stores only its cryptographic hash. Sign-in, SSO and passkey answers carry the user id only, so page scripts never see the token. +- **Device pinning.** Tokens are bound to a verified device ID. Tokens copied to another device without the matching device cookie are rejected. +- **Short session cache.** An active session is cached for up to 60 seconds with [Redis](/docs/dev/advanced/redis), or up to 15 seconds in the API process without it, so repeated requests skip the database. The in-process cache is per instance, so run Redis when you run more than one API instance. +- **Confirmed email addresses.** With an email adapter, members confirm their address before their first sign-in. See [Email verification](/docs/dev/working-with-users/email-verification). +- **One account per mailbox.** Addresses are stored in their canonical form, so Gmail dot, `googlemail.com` and `+tag` variants cannot become a second account. See [One mailbox, one account](#one-mailbox-one-account). --- -## One Mailbox, One Account +## One mailbox, one account `jankowalski@gmail.com` and `jan.kowalski@gmail.com` look like two addresses. Gmail ignores the dots, so they are one mailbox - and a site that treats them as two lets the same person hold two accounts, dodge a ban, or farm their own referral link. @@ -105,7 +105,7 @@ The rules are per-provider, because they are only true per-provider: ``` -## `authorization` Options Reference +## `authorization` options -Password hashes already in the database are left alone, so switching `enabled` back to `true` restores password sign-in for everyone who had one. +Password hashes already in the database are left alone, so setting `password` back to `true` restores password sign-in for everyone who had one. -## Learn More +## Related diff --git a/apps/web/content/docs/dev/advanced/queue-debug-panel.webp b/apps/web/content/docs/dev/advanced/queue-debug-panel.webp new file mode 100644 index 0000000000..f795d5d965 Binary files /dev/null and b/apps/web/content/docs/dev/advanced/queue-debug-panel.webp differ diff --git a/apps/web/content/docs/dev/advanced/queue.mdx b/apps/web/content/docs/dev/advanced/queue.mdx index f11f66d19f..aeb47107c1 100644 --- a/apps/web/content/docs/dev/advanced/queue.mdx +++ b/apps/web/content/docs/dev/advanced/queue.mdx @@ -1,158 +1,309 @@ --- -title: Queue Tasks -description: Run asynchronous background tasks through VitNode's database-backed queue with retries and AdminCP monitoring. +title: Queue +description: Run slow work in the background with VitNode's PostgreSQL queue. Define a task, validate its payload, dispatch it from a route, and follow it to completion with retries, delays, leases and deduplication. icon: ListOrdered --- -import { TypeTable } from "fumadocs-ui/components/type-table" +import { TypeTable } from "fumadocs-ui/components/type-table"; -Queue tasks handle asynchronous, one-off background work (e.g. sending bulk emails, processing media, or scheduling posts). Tasks are persisted in the `core_queue` table and drained periodically by VitNode's background worker. +import { ImgDocs } from "@/components/fumadocs/img"; +import queuePanel from "./queue-debug-panel.webp"; -## Quick start +The queue moves work out of the request. A route stores a task in the `core_queue` table and answers at once, and a background worker runs it a little later, retrying it if it fails. In this guide the `site-notes` plugin queues an announcement for a note and you watch the task complete. -### 1. Define a Queue Task +## Before you begin -```ts title="plugins/blog/src/api/tasks/send-newsletter.task.ts" -import { buildQueueTask } from "@vitnode/core/api/lib/queue" +- **A queue processor.** Tasks run only when core's `process-queue` cron job runs, every minute. Configure a [cron adapter](/docs/dev/cron) or an external trigger. During development you can trigger it by hand, as shown in [Check the result](#check-the-result). +- The `site-notes` plugin with its `site_notes` table and the **Create notes** permission, from the beginner path up to [Permissions](/docs/dev/working-with-users/staff-permissions). Any plugin with a top-level API module and a guarded route works the same way. -export interface NewsletterPayload { - postId: number -} + -export const sendNewsletterTask = buildQueueTask({ - name: "send-newsletter", + + +### Create the task + +Create `src/api/tasks/announce-note.task.ts`: + +```ts title="plugins/site-notes/src/api/tasks/announce-note.task.ts" +import { buildQueueTask } from "@vitnode/core/api/lib/queue"; +import { eq } from "drizzle-orm"; +import { z } from "zod"; + +import { site_notes } from "@/database/notes"; + +const zodPayload = z.object({ noteId: z.number().int().positive() }); + +export const announceNoteTask = buildQueueTask({ + name: "announce-note", + maxAttempts: 5, handler: async (c, payload) => { - await c.get("log").info(`Processing newsletter for post #${payload.postId}`) + const { noteId } = zodPayload.parse(payload); + const [note] = await c + .get("db") + .select({ title: site_notes.title }) + .from(site_notes) + .where(eq(site_notes.id, noteId)); + + // Deleted since it was queued: nothing to announce, and nothing to retry. + if (!note) return; + + await c.get("log").debug(`Announced note "${note.title}"`); }, -}) +}); ``` ---- +- **The payload is stored as JSON**, so the handler receives it as `Record`. `buildQueueTask` is not generic, so validate the payload yourself, here with Zod. An invalid payload throws, which counts as a failed attempt. +- **Returning completes the task. Throwing fails the attempt** and schedules a retry. +- **`name` is unique within the plugin.** Tasks are matched to handlers by plugin id and name. +- **`c.get("log").debug()`** writes to the console and to the Debug Panel's log explorer. + + + + + +### Register it on a top-level module -### 2. Register in an API Module +Add the task to the `queueTasks` of `notesModule`, which `config.api.ts` passes to `buildApiPlugin` directly: -Attach the task to your module's `queueTasks` list: +```ts title="plugins/site-notes/src/api/modules/notes/notes.module.ts" +import { buildModule } from "@/api/builders"; +import { announceNoteTask } from "@/api/tasks/announce-note.task"; // [!code ++] -```ts title="plugins/blog/src/api/modules/posts/posts.module.ts" -import { buildModule } from "@/api/builders" -import { sendNewsletterTask } from "../../tasks/send-newsletter.task" +import { listNotesRoute } from "./list.route"; +import { showNoteRoute } from "./show.route"; -export const postsModule = buildModule({ - name: "posts", - routes: [createPostRoute], - queueTasks: [sendNewsletterTask], // [!code ++] -}) +export const notesModule = buildModule({ + name: "notes", + routes: [listNotesRoute, showNoteRoute], + queueTasks: [announceNoteTask], // [!code ++] +}); ``` ---- +Queue tasks are read only from modules passed to `buildApiPlugin`. Declared on a nested module, such as `admin/notes`, a task is never registered, and its dispatches fail with "No handler registered". -### 3. Dispatch Tasks + -Dispatch jobs from any Hono route or service: + -```ts -// Dispatch immediately -await c.get("queue").dispatch({ - name: "send-newsletter", - payload: { postId: 42 }, -}) +### Dispatch it from a route + +Create `src/api/modules/admin/notes/announce.route.ts`. It queues the task and returns what `dispatch()` reported: + +```ts title="plugins/site-notes/src/api/modules/admin/notes/announce.route.ts" +import { z } from "@hono/zod-openapi"; + +import { buildRoute } from "@/api/builders"; + +export const announceNoteRoute = buildRoute({ + adminStaffPermission: { module: "notes", permission: "can_create" }, + route: { + method: "post", + path: "/{id}/announce", + request: { + params: z.object({ id: z.coerce.number().int().positive() }), + }, + responses: { + 202: { + content: { + "application/json": { + schema: z.object({ + deduplicated: z.boolean(), + taskId: z.number().nullable(), + }), + }, + }, + description: "The announcement is queued, or already waiting.", + }, + }, + }, + handler: async c => { + const { id } = c.req.valid("param"); + const { deduplicated, id: taskId } = await c.get("queue").dispatch({ + name: "announce-note", + payload: { noteId: id }, + dedupeKey: `announce-note:${id}`, + }); + + return c.json({ deduplicated, taskId }, 202); + }, +}); +``` + +Then add `announceNoteRoute` to the `routes` of `adminNotesModule` in `src/api/modules/admin/notes/notes.module.ts`, after `createNoteRoute`. + +`dispatch()` only inserts a row and returns `{ deduplicated, id }`. It never runs the handler. The task belongs to the plugin of the route that dispatched it; pass `pluginId` to queue another plugin's task. + + + + + +## Check the result + +1. Add a temporary button that queues an announcement for note `3` with `fetcher`. Create `src/pages/check-announce-note.tsx`: + + ```tsx title="plugins/site-notes/src/pages/check-announce-note.tsx" + import { Button } from "@vitnode/core/components/ui/button"; + import { fetcher } from "@vitnode/core/tanstack/fetcher"; + import { useState } from "react"; + + import { manifest } from "@/manifest.gen"; -// Or schedule for future execution + export const CheckAnnounceNote = () => { + const [result, setResult] = useState(""); + const [isPending, setIsPending] = useState(false); + + const onClick = async () => { + setIsPending(true); + try { + const response = await fetcher({ + plugin: manifest.id, + method: "post", + module: "admin/notes", + path: "/{id}/announce", + args: { params: { id: 3 } }, + }); + setResult(response.ok ? JSON.stringify(await response.json()) : String(response.status)); + } finally { + setIsPending(false); + } + }; + + return ( +
        + + {result ? {result} : null} +
        + ); + }; + ``` + + Render `` in `src/pages/notes-page.tsx`, sign in to the AdminCP in the same browser, open `/site-notes/notes` and press the button twice. The first press shows `{"deduplicated":false,"taskId":3}`, the second `{"deduplicated":true,"taskId":null}`. The second dispatch is dropped because a task with the same `dedupeKey` is still waiting. + +2. Open **Debug Panel → Queue** (`/admin/core/debug/queue`). The `announce-note` task from `site-notes` is **pending**, because nothing has run it yet. +3. Run the processor. With a cron adapter, wait for the next minute. During development you can trigger cron yourself; `CRON_SECRET` is in your `.env`: + + ```bash + curl -X POST http://localhost:3000/api/@vitnode/core/cron \ + -H "Authorization: Bearer $CRON_SECRET" \ + -H "Content-Type: application/json" + ``` + + It answers `Cron jobs processed. Executed N jobs.` + +4. Reload **Debug Panel → Queue**. The task is **completed**, with `1/5` attempts. **Debug Panel → Log explorer** has the debug entry `Announced note "New coffee machine"`. It is listed under `@vitnode/core`, because the worker runs outside any plugin's route. Remove `CheckAnnounceNote` when you are done. + + + +## Run a task later + +`availableAt` is the earliest time the task may run. The default is now: + +```ts await c.get("queue").dispatch({ - name: "send-newsletter", - payload: { postId: 42 }, - executeAt: new Date(Date.now() + 60 * 60 * 1000), // 1 hour from now - priority: 10, // Higher numbers run first -}) + name: "announce-note", + payload: { noteId: id }, + availableAt: new Date(Date.now() + 60 * 60 * 1000), // in an hour +}); ``` -`dispatch` returns `{ id }` as soon as the task row is written to PostgreSQL. +The task still waits for the next processor run after that time. `priority` (higher first, default `0`) orders tasks that are due at the same time. ---- +## Retries and backoff -## Retries and Error Handling +A thrown error fails the attempt. Until `maxAttempts` is reached, the task returns to `pending` with an exponential delay. The first retry waits about 10 seconds, and each later one waits twice as long, up to one hour. After the last attempt it is **failed**, with the error in `lastError`. -VitNode tasks automatically retry on failure using exponential backoff: +`maxAttempts` comes from the dispatch, then the task definition, then `3`. To wait without using an attempt, for example on a rate limit, throw `QueueDeferError` from `@vitnode/core/api/lib/queue`: ```ts -export const riskyTask = buildQueueTask({ - name: "sync-external-api", - maxAttempts: 5, // Default is 3 - handler: async (c, payload) => { - // If an error is thrown, the task retries automatically - }, -}) +throw new QueueDeferError(new Date(Date.now() + 60_000), "Provider rate limit"); ``` -| Attempt | Delay Before Next Retry | -| :--- | :--- | -| 1st failure | ~10 seconds | -| 2nd failure | ~20 seconds | -| Each next failure | Doubles, capped at 1 hour | -| Final failure | Marked as `failed` in `core_queue` | +A failed task stays failed. Fix the cause and dispatch again. -### How the Worker Drains the Queue +## Leases and abandoned tasks -Each `process-queue` tick claims 25 tasks at a time and runs up to 5 of them at once, then claims the next 25 - until the queue is empty or about 45 seconds have passed. Handlers share one tick, so make them safe to run side by side. Finished tasks stay in `core_queue` for 7 days; the hourly `clean` job removes older ones in small batches. +A worker that claims a task holds it for the task's `leaseSeconds`, `600` by default. If the worker dies, or the handler is still running when the lease ends, the task counts as abandoned. It returns to `pending`, or becomes **failed** when it has no attempts left, and another run picks it up. -### Stale Tasks Are Reclaimed +Keep handlers well inside their lease, or raise `leaseSeconds` on the task. A handler that outlives its lease may run twice at once. For large jobs, save progress and dispatch a follow-up task, as [notification fan-out](/docs/dev/notifications/queue-and-scale) does. -A task still `processing` after 15 minutes (`QUEUE_STALE_PROCESSING_MINUTES`) belonged to a worker that died mid-run. The queue worker puts it back to `pending`, or marks it `failed` if it has used every attempt, so a crash never strands a task forever. +## Deduplication - - A handler that runs longer would be reclaimed and run again while it is still going. Long work should save its progress and dispatch a follow-up task instead - the way [notification fan-out](/docs/dev/notifications/queue-and-scale) does. - +While a task with the same plugin id and `dedupeKey` is **pending** or **processing**, a new dispatch is dropped and returns `{ deduplicated: true, id: null }`. Once the first task completes or fails, the same key can be queued again. Without a `dedupeKey`, every dispatch creates a task. ---- +## Enqueue inside a transaction -## Inspect tasks in the Debug Panel +Pass `tx` to insert the task with the transaction that writes your data, so it is only queued if that write commits: -{/* Image prompt: VitNode AdminCP Debug Panel at /admin/core/debug/queue with the "Queue" section open in the side menu. Table of queue tasks with columns Task, Queue, Status (pending, processing, completed, failed badges), Attempts, Available At, Created At and Last Error, plus a Status filter above it. Dark theme, 1440x900. */} +```ts +await c.get("db").transaction(async tx => { + const [note] = await tx.insert(site_notes).values(values).returning(); + await c.get("queue").dispatch({ name: "announce-note", payload: { noteId: note.id }, tx }); +}); +``` -Open **Debug Panel → Queue** in the AdminCP (`/admin/core/debug/queue`), or type "Queue tasks" into the ⌘K admin search. The table lists every task with its queue, status, attempts, when it is next available, when it was created and its last error. Filter by status to find the failed ones. +## How the worker runs -The section is read-only. A failed task stays failed, so fix the cause and dispatch it again from your code. Viewing it needs the `queue → can_view` staff permission. +Each `process-queue` run claims tasks in batches of 25, runs up to 5 at once, highest `priority` first, and keeps draining for about 45 seconds. Tasks on the `ai` queue are drained by core's AI job instead. Completed and failed tasks stay in `core_queue` for 7 days; the hourly cleanup removes older ones. ---- +**Debug Panel → Queue** lists every task with its queue, status, attempts, next run, creation time and last error, filterable by status. It is read-only and needs the `queue → can_view` staff permission. -## `buildQueueTask` Options +## `buildQueueTask` options Promise", + type: "(c: Context, payload: Record) => Promise | void", }, maxAttempts: { default: "3", - description: "Maximum retry attempts before permanently failing.", + description: "Attempts before the task is failed, unless the dispatch sets its own.", type: "number", }, + leaseSeconds: { + default: "600", + description: "How long a worker may hold a claimed task before it counts as abandoned.", + type: "number", + }, + description: { + description: "A note for developers. Core does not display it.", + type: "string", + }, }} /> -## Learn More - - - - - - +## `dispatch()` options + +" }, + availableAt: { description: "Earliest time the task may run.", default: "now", type: "Date" }, + dedupeKey: { description: "Drops the dispatch while an active task has the same key.", type: "string" }, + maxAttempts: { description: "Overrides the task's attempt limit.", type: "number" }, + priority: { description: "Higher runs first.", default: "0", type: "number" }, + queue: { description: "Queue name. The default worker drains every queue except `ai`.", default: '"default"', type: "string" }, + pluginId: { description: "Plugin that owns the task.", default: "the current route's plugin", type: "string" }, + tx: { description: "A transaction to insert with.", type: "Transaction" }, + }} +/> + +`dispatch()` returns `Promise<{ deduplicated: boolean; id: number | null }>`. + +## Related + +- [Cron](/docs/dev/cron): recurring jobs, and the adapters that run the queue. +- [Events](/docs/dev/events): react to something that happened, inside the request. diff --git a/apps/web/content/docs/dev/advanced/rate-limiter-debugging.mdx b/apps/web/content/docs/dev/advanced/rate-limiter-debugging.mdx index 6940c2a27c..6e624e4d23 100644 --- a/apps/web/content/docs/dev/advanced/rate-limiter-debugging.mdx +++ b/apps/web/content/docs/dev/advanced/rate-limiter-debugging.mdx @@ -1,11 +1,16 @@ --- -title: Debug unexpected 429 errors +title: Debug 429 errors description: Find which VitNode rate-limit budget a visitor ran out of, using the X-RateLimit-Category header and the API's rate-limit log line, then fix the cause. icon: SearchCheck --- When a real visitor sees "Too many requests", find out which [rate-limit budget](/docs/dev/advanced/rate-limiter) refused them before you raise anything. Raising the wrong budget hides the cause and weakens protection you still need. +## Before you begin + +- An API running with `NODE_ENV` other than `development`, such as production or a staging copy. In development every budget is skipped, so these steps never show a `429` locally. +- Access to the API's logs for step 2. + diff --git a/apps/web/content/docs/dev/advanced/rate-limiter.mdx b/apps/web/content/docs/dev/advanced/rate-limiter.mdx index 2ab10d2fb4..351526888d 100644 --- a/apps/web/content/docs/dev/advanced/rate-limiter.mdx +++ b/apps/web/content/docs/dev/advanced/rate-limiter.mdx @@ -1,5 +1,5 @@ --- -title: Rate Limiter +title: Rate limiter description: How VitNode splits API rate limits into read, write, sign-in and content-write budgets under a global safety cap, with Redis-shared counters and 429 responses that carry Retry-After. icon: ShieldAlert --- @@ -17,7 +17,7 @@ Every request spends points from the budgets that apply to it, in this order, an | Safety cap | Every request, including `OPTIONS` preflights | 1000 / 60 s | `rateLimiter.safety` | | Read | `GET` and `HEAD` | 300 / 60 s | `rateLimiter.read` | | Write | `POST`, `PUT`, `PATCH`, `DELETE` and any other method | 60 / 60 s | `rateLimiter.write` | -| Sign-in | `POST` to the sign-in, sign-up, password and passkey routes, plus write | 10 / 60 s | `rateLimiter.auth` | +| Sign-in | `POST` to the sign-in, sign-up, password, email verification and passkey routes, plus write | 10 / 60 s | `rateLimiter.auth` | | Content writes | `POST` and `PATCH` under a plugin's `/content/`, outside `/admin/`, plus write | 20 / 60 s | `rateLimiter.contentWrite` | So a sign-in spends the safety cap, the write budget and the sign-in budget. A `GET` spends only the safety cap and the read budget. Each budget is spent at most once per request. diff --git a/apps/web/content/docs/dev/advanced/redis.mdx b/apps/web/content/docs/dev/advanced/redis.mdx index 530b75b825..569d324dd7 100644 --- a/apps/web/content/docs/dev/advanced/redis.mdx +++ b/apps/web/content/docs/dev/advanced/redis.mdx @@ -1,51 +1,28 @@ --- title: Redis -description: Add a shared Redis cache to VitNode for faster session lookups, distributed rate limiting, and multi-instance WebSocket broadcasts. +description: Connect Redis to VitNode to share the API cache, sessions, rate-limit counters and realtime events across instances, and see what happens without it. icon: DatabaseZap --- -import { Tab, Tabs } from "fumadocs-ui/components/tabs" +[Redis](https://redis.io) is VitNode's shared cache and pub/sub broker. It is optional. On a single API instance VitNode works without it and uses an in-process cache and per-instance counters. Add Redis when you run more than one API instance, deploy to a serverless host, or want cached values to live longer than 15 seconds. -[Redis](https://redis.io) serves as VitNode's shared cache and pub/sub broker across horizontally scaled instances. Redis is optional: without it, VitNode degrades gracefully to in-memory counters and local delivery. - -## Quick start - -### 1. Configure Environment Variables - -```bash title=".env" -REDIS_URL=redis://localhost:6379 // [!code ++] -REDIS_PASSWORD=root // [!code ++] -``` - -### 2. Connect in API Configuration - -```ts title="apps/api/src/vitnode.api.config.ts" -import { buildApiConfig } from "@vitnode/core/vitnode.config" - -export const vitNodeApiConfig = buildApiConfig({ - // [!code ++:5] - redis: process.env.REDIS_URL - ? { url: process.env.REDIS_URL, password: process.env.REDIS_PASSWORD } - : undefined, -}) -``` - ---- - -## What Redis Powers +## What Redis changes | Feature | Without Redis | With Redis | | :--- | :--- | :--- | -| **API Domain Cache** | No-op (always executes DB query) | Shared key-value store via `c.get("cache")` | -| **Rate Limiter** | In-memory counters per instance | Distributed sliding-window across cluster | -| **WebSocket Realtime** | Single instance only | Cross-instance delivery via Redis Pub/Sub; required on serverless hosts ([details](/docs/dev/websocket/reconnects#delivery-across-instances)) | -| **Sessions** | Database queries on every request | Instant cache hits with DB fallback | +| **API cache** (`c.get("cache")`) | In-process memory, TTLs capped at 15 seconds, per instance; a delete on one instance does not reach the others | Shared by every instance, with the TTL you ask for | +| **Sessions** | Cached in process for up to 15 seconds, then read from the database | Cached in Redis for up to 60 seconds, then read from the database | +| **Rate limiter** | Counters in memory, per instance | Counters shared by every instance | +| **WebSocket realtime** | Delivered only to clients on the same instance | Delivered across instances through Redis pub/sub; required on serverless hosts ([details](/docs/dev/websocket/reconnects#delivery-across-instances)) | ---- +## Connect Redis + + + -## Local Development (Docker) +### Start a Redis server -Start a Redis container with Docker Compose: +For local development, run Redis with Docker Compose. `--requirepass` sets the password used in the next step: ```yaml title="docker-compose.yml" services: @@ -57,41 +34,78 @@ services: command: redis-server --requirepass root ``` ---- +In production, use your host's managed Redis or your own server. -## Using the Cache in API Routes + + -Use `remember` with a key, a TTL in seconds, and a loader to cache expensive database lookups: +### Set the environment variables -```ts -const posts = await c.get("cache").remember( - "featured_posts", - 60, - async () => await fetchFeaturedPostsFromDB(), -) +Add the connection URL and the password to the API's `.env`. The password is a separate variable, so the URL can stay free of secrets: + +```bash title="apps/api/.env" +REDIS_URL=redis://localhost:6379 # [!code ++] +REDIS_PASSWORD=root # [!code ++] ``` -See [Cache API data](/docs/dev/cache/api) for invalidation and the full method list. + + ---- +### Pass them to the API config + +The starter apps already contain this. Check that `redis` is set in `vitnode.api.config.ts`: + +```ts title="apps/api/src/vitnode.api.config.ts" +import { buildApiConfig } from "@vitnode/core/vitnode.config"; + +export const vitNodeApiConfig = buildApiConfig({ + // [!code ++:3] + redis: process.env.REDIS_URL + ? { url: process.env.REDIS_URL, password: process.env.REDIS_PASSWORD } + : undefined, +}); +``` -## Check Redis in the AdminCP +`redis` accepts any option of node-redis `createClient`. When `REDIS_URL` is unset, `redis` is `undefined` and VitNode uses the in-process fallbacks from the table above. Restart the API after changing `.env`. -{/* Image prompt: VitNode AdminCP Debug Panel at /admin/core/debug/cache with the "Cache" section open. Engine "Redis" with a green "Redis connected" status, "Realtime across instances: Yes", and a "Clear cache" button. Dark theme, 1440x900. */} + + + +## Check the result + +{/* Image prompt: VitNode AdminCP Debug Panel at /admin/core/debug/cache with the "Cache" section open. Engine "Redis" with a green "Redis connected" status, "Realtime across instances: Yes", and a "Clear Cache" button. Dark theme, 1440x900. */} + +Open **Debug Panel → Cache** in the AdminCP (`/admin/core/debug/cache`). With Redis connected you see **Redis connected** and whether realtime events reach every instance (**Realtime across instances**). Without Redis it says **Redis is not configured, so the cache lives in memory**. An unreachable server shows **Redis is unreachable**, also on the Debug Panel's **Overview**. + +## Use the cache in an API route + +`c.get("cache")` works the same with or without Redis. `remember` takes a key, a TTL in seconds and a loader, and calls the loader only when the key is missing: + +```ts +import { count } from "drizzle-orm"; + +import { site_notes } from "@/database/notes"; + +// inside a route handler, where `c` is the Hono context +const total = await c.get("cache").remember("notes:total", 60, async () => { + const [row] = await c.get("db").select({ value: count() }).from(site_notes); + return row.value; +}); +``` -Open **Debug Panel → Cache** in the AdminCP (`/admin/core/debug/cache`). It shows whether Redis is connected, unreachable or not configured (then the cache lives in memory), and whether realtime events reach every instance. An unreachable Redis also shows up on the Debug Panel's **Overview**. +Without Redis the 60-second TTL is capped at 15 seconds. [Cache API data](/docs/dev/cache/api) covers keys, invalidation after a write and the full method list. -## Learn More +## Related diff --git a/apps/web/content/docs/dev/ai/action-options.mdx b/apps/web/content/docs/dev/ai/action-options.mdx new file mode 100644 index 0000000000..69b9e55b1e --- /dev/null +++ b/apps/web/content/docs/dev/ai/action-options.mdx @@ -0,0 +1,322 @@ +--- +title: Action options +description: Reference for defineAiAction in VitNode. Every AI action field, default and limit, the options of c.get("ai").run(), runAsSystem() and stream(), the run result, and the order of checks during a run. +icon: ListChecks +--- + +import { TypeTable } from 'fumadocs-ui/components/type-table' + +This page lists everything an AI action can declare and everything a run accepts and returns. Use it when you change an action you already built; to build your first one, follow [AI actions](/docs/dev/ai/actions). The examples continue that page's `@vitnode/forum` **Summarize topic** action. + +```ts +import { defineAiAction } from '@vitnode/core/api/lib/ai/action' +import { aiActionRef } from '@vitnode/core/api/lib/ai/registry' +``` + +`defineAiAction(definition)` validates the definition when the module loads and fills in defaults. Anything invalid throws an `AiActionDefinitionError` at boot. Register the result in `buildApiPlugin({ aiActions: [...] })`. + +## Definition fields + + boolean | Promise', + }, + defaults: { + description: 'Limits for each run. See Defaults and limits.', + type: 'AiActionDefaults', + required: true, + }, + inputSchema: { + description: 'Zod schema that validates the input before anything else.', + type: 'z.ZodType', + required: true, + }, + measureInput: { + description: + 'Characters counted against `maxInputCharacters`. By default every string in the input counts.', + type: '(input) => number', + }, + output: { + description: 'Plain text or a structured object.', + type: "'text' | 'object'", + required: true, + }, + parseText: { + description: + 'Text actions only: turns the model text into the value `outputSchema` checks. Throw to reject the answer.', + type: '(text, input) => unknown', + }, + outputSchema: { + description: 'Zod schema that validates the answer.', + type: 'z.ZodType', + required: true, + }, + buildPrompt: { + description: + 'Builds the prompt from the validated input. Returns `{ prompt, system? }` or `{ messages, system? }`.', + type: '(input, { instructions }) => AiPrompt', + required: true, + }, + }} +/> + +## Ids and canonical keys + +Core stores an action under its **canonical key**, `:`: + +```text +@vitnode/forum:topic.summarize +@vitnode/blog:article.review +``` + +Two plugins may both have a `topic.summarize`, because their canonical keys differ. The same canonical key twice throws at boot. Settings, history, limits and field `ai` options all use the canonical key. + +`aiActionRef(pluginId, action)` returns the canonical key typed with the action's input and output. Pass it to `run()` and TypeScript checks `input` and infers `output`. A plain string key also works, but its output is `unknown`. + +## Title, description and icon + +Every action appears on **Artificial Intelligence (AI) → Actions**, on the role form's **Artificial intelligence (AI)** tab and on the member's AI usage page. `title` and `description` are message keys such as `ai_actions.@vitnode/forum.topic_summarize.title`. The API returns them untranslated and each page translates them, falling back to the default language. Keep them in your plugin's `src/locales/en.json` under `ai_actions` → your plugin id, and translate them like any other [plugin message](/docs/dev/i18n/messages). + +A value that is not a message key is shown as written and never translated, which is handy while prototyping. A blank title or description, or an icon that is not a kebab-case name, throws at boot. + +## Prompt and instructions + +`buildPrompt(input, { instructions })` returns either `{ prompt, system? }` or `{ messages, system? }`, the same prompt shapes the AI SDK takes. `instructions` is extra guidance an admin set for this action in the AdminCP, or `null`. Append it to the system prompt: + +```ts +buildPrompt: (input, { instructions }) => ({ + system: ['Summarize the forum topic in two plain sentences.', instructions] + .filter(Boolean) + .join('\n'), + prompt: `${input.title}\n\n${input.content}`, +}), +``` + +`promptVersion` is stored with every run, so the AI logs show which prompt produced which result. + +## Input and output + +- **`inputSchema`** validates the input first. Invalid input fails with `AI_INVALID_INPUT`. +- **`measureInput(input)`** returns the characters counted against `maxInputCharacters`. Override it when only part of the input reaches the prompt. The blog's article review counts the title and at most 30,000 characters of the article. +- **`output: "text"`**: the model returns text, `parseText(text, input)` turns it into your value, then `outputSchema` checks it. +- **`output: "object"`**: the model returns JSON through the AI SDK's `Output.object`, and `outputSchema` validates it. The action must require `structured-output`, and it cannot be streamed. + +An empty answer, a `parseText()` that throws or a schema mismatch fails with `AI_INVALID_OUTPUT`, and the user is not charged. + +```ts title="plugins/forum/src/api/ai/actions.ts (excerpt)" +const topicTitleIdeasAiAction = defineAiAction({ + id: 'topic.title-ideas', + title: 'ai_actions.@vitnode/forum.topic_title_ideas.title', + description: 'ai_actions.@vitnode/forum.topic_title_ideas.description', + promptVersion: 1, + permission: { key: 'summarize', defaultGranted: true }, + requiredCapabilities: ['text', 'structured-output'], + defaults: { maxInputCharacters: 20_000, maxOutputTokens: 200, timeoutMs: 30_000 }, + inputSchema: z.object({ content: z.string().min(1) }), + output: 'object', + outputSchema: z.object({ titles: z.array(z.string().max(120)).max(3) }), + buildPrompt: (input) => ({ + system: 'Suggest up to three short titles for this forum topic.', + prompt: input.content, + }), + authorize: async ({ c }) => + await checkStaffPermission(c, { + type: 'admin', + plugin: manifest.id, + module: topicContentType.permissionModule, + permission: 'can_view', + }), +}) +``` + +It sits next to **Summarize topic** and reuses that file's imports. Both actions use the `summarize` permission, so they share one role switch and one daily counter. Actions that share a key must declare the same `defaultGranted`, or boot fails. + +### Images + +For a vision action, require `image-input` and pass the image bytes as a `file` part. Read them server-side, for example with `c.get("storage").readBytes(key, maxBytes)`, so no private file has to be made public for the provider: + +```ts +buildPrompt: (input) => ({ + messages: [ + { + role: 'user', + content: [ + { type: 'text', text: 'Describe this image in one sentence.' }, + { type: 'file', data: input.image, mediaType: input.mediaType }, + ], + }, + ], +}), +``` + +`maxImages` defaults to `1` for these actions and bounds how many images one run may send. Only models that declare `image-input` run them. See [Model capabilities](/docs/dev/ai#model-capabilities). + +## Permissions and authorize() + +Every action names a **permission**, which admins grant per role on the **Artificial intelligence (AI)** tab of the role form: + +```ts +permission: 'summarize' // same as { key: 'summarize', defaultGranted: false } +permission: { key: 'summarize', defaultGranted: true } +``` + +`defaultGranted` applies while no role has a setting for the key. The stored permission is `:`, for example `@vitnode/forum:summarize`. + +`authorize()` is required for actions users can run. The AI permission says "this role may use AI summaries"; `authorize()` says "this user may read this topic". Both must pass, or the run fails with `AI_UNAUTHORIZED`. It receives `{ c, input, resource, userId }`, where `resource` is the optional `{ type, id }` the caller passed to `run()`. + +## Actors + +`actors` decides who may run an action. The server picks the actor; a request never does. + +| Actor | Runs with | Charged to | +| :------- | :-------------------------- | :--------------------------------------------- | +| `user` | `c.get("ai").run()` | The site budget and the user's AI points | +| `system` | `c.get("ai").runAsSystem()` | The site budget and the optional system budget | + +`run()` uses the signed-in AdminCP user first, then the site user, and fails with `AI_UNAUTHORIZED` when there is neither. A `system`-only action (`actors: ['system']`) needs no `authorize()`. Call `runAsSystem()` only from trusted server code such as cron jobs, queue tasks and event listeners; [Run an action in a background job](/docs/dev/ai/errors#run-an-action-in-a-background-job) shows one. + +## Defaults and limits + +`defaults` sets an action's limits. Admins can override most of them per action on **Artificial Intelligence (AI) → Actions**. A value outside its range throws at boot. + +| Field | Required | Default | Range | +| :------------------- | :------- | :------------------------------- | :-------------- | +| `maxInputCharacters` | Yes | - | 1 – 1,000,000 | +| `maxOutputTokens` | Yes | - | 1 – 200,000 | +| `timeoutMs` | Yes | - | 1,000 – 600,000 | +| `maxRetries` | No | `0` | 0 – 3 | +| `maxSteps` | No | `1` | 1 – 10 | +| `maxImages` | No | `1` with `image-input`, else `0` | 0 – 10 | +| `dailyLimit` | No | `null` (no limit) | 0 – 100,000 | + +- `timeoutMs` applies to each attempt. +- `maxRetries` is extra attempts on the same model after a failed provider call. +- `maxSteps` is model round trips inside one attempt (tool loops). Each step is billed as its own call. +- `dailyLimit` counts runs per user per day for the action's permission key. A role's daily limit wins over the admin's action setting, which wins over this default. +- Admins cannot change `maxImages`, because it bounds the budget reserved for images. + +Admins can also switch an action off, assign it a model and a fallback model, and add `instructions`. + +## Run options + +`c.get("ai").run()`, `runAsSystem()`, `stream()` and `estimate()` take the same request: + + + +## Run result + +| Method | Returns | +| :---------------------- | :---------------------------------------------- | +| `run()`, `runAsSystem()` | `Promise<{ output, runId, usage }>` | +| `stream()` | `Promise<{ textStream, result, runId }>`. See [Streaming](/docs/dev/ai/streaming). | +| `estimate()` | `Promise<{ maxPoints, maxUsd }>`, the most the run could cost as decimal strings, or `null` without pricing | + +```ts +{ + output: Output, // validated by outputSchema + runId: number, + usage: { + chargedPoints: string, // points charged to the user, full precision; "0" for system runs + costKnown: boolean, + modelId: string, // the configured model that answered + }, +} +``` + +`usage` is safe to send to the browser. It never contains prices or provider details. + +## What happens during a run + +1. `run()`, `stream()` and `estimate()` find the signed-in user (`AI_UNAUTHORIZED` without one). +2. Find the action (`AI_ACTION_UNKNOWN`), check the actor, validate `input` (`AI_INVALID_INPUT`). +3. Check that `ai.models` is not empty (`AI_NOT_CONFIGURED`), then the global AI switch and the action's switch. +4. For users: check the role permission, then `authorize()`. +5. Measure the input against `maxInputCharacters`. +6. Pick the model: the admin's choice, or the first configured model with every required capability. `stream()` also requires `streaming`. Resolve the optional fallback, skipping an incompatible one. +7. Build the prompt and count its images against `maxImages`. +8. **Reserve** budget for the worst case and check rate and concurrency limits, in one short database transaction. +9. Call the provider outside any transaction. Retry up to `maxRetries` times, then try the fallback model once. +10. Validate the output. +11. **Settle**: charge the real cost and release the rest of the reservation. +12. Emit `ai.run.completed` or `ai.run.failed`. + +A canceled run stops at once. An invalid answer is not retried on the same model, but the fallback model still gets one try. Nothing before step 8 costs anything. [Costs and budgets](/docs/dev/ai/costs-and-limits) explains steps 8 and 11. diff --git a/apps/web/content/docs/dev/ai/actions.mdx b/apps/web/content/docs/dev/ai/actions.mdx index f501416f7d..ee5c7c318b 100644 --- a/apps/web/content/docs/dev/ai/actions.mdx +++ b/apps/web/content/docs/dev/ai/actions.mdx @@ -1,6 +1,6 @@ --- -title: AI Actions -description: Define managed AI features with defineAiAction, register them in a plugin and run them with c.get("ai").run(). VitNode Core handles permissions, limits, budgets and history. +title: AI actions +description: Create your first managed AI action in a VitNode plugin. Define it with defineAiAction, register it, run it with c.get("ai").run() from an AdminCP route and find the run in the AI logs. icon: Wand implementation: objective: Add an AI feature to a plugin through a registered AI action. @@ -13,46 +13,35 @@ implementation: constraints: - Define the action with `defineAiAction` and register it in `aiActions` of the plugin's `buildApiPlugin()`. - Run it through `aiActionRef` with `c.get("ai").run()`, `runAsSystem()` or `stream()`. Never hard-code a model or call a provider SDK directly. - - '`authorize()` checks access to the content the action reads, for example with `checkStaffPermission`.' + - '`authorize()` checks access to the content the action reads, for example with `checkStaffPermission` on the content type''s `permissionModule`.' - The action's `title` and `description` are message keys in the plugin's locale, not plain text. + - Mount the route that runs the action in one of the plugin's modules. related: - - title: AI in fields and the editor - href: /docs/dev/ai/fields-and-editor - - title: AI costs and limits - href: /docs/dev/ai/costs-and-limits + - title: Action options + href: /docs/dev/ai/action-options + - title: AI errors and limits + href: /docs/dev/ai/errors - title: AI in the AdminCP href: /docs/dev/ai/admin --- -import { TypeTable } from 'fumadocs-ui/components/type-table' +An **AI action** is one AI feature a plugin offers, such as "write an excerpt", "translate a field" or "summarize a topic". Your plugin describes the prompt and the shape of the input and output. VitNode Core runs it. It picks the model, checks permissions, enforces limits, holds budget, calls the provider through the [Vercel AI SDK](https://ai-sdk.dev), validates the answer and records what it cost. -An **AI action** is one AI feature a plugin offers, such as "write an excerpt", "translate a field" or "suggest tags". The plugin describes the prompt and the input and output shapes. VitNode Core runs it: it picks the model, checks permissions, enforces limits, holds budget, calls the provider, validates the answer and records what it cost. +On this page you add a **Summarize topic** action to the `@vitnode/forum` plugin, run it from an AdminCP route, and find the run in the AI logs. -```ts -const { output } = await c.get('ai').run({ - action: EXCERPT_AI_ACTION, - input: { title, content, locale }, -}) -``` - -## Why actions exist - -Calling `generateText` with `c.get("ai").model()` works, but every plugin would then need its own answers to the same questions: who may use this, how much can they spend, what happens when the provider times out, and where did the money go? - -Actions answer those once, in Core: - -- **One place for models.** Admins choose the model, fallback and limits per action in the AdminCP. Plugins never hard-code a model. -- **One place for limits.** Permissions, daily limits, rate limits and concurrency apply to every action the same way. -- **One place for accounting.** Every run and every provider call is recorded with tokens and cost, and charged to the right [budget](/docs/dev/ai/costs-and-limits). - - - Direct SDK calls with `c.get("ai").model()` bypass budgets, limits and - history. Keep them for internal tooling you fully control. + + `generateText` with `c.get("ai").model()` works, but it skips permissions, + budgets, AI points and run history. Use an action for anything people can + trigger, and keep [direct SDK calls](/docs/dev/ai/usage) for internal tooling. -## Create an action +## Before you begin -This example adds a "summarize topic" button to a forum plugin's AdminCP. +- **A language model with the `text` capability** in `ai.models` of your API config. See [AI setup](/docs/dev/ai). +- **The `@vitnode/forum` plugin** in your app. Any plugin with an API part (`src/config.api.ts`) works the same way; swap the plugin id, paths and content type. +- **An AdminCP account with a root role.** Root roles hold every AI permission and have no point cap. Any other role needs the permission, a monthly points allowance (the default is `0`) and a priced model; [AI in the AdminCP](/docs/dev/ai/admin#first-time-setup) walks through that. + +## Build the action @@ -60,20 +49,23 @@ This example adds a "summarize topic" button to a forum plugin's AdminCP. ### Define the action +Create `src/api/ai/actions.ts` in the forum plugin: + ```ts title="plugins/forum/src/api/ai/actions.ts" import { defineAiAction } from '@vitnode/core/api/lib/ai/action' import { aiActionRef } from '@vitnode/core/api/lib/ai/registry' import { checkStaffPermission } from '@vitnode/core/api/lib/check-staff-permission' import { z } from 'zod' +import { topicContentType } from '@/content/topic' import { manifest } from '@/manifest.gen' export const zodSummarizeTopicSchema = z.object({ - title: z.string().trim().min(1).max(255), + title: z.string().trim().min(1).max(200), content: z.string().trim().min(1).max(50_000), }) -export const summarizeTopicAiAction = defineAiAction({ +const summarizeTopicAiAction = defineAiAction({ id: 'topic.summarize', title: 'ai_actions.@vitnode/forum.topic_summarize.title', description: 'ai_actions.@vitnode/forum.topic_summarize.description', @@ -101,7 +93,7 @@ export const summarizeTopicAiAction = defineAiAction({ await checkStaffPermission(c, { type: 'admin', plugin: manifest.id, - module: 'topics', + module: topicContentType.permissionModule, permission: 'can_view', }), }) @@ -114,10 +106,12 @@ export const SUMMARIZE_TOPIC_AI_ACTION = aiActionRef( ) ``` -`title` and `description` are message keys. Add their text to the plugin's -messages in the next step. +- `id` is local to the plugin. Core stores the action as `@vitnode/forum:topic.summarize`. +- `permission` is the switch admins flip per role. `defaultGranted: true` keeps it on until a role says otherwise. +- `authorize()` asks whether this admin may view forum topics. `topicContentType.permissionModule` is `topic`, derived from the type id `forum.topic`. +- `aiActionRef()` builds the canonical key and carries the input and output types, so `run()` can check them. -`defineAiAction()` validates the definition when the module loads. A bad id, a missing `authorize()` or an out-of-range limit throws an `AiActionDefinitionError` at boot, not in production traffic. +`defineAiAction()` validates the definition when the module loads, so a bad id or a missing `authorize()` throws at boot instead of in production traffic. Every field is listed in [Action options](/docs/dev/ai/action-options). @@ -125,11 +119,9 @@ messages in the next step. ### Add the title and description -Add the text to the plugin's messages, under the shared `ai_actions` key and -your plugin id. Every AI page loads `ai_actions` as one namespace, so all -plugins' action names arrive together. +`title` and `description` are message keys. Add their text to the plugin's messages under the shared `ai_actions` key and your plugin id. Keep the existing keys in the file: -```json title="plugins/forum/src/locales/en.json" +```json title="plugins/forum/src/locales/en.json (excerpt)" { "ai_actions": { "@vitnode/forum": { @@ -142,24 +134,27 @@ plugins' action names arrive together. } ``` +The AdminCP, the role form and the member's AI usage page translate these keys in the reader's language. + -### Register it in the plugin +### Register the action in the plugin -```ts title="plugins/forum/src/config.api.ts" -import { buildApiPlugin } from '@vitnode/core/api/lib/plugin' +Open `src/config.api.ts` and pass the list to `aiActions`. Leave the existing modules as they are: -import { forumAiActions } from '@/api/ai/actions' -import { adminModule } from '@/api/modules/admin/admin.module' -import { manifest } from '@/manifest.gen' +```ts title="plugins/forum/src/config.api.ts (excerpt)" +import { forumAiActions } from '@/api/ai/actions' // [!code ++] export const forumApiPlugin = () => buildApiPlugin({ manifest, aiActions: forumAiActions, // [!code ++] - modules: [adminModule], + modules: [ + adminModule, + // ...forumModule and the public content module stay unchanged + ], }) ``` @@ -167,7 +162,9 @@ export const forumApiPlugin = () => -### Run it from a route +### Run the action from a route + +Create an AdminCP route that validates the body and passes it to `c.get("ai").run()`: ```ts title="plugins/forum/src/api/modules/admin/ai/routes/summarize.route.ts" import { z } from 'zod' @@ -177,9 +174,13 @@ import { zodSummarizeTopicSchema, } from '@/api/ai/actions' import { buildRoute } from '@/api/builders' +import { topicContentType } from '@/content/topic' export const summarizeTopicRoute = buildRoute({ - adminStaffPermission: { module: 'topics', permission: 'can_view' }, + adminStaffPermission: { + module: topicContentType.permissionModule, + permission: 'can_view', + }, route: { method: 'post', description: 'Summarize a forum topic with the configured AI model.', @@ -197,6 +198,7 @@ export const summarizeTopicRoute = buildRoute({ }, description: 'The summary', }, + 400: { description: 'No AI model is configured or the input is invalid' }, 403: { description: 'No access to topics or to this AI feature' }, 429: { description: 'A personal AI limit was reached' }, 502: { description: 'The AI provider failed or answered unusably' }, @@ -204,7 +206,6 @@ export const summarizeTopicRoute = buildRoute({ }, }, handler: async (c) => { - // [!code ++:4] const { output } = await c.get('ai').run({ action: SUMMARIZE_TOPIC_AI_ACTION, input: c.req.valid('json'), @@ -215,363 +216,126 @@ export const summarizeTopicRoute = buildRoute({ }) ``` -TypeScript infers `output` as `string` from the action's `outputSchema`. When the run fails, `run()` throws an `AiError` and the route answers with its [error code](#error-codes). +TypeScript infers `output` as `string` from the action's `outputSchema`. When a run fails, `run()` throws an `AiError` and the route answers with its status and a JSON body. See [AI errors and limits](/docs/dev/ai/errors). - - -For a complete, real example, read the `@vitnode/blog` plugin: `plugins/blog/src/api/ai/actions.ts` and its `admin/ai` routes. - -## Action ids - -An action has a **local id**, unique inside its plugin: lowercase, dot-separated words such as `topic.summarize`. - -Core stores it under a **canonical key**, `:`: - -```text -@acme/forum:topic.summarize -@vitnode/blog:article.review -``` - -Two plugins may both have a `topic.summarize`; their canonical keys differ. The same canonical key twice throws at boot. Settings, history and limits all use the canonical key. - -`aiActionRef(pluginId, action)` builds the canonical key **and** remembers the action's input and output types. Pass it to `run()` and TypeScript checks `input` and infers `output`. A plain string key also works, but its output is `unknown`. - -## Title, description and icon - -Every action appears as a row on **Artificial Intelligence (AI) → Actions**, as a feature on the role form's **Artificial intelligence (AI)** tab and in a member's AI usage. Each of those needs a human name in every language: - -- **`title`** (required): the message key of a short name, e.g. `ai_actions.@vitnode/forum.topic_summarize.title` for "Summarize topic". -- **`description`** (required): the message key of one sentence on what it does. -- **`icon`** (optional): a [Lucide](https://lucide.dev/icons) icon name in kebab case, e.g. `languages` or `wand-sparkles`. Without one, the row shows a sparkles icon. - -The API returns both keys untranslated. The AdminCP and the member's settings translate them into the page's language. A language without the key falls back to the default language's text. Keep the keys in your plugin's `src/locales/en.json` under `ai_actions` → your plugin id, and translate them like any other [plugin message](/docs/dev/i18n/messages). - - - A `title` or `description` that is not a message key is shown as written. That - is handy while prototyping, but it never gets translated. - - -A blank title or description, or an icon that isn't a kebab-case name, throws at boot. - -## Prompt versions and instructions - -`promptVersion` is stored with every run. Bump it whenever the prompt changes meaningfully, so the history shows which prompt produced which result. + -`buildPrompt(input, { instructions })` returns either `{ prompt, system? }` or `{ messages, system? }`. `instructions` is optional extra guidance an admin set for this action in the AdminCP, or `null`. Append it to the system prompt. +### Mount the route -## Input and output +Create an `ai` module for the route: -- **`inputSchema`** (Zod) validates the input before anything else happens. Invalid input fails with `AI_INVALID_INPUT`. -- **`measureInput(input)`** returns the number of characters counted against `maxInputCharacters`. By default every string in the input counts. Override it when only part of the input reaches the prompt. The blog's article review, for example, counts the title and at most 30,000 characters of the article text. -- **`output: "text"`** makes the model return text. `parseText(text, input)` turns it into your output, then `outputSchema` checks it. Throw from `parseText()` to reject an answer. -- **`output: "object"`** makes the model return structured JSON, which `outputSchema` validates. The action must require the `structured-output` capability, and it cannot be streamed. +```ts title="plugins/forum/src/api/modules/admin/ai/ai.admin.module.ts" +import { buildModule } from '@/api/builders' -An empty answer, a `parseText()` that throws or a schema mismatch fails with `AI_INVALID_OUTPUT`. The user is not charged for it. +import { summarizeTopicRoute } from './routes/summarize.route' -```ts title="An object action" -export const suggestTagsAiAction = defineAiAction({ - id: 'topic.tags', - title: 'ai_actions.@vitnode/forum.topic_tags.title', - description: 'ai_actions.@vitnode/forum.topic_tags.description', - promptVersion: 1, - permission: 'tags', - actors: ['system'], - requiredCapabilities: ['text', 'structured-output'], - defaults: { - maxInputCharacters: 20_000, - maxOutputTokens: 200, - timeoutMs: 30_000, - maxRetries: 1, - }, - inputSchema: z.object({ content: z.string().min(1) }), - output: 'object', - outputSchema: z.object({ tags: z.array(z.string().max(32)).max(5) }), - buildPrompt: (input) => ({ - system: 'Suggest up to five short, lowercase tags for this forum topic.', - prompt: input.content, - }), +export const aiAdminModule = buildModule({ + name: 'ai', + routes: [summarizeTopicRoute], }) - -export const TOPIC_TAGS_AI_ACTION = aiActionRef( - manifest.id, - suggestTagsAiAction, -) ``` -`actors: ["system"]` makes this a background-only action, so it needs no `authorize()`. See [Actors](#actors-users-and-the-system). - -### Images +Then add it to the forum's existing `admin` module: -For a vision action, require `image-input` and pass the image **bytes** as a `file` part. Read them server-side, for example with `c.get("storage").readBytes(key, maxBytes)`, so no private file ever has to be made public for the provider: +```ts title="plugins/forum/src/api/modules/admin/admin.module.ts (excerpt)" +import { aiAdminModule } from './ai/ai.admin.module' // [!code ++] -```ts -buildPrompt: (input) => ({ - messages: [ - { - role: 'user', - content: [ - { type: 'text', text: 'Describe this image in one sentence.' }, - { type: 'file', data: input.image, mediaType: input.mediaType }, - ], - }, +export const adminModule = buildModule({ + name: 'admin', + routes: [], + modules: [ + aiAdminModule, // [!code ++] + buildContentAdminModule({ + containers: [forumContainers], + contentTypes: [topicContent, commentContent], + }), ], -}), -``` - -`maxImages` defaults to `1` for these actions and bounds how many images one run may send. Only models that declare `image-input` are used. See [Model capabilities](/docs/dev/ai#model-capabilities). - -## Permissions and authorize() - -Every action names a **permission**. Admins grant it per role on the **Artificial intelligence (AI)** tab of the role form. Several actions may share one key. They then share one switch and one daily counter. - -```ts -permission: 'summarize' // same as { key: 'summarize', defaultGranted: false } -permission: { key: 'summarize', defaultGranted: true } -``` - -`defaultGranted` applies while no role has a setting for the key. The canonical permission is `:`. Two actions sharing a key must agree on `defaultGranted`, or boot fails. - -`authorize()` is **required** for actions users can run. The AI permission says "this role may use AI summaries". `authorize()` says "this user may read _this_ topic". Both must pass: - -```ts -// canReadTopic() is the forum plugin's own access check -authorize: async ({ c, input, userId }) => - await canReadTopic(c, { topicId: input.topicId, userId }), -``` - -`resource` is the optional `{ type, id }` the caller passed to `run()`. It is also stored with the run, so history can link back to the content. - -## Actors: users and the system - -`actors` decides who may run an action. The server sets the actor; the request never does. - -| Actor | Runs with | Charged to | -| :------- | :-------------------------- | :--------------------------------------------- | -| `user` | `c.get("ai").run()` | The site budget and the user's AI points | -| `system` | `c.get("ai").runAsSystem()` | The site budget and the optional system budget | - -The default is `["user"]`. `run()` uses the signed-in AdminCP user first, then the site user. Without one, it fails with `AI_UNAUTHORIZED`. - -Use `runAsSystem()` only from trusted server code: cron jobs, queue tasks, event listeners. No route should pass a client's request into it. - -```ts title="plugins/forum/src/api/cron/tag-topics.cron.ts" -import { isAiError } from '@vitnode/core/api/lib/ai/errors' -import { buildCron } from '@vitnode/core/api/lib/cron' - -import { TOPIC_TAGS_AI_ACTION } from '@/api/ai/actions' - -export const tagTopicsCron = buildCron({ - name: 'tag-new-topics', - description: 'Suggest tags for new forum topics', - schedule: '*/15 * * * *', - handler: async (c) => { - for (const topic of await loadUntaggedTopics(c)) { - try { - const { output } = await c.get('ai').runAsSystem({ - action: TOPIC_TAGS_AI_ACTION, - input: { content: topic.content }, - resource: { type: 'forum_topic', id: topic.id }, - }) - await saveTopicTags(c, topic.id, output.tags) - } catch (error) { - // Out of budget: stop now and try again on the next tick. - if (isAiError(error) && error.code === 'AI_BUDGET_EXHAUSTED') return - throw error - } - } - }, }) ``` -## Defaults and limits - -`defaults` sets an action's limits. Admins can override most of them per action in the AdminCP. Values outside the allowed range throw at boot. - -| Field | Required | Default | Range | -| :------------------- | :------- | :------------------------------- | :-------------- | -| `maxInputCharacters` | Yes | - | 1 – 1,000,000 | -| `maxOutputTokens` | Yes | - | 1 – 200,000 | -| `timeoutMs` | Yes | - | 1,000 – 600,000 | -| `maxRetries` | No | `0` | 0 – 3 | -| `maxSteps` | No | `1` | 1 – 10 | -| `maxImages` | No | `1` with `image-input`, else `0` | 0 – 10 | -| `dailyLimit` | No | `null` (no limit) | 0 – 100,000 | - -- `timeoutMs` applies to each attempt. -- `maxRetries` is extra attempts on the same model after a failed provider call. -- `maxSteps` is model round trips inside one attempt (tool loops). Each step is billed as its own call. -- `dailyLimit` counts runs per user per day for the action's permission key. A role's daily limit takes precedence over the admin's action setting, which takes precedence over this default. -- Admins cannot change `maxImages`, because it bounds the budget reserved for images. +The route is now served at `POST /api/@vitnode/forum/admin/ai/summarize`. -Admins can also, per action: switch it off, assign a model, assign a fallback model, and add `instructions`. - -## Run options - - - -`run()` and `runAsSystem()` resolve to: - -```ts -{ - output: Output, // validated by outputSchema - runId: number, - usage: { - chargedPoints: string, // points charged to the user, full precision; "0" for system runs - costKnown: boolean, - modelId: string, // the configured model that answered - }, -} -``` - -You can safely return `usage` to the browser: it never contains prices or provider details. - -## What happens during a run - -1. Find the action, check the actor, validate `input`. -2. Check the global AI switch and the action's switch. -3. For users: check the role permission, then `authorize()`. -4. Measure the input and the number of images. -5. Pick the model: the admin's choice, or the first configured model with every required capability. Resolve the optional fallback, skipping an incompatible one. -6. **Reserve** budget for the worst case and check rate and concurrency limits, in one short database transaction. -7. Call the provider outside any transaction. Retry up to `maxRetries` times, then try the fallback model once. -8. Validate the output. -9. **Settle**: charge the real cost and release the rest of the reservation. -10. Emit `ai.run.completed` or `ai.run.failed`. - -A canceled run stops immediately. An invalid answer is not retried on the same model, but the fallback model still gets one try. Everything before step 6 costs nothing. [Costs, Points & Budgets](/docs/dev/ai/costs-and-limits) explains steps 6 and 9. - -## Stream a text action - -`c.get("ai").stream()` streams a `text` action for the signed-in user. Object actions cannot be streamed. - -```ts -handler: async (c) => { - const { textStream } = await c.get('ai').stream({ - action: SUMMARIZE_TOPIC_AI_ACTION, - input: c.req.valid('json'), - signal: c.req.raw.signal, // stop paying when the reader leaves - }) - - return new Response(textStream.pipeThrough(new TextEncoderStream()), { - headers: { 'Content-Type': 'text/plain; charset=utf-8' }, - }) -} -``` - -The stream also returns `result`, a promise that resolves to the validated output once the stream ends, and rejects with an `AiError` if the full text is invalid. A stream makes one attempt on the primary model, with no retries and no fallback. If the reader cancels, the run settles as `canceled`. The user is charged nothing if no text had reached them yet; once text was sent, their points and daily count are charged as for a delivered run. - -## Error codes - -Every refusal and failure is an `AiError` with a stable `code`. Routes answer with this JSON body. Key the browser's messages on `code`, never on the English `message`: - -```json -{ - "code": "AI_USER_LIMIT_REACHED", - "message": "You used all your AI points for this period.", - "resetsAt": "2026-11-01T00:00:00.000Z", - "runId": 42 -} -``` - -`resetsAt` is set for limit and budget errors. `runId` is set when a run was recorded. - -| Code | Status | When | -| :----------------------- | :----- | :---------------------------------------------------------------------- | -| `AI_NOT_CONFIGURED` | 400 | No `ai.models` are configured. | -| `AI_INVALID_INPUT` | 400 | The input failed `inputSchema`, or an object action was streamed. | -| `AI_INPUT_TOO_LARGE` | 413 | The input exceeds `maxInputCharacters` or `maxImages`. | -| `AI_DISABLED` | 503 | An admin switched AI off for the whole site. | -| `AI_ACTION_DISABLED` | 403 | An admin switched this action off. | -| `AI_ACTION_UNKNOWN` | 404 | No installed plugin registers this key. | -| `AI_UNAUTHORIZED` | 403 | Not signed in, no AI permission, `authorize()` refused, or wrong actor. | -| `AI_MODEL_INCOMPATIBLE` | 409 | No model has the required capabilities. | -| `AI_PRICING_MISSING` | 409 | The model has no pricing while a budget cap applies. | -| `AI_USER_LIMIT_REACHED` | 429 | The user's monthly AI points are used up. | -| `AI_DAILY_LIMIT_REACHED` | 429 | The user reached today's limit for this permission. | -| `AI_RATE_LIMITED` | 429 | Too many runs started in the last minute. | -| `AI_CONCURRENCY_LIMITED` | 429 | Too many runs in flight. | -| `AI_BUDGET_EXHAUSTED` | 503 | The site budget (or the system budget) for this month is used up. | -| `AI_DUPLICATE_REQUEST` | 409 | The `idempotencyKey` was already used. | -| `AI_PROVIDER_FAILED` | 502 | The provider returned an error. | -| `AI_INVALID_OUTPUT` | 502 | The answer was empty or failed `parseText()` / `outputSchema`. | -| `AI_TIMEOUT` | 504 | The provider took longer than `timeoutMs`. | -| `AI_CANCELED` | 408 | The `signal` aborted the run. | - -Import the helpers from `@vitnode/core/api/lib/ai/errors`: `AiError`, `isAiError()`, `AI_ERROR_CODES` and `AI_ERROR_STATUS`. - -## Events - -Each run that got past the reservation emits one event after it settles: - -| Event | Payload | -| :----------------- | :------------------------------------------------------- | -| `ai.run.completed` | `actionKey`, `actorType`, `runId`, `userId` | -| `ai.run.failed` | `actionKey`, `actorType`, `errorCode`, `runId`, `userId` | - -`userId` is `null` for system runs. Refusals before the reservation, such as a missing permission or a reached limit, emit nothing. A listener that throws never breaks the run. - -```ts title="plugins/forum/src/api/lib/ai-events.ts" -import { buildEventListener } from '@vitnode/core/api/lib/events' - -export const aiFailureListener = buildEventListener({ - event: 'ai.run.failed', - name: 'log-ai-failures', - handler: async (c, payload) => { - if (!payload.actionKey.startsWith('@acme/forum:')) return - await c - .get('log') - .warn(`AI run ${payload.runId} failed: ${payload.errorCode}`) - }, -}) -``` + -See [Events](/docs/dev/events) for registering listeners. + -## Learn more +## Check the result + +Start or restart the dev server so the API loads the new action, then sign in to the AdminCP. + +1. Open **Artificial Intelligence (AI) → Actions**. A **Summarize topic** row appears with its description and icon. Nothing has been spent yet. +2. Add a temporary button that calls the route with `fetcher`. Create `src/pages/check-summarize.tsx`: + + ```tsx title="plugins/forum/src/pages/check-summarize.tsx" + import { Button } from '@vitnode/core/components/ui/button' + import { fetcher } from '@vitnode/core/tanstack/fetcher' + import { useState } from 'react' + + import { manifest } from '@/manifest.gen' + + export const CheckSummarize = () => { + const [result, setResult] = useState('') + const [isPending, setIsPending] = useState(false) + + const onClick = async () => { + setIsPending(true) + try { + const response = await fetcher({ + plugin: manifest.id, + method: 'post', + module: 'admin/ai', + path: '/summarize', + args: { + body: { + title: 'Parking on Saturday', + content: + 'The lot is closed on Saturday morning for repainting. Park on Elm Street instead.', + }, + }, + }) + setResult(JSON.stringify(await response.json())) + } finally { + setIsPending(false) + } + } + + return ( +
        + + {result ? {result} : null} +
        + ) + } + ``` + + Render `` on a forum page, such as `src/pages/forum-page.tsx`. The browser sends the AdminCP session cookie from every page, so open that page in the browser you signed in with and press the button. It shows `{"text":"…"}` with a two-sentence summary. This call spends real provider tokens. Remove the button when you are done. + +3. Open **Debug Panel → AI logs**. The newest row is `@vitnode/forum:topic.summarize` with status `succeeded`, the model that answered, the tokens and the cost. + +If the call answers an error instead, the body names it, for example `{ "code": "AI_NOT_CONFIGURED", ... }` when `ai.models` is empty. [AI errors and limits](/docs/dev/ai/errors) lists every code and its fix. + +Your own React code calls the route the same way, with `fetcher`. See [Fetcher](/docs/dev/fetcher). + +## Related + diff --git a/apps/web/content/docs/dev/ai/admin.mdx b/apps/web/content/docs/dev/ai/admin.mdx index bcc653c7e3..d160b0e344 100644 --- a/apps/web/content/docs/dev/ai/admin.mdx +++ b/apps/web/content/docs/dev/ai/admin.mdx @@ -1,5 +1,5 @@ --- -title: AdminCP & Account Usage +title: AI in the AdminCP description: Manage AI in the VitNode AdminCP with the global switch, monthly budgets in USD, per-action settings, AI access on roles and users, and history. Members see their AI points at /settings/ai. icon: LayoutDashboard --- @@ -26,7 +26,7 @@ Models and their prices are not edited in the AdminCP. They live in `vitnode.api ## First-time setup -[AI Setup](/docs/dev/ai) covers the models and prices you set in code. Everything after that happens in the AdminCP. +[AI setup](/docs/dev/ai) covers the models and prices you set in code. Everything after that happens in the AdminCP. @@ -58,7 +58,7 @@ Open **Users → Roles**, edit a role and switch to the **Artificial intelligenc ### Optionally turn on automatic ALT text -In the same **Settings** sheet, under **ALT text**, switch on **Write ALT text automatically**. You need a vision model (`image-input`) with pricing and a running cron. See [Automatic Image ALT Text](/docs/dev/ai/automatic-alt). +In the same **Settings** sheet, under **ALT text**, switch on **Write ALT text automatically**. You need a vision model (`image-input`) with pricing and a running cron. See [Image alt text](/docs/dev/ai/automatic-alt). @@ -100,7 +100,7 @@ Switch **AI features** off and the rest of the sheet hides, because none of it a Budgets and allowances are calendar months in the **site's time zone** (the default language's). The settings sheet shows which one. -Budgets are in **USD**, because that is what providers bill. Members are charged in **AI points**. [Costs, Points & Budgets](/docs/dev/ai/costs-and-limits) explains the conversion and what happens on failure. +Budgets are in **USD**, because that is what providers bill. Members are charged in **AI points**. [Costs and budgets](/docs/dev/ai/costs-and-limits) explains the conversion and what happens on failure. ## Actions @@ -233,21 +233,23 @@ Every screen is backed by an API route under `@vitnode/core`. Use them with [`fe | `GET /ai/usage` | signed in | The member's own allowance, notices and features | | `GET /ai/history?action=&limit=` | signed in | The member's own runs, optionally for one feature | +[Custom UI](/docs/dev/ai/custom-ui#built-in-run-routes) and [Streaming](/docs/dev/ai/streaming#built-in-stream-routes) list the routes that run actions (`/admin/ai/assist*`, `/ai/run`, `/ai/stream`, `/ai/estimate`, `/ai/available` and the feedback routes). [Translations](/docs/dev/ai/translations#track-outdated-translations) covers `/admin/ai/translation-sources`. + ## Learn more diff --git a/apps/web/content/docs/dev/ai/automatic-alt.mdx b/apps/web/content/docs/dev/ai/automatic-alt.mdx index ff10ce5703..d0906248e8 100644 --- a/apps/web/content/docs/dev/ai/automatic-alt.mdx +++ b/apps/web/content/docs/dev/ai/automatic-alt.mdx @@ -1,5 +1,5 @@ --- -title: Automatic Image ALT Text +title: Image alt text description: VitNode can describe uploaded images in the background and translate the ALT text into every site language, under a budget and without overwriting text a person wrote. This page covers how it works, how to render it and how to run its cron. icon: Image --- @@ -22,6 +22,8 @@ import { resolveImageAlt } from '@vitnode/core/lib/files/resolve-alt' /> ``` +`item` is the article your page loader returned, and `locale` and `defaultLocale` are the page's current and default languages. See [Render the ALT text](#render-the-alt-text) for the rules `resolveImageAlt()` follows. + ## Turn it on @@ -138,7 +140,7 @@ Each task has a 15-minute lease. A worker that dies mid-task leaves it `processi The job reads the image **server-side** and sends bytes to the provider, never a URL: -- through the storage adapter's optional `read(key, { maxBytes })` method (the Local adapter has one); +- through the storage adapter's optional `read(key, { maxBytes })` method (the bundled Local, S3 and Supabase adapters all have one); - otherwise by a bounded server-side `fetch()` of the adapter's URL. Files over 20 MB are skipped. With `sharp` installed, the copy sent to the provider is resized to at most 1024 px and re-encoded as WebP. A private file never has to be made public for the AI provider. @@ -253,23 +255,23 @@ See [Built-in events](/docs/dev/events/built-in-events#ai-events). - **Images only.** The job reads `image/jpeg`, `image/png`, `image/gif` and `image/webp`. It never analyses other files. - **Base language is English.** Other languages are translations of the English description. - **Legacy `metadata.alt` is not migrated.** An ALT a plugin stored in the free-form `core_files.metadata` column has no language, and VitNode does not guess one. The job treats those images as missing ALT text. Copy such values into `core_files_alt` yourself with `PUT /admin/files/{id}/alt` if you know their language. -- **Cost.** The **Automatic ALT text** card on **Artificial Intelligence (AI) → Overview** shows images described, translations and the known cost per image and per translation. +- **Cost.** The job's runs appear in **Debug Panel → AI logs** under **Background jobs**. Filter by the actions `@vitnode/core:media.alt.generate` and `@vitnode/core:media.alt.translate` to see what each image and translation cost. ## Learn more diff --git a/apps/web/content/docs/dev/ai/costs-and-limits.mdx b/apps/web/content/docs/dev/ai/costs-and-limits.mdx index f7a75e7ab1..ee385a2610 100644 --- a/apps/web/content/docs/dev/ai/costs-and-limits.mdx +++ b/apps/web/content/docs/dev/ai/costs-and-limits.mdx @@ -1,5 +1,5 @@ --- -title: Costs, Points & Budgets +title: Costs and budgets description: How VitNode prices every AI call, converts it to AI points, reserves budget before a provider call and charges users only for results they receive. icon: Coins --- @@ -336,12 +336,12 @@ Budgets, reservations, rate limits, concurrency and idempotency keys live in Pos diff --git a/apps/web/content/docs/dev/ai/custom-ui.mdx b/apps/web/content/docs/dev/ai/custom-ui.mdx new file mode 100644 index 0000000000..3df93921db --- /dev/null +++ b/apps/web/content/docs/dev/ai/custom-ui.mdx @@ -0,0 +1,170 @@ +--- +title: Custom UI +description: Run any registered VitNode AI action from your own AdminCP component with useAvailableAiActions() and requestAiAssist(), using Core's generic AI routes instead of writing a route of your own. +icon: MousePointerClick +--- + +The [field suggestion](/docs/dev/ai/fields-and-editor) button is built on generic Core routes that run any registered AI action for the signed-in user. Your own components can use them too, so an AI button needs no API route of its own. This page adds a **Summarize** button for the forum's **Summarize topic** action to an AdminCP component. + +## Before you begin + +- **An action that users can run** (`actors` includes `user`), registered in a plugin. This page uses **Summarize topic** from [AI actions](/docs/dev/ai/actions). +- **An AdminCP page or dialog in your plugin** to put the button on. `requestAiAssist()` calls the AdminCP route, so it uses the admin's session. + +## Add an AI button + + + + + +### Hide the button when the action is unavailable + +`useAvailableAiActions()` returns the action keys the current admin may start. An action makes the list when AI is on, the action is enabled, a configured model can run it, and the admin's roles grant its permission. + +```tsx title="plugins/forum/src/views/admin/summarize-button.tsx" +import { useAvailableAiActions } from '@vitnode/core/components/ai/use-available-ai-actions' + +const SUMMARIZE_ACTION = '@vitnode/forum:topic.summarize' + +export const SummarizeButton = ({ + content, + title, +}: { + content: string + title: string +}) => { + const { data: available = [] } = useAvailableAiActions('admin') + if (!available.includes(SUMMARIZE_ACTION)) return null + + // ...the button comes in the next step +} +``` + +The list only decides which buttons to show. The server checks the permission and `authorize()` again on every run. + + + + + +### Run the action + +`requestAiAssist()` posts to `POST /admin/ai/assist` with a fresh idempotency key and resolves to `{ output, runId }`. On failure it throws an `AiRequestError`; `aiErrorCodeOf()` reads its code, and Core's `core.global.ai_assist.error` messages translate it. + +```tsx title="plugins/forum/src/views/admin/summarize-button.tsx" +import { useAvailableAiActions } from '@vitnode/core/components/ai/use-available-ai-actions' +import { Button } from '@vitnode/core/components/ui/button' +import { + aiErrorCodeOf, + requestAiAssist, +} from '@vitnode/core/lib/ai/assist-client' +import React from 'react' +import { useTranslations } from 'use-intl' + +const SUMMARIZE_ACTION = '@vitnode/forum:topic.summarize' + +export const SummarizeButton = ({ + content, + title, +}: { + content: string + title: string +}) => { + const tError = useTranslations('core.global.ai_assist.error') + const { data: available = [] } = useAvailableAiActions('admin') + const [summary, setSummary] = React.useState(null) + const [error, setError] = React.useState(null) + const [pending, setPending] = React.useState(false) + + if (!available.includes(SUMMARIZE_ACTION)) return null + + const onClick = async () => { + setPending(true) + setError(null) + try { + const { output } = await requestAiAssist({ + action: SUMMARIZE_ACTION, + input: { content, title }, + }) + setSummary(typeof output === 'string' ? output : null) + } catch (caught) { + setError(tError(aiErrorCodeOf(caught))) + } finally { + setPending(false) + } + } + + return ( +
        + + {summary &&

        {summary}

        } + {error &&

        {error}

        } +
        + ) +} +``` + +`output` is `unknown` in the browser, because the generic route does not know your action's types. Check its shape before you use it. + +
        + + + +### Record whether the result was used (optional) + +When the editor accepts or discards a suggestion, record it on the run. It feeds the AI logs and never changes what was charged. Read `runId` from the `requestAiAssist()` result: + +```ts +import { sendAiFeedback } from '@vitnode/core/lib/ai/assist-client' + +await sendAiFeedback({ accepted: true, runId }) +``` + +`sendAiFeedback()` ignores network errors, so it never breaks the UI. + + + +
        + +## Check the result + +Render `` on an AdminCP page with a topic's title and text, then click **Summarize**. The button shows its loading state, and the summary appears below it. **Debug Panel → AI logs** shows the run. An admin whose roles lack the `summarize` permission does not see the button at all. + +## Built-in run routes + +| Route | Session | Purpose | +| :----------------------------------------- | :---------- | :------------------------------------------ | +| `POST /admin/ai/assist` | AdminCP | Run an action, JSON in, JSON out. | +| `GET /admin/ai/assist/available` | AdminCP | Action keys this admin may start. | +| `POST /admin/ai/assist/runs/{id}/feedback` | AdminCP | Record `{ accepted }` for one of your runs. | +| `POST /ai/run` | Site member | Run an action, JSON in, JSON out. | +| `GET /ai/available` | Site member | Action keys this member may start. | +| `POST /ai/runs/{id}/feedback` | Site member | Record `{ accepted }`. | + +All of them live in `@vitnode/core`, so the full path is `/api/@vitnode/core/...`. The run body is `{ action, input, idempotencyKey?, resource?, sourceFingerprint? }`, and a successful run answers `{ output, runId, usage }`. The body never carries a model, a price or an actor; the server decides those. These routes have no extra staff permission on purpose. The action's AI permission and its `authorize()` decide who gets in. + +For member pages, use `useAvailableAiActions('user')` and call `POST /ai/run` with [`fetcher`](/docs/dev/fetcher). `requestAiAssist()` only calls the AdminCP route. To stream the answer instead, see [Streaming](/docs/dev/ai/streaming). + +## Example: pre-publication review + +The blog's `@vitnode/blog:article.review` action is an optional read-through in the article editor's side panel, built exactly like this. Its component sends `{ content, locale, title }` with `requestAiAssist()`, where `content` is the article's rich-text document. It is an `output: "object"` action, so it needs a `structured-output` model, and it returns: + +```ts +{ + summary: string, + suggestions: Array<{ + area: 'clarity' | 'completeness' | 'structure' | 'tone', + fix: { quote: string, replacement: string } | null, + message: string, + priority: 'high' | 'low', + }>, // at most 8 +} +``` + +`fix` is set when a suggestion is about specific wording. `quote` is a passage copied from the article and `replacement` is the rewritten passage, or an empty string to delete it. + +- It **never verifies facts**. The prompt forbids judging whether facts are true, because the model has nothing to check them against. +- It **never gates publishing**. Publishing works the same with or without a review. + +Read `plugins/blog/src/api/ai/actions.ts` and `plugins/blog/src/views/admin/article/editor/ai-review.tsx` for the whole feature. diff --git a/apps/web/content/docs/dev/ai/errors.mdx b/apps/web/content/docs/dev/ai/errors.mdx new file mode 100644 index 0000000000..59cabd7248 --- /dev/null +++ b/apps/web/content/docs/dev/ai/errors.mdx @@ -0,0 +1,189 @@ +--- +title: Errors and limits +description: Handle VitNode AI action errors. The AiError JSON body, every error code with its status and fix, showing errors in the browser, stopping background jobs when the budget runs out, and listening to ai.run events. +icon: ShieldAlert +--- + +When an AI action is refused or fails, VitNode throws an `AiError` with a stable `code` such as `AI_USER_LIMIT_REACHED`. This page shows how to read that code in a route, in the browser and in a background job, what each code means and how to fix it, and how to react to finished runs with events. + +The examples continue the **Summarize topic** action from [AI actions](/docs/dev/ai/actions). + +## The error body + +A route that calls `c.get("ai").run()` does not need a `try`/`catch`. `AiError` is a Hono `HTTPException`, so an uncaught one becomes a response with the code's status and this JSON body: + +```json +{ + "code": "AI_USER_LIMIT_REACHED", + "message": "You used all your AI points for this period.", + "resetsAt": "2026-11-01T00:00:00.000Z", + "runId": 42 +} +``` + +- `resetsAt` is set for limit and budget errors. +- `runId` is set when a run was recorded, so you can find it in **Debug Panel → AI logs**. +- `message` is English, for logs. Key whatever the user sees on `code`. + +You can see one without spending anything. Core's member run route refuses a request without a session before it looks at the action: + +```bash +curl -i -X POST http://localhost:3000/api/@vitnode/core/ai/run \ + -H 'Content-Type: application/json' \ + -H 'Origin: http://localhost:3000' \ + -d '{"action":"@vitnode/forum:topic.summarize","input":{}}' +``` + +```text +HTTP/1.1 403 + +{"code":"AI_UNAUTHORIZED","message":"Sign in to use AI features."} +``` + +## Error codes + +| Code | Status | When | What to do | +| :----------------------- | :----- | :---------------------------------------------------------------------- | :------------------------------------------------------------------ | +| `AI_NOT_CONFIGURED` | 400 | No `ai.models` are configured. | Add a model in [AI setup](/docs/dev/ai). | +| `AI_INVALID_INPUT` | 400 | The input failed `inputSchema`, or an object action was streamed. | Send the shape the action's `inputSchema` expects. | +| `AI_INPUT_TOO_LARGE` | 413 | The input exceeds `maxInputCharacters` or `maxImages`. | Send less, or raise the limit on the action. | +| `AI_DISABLED` | 503 | An admin switched AI off for the whole site. | Switch it on in the AI **Settings** sheet. | +| `AI_ACTION_DISABLED` | 403 | An admin switched this action off. | Switch it on in **Artificial Intelligence (AI) → Actions**. | +| `AI_ACTION_UNKNOWN` | 404 | No installed plugin registers this key. | Check the canonical key and the plugin's `aiActions`. | +| `AI_UNAUTHORIZED` | 403 | Not signed in, no AI permission, `authorize()` refused, or wrong actor. | Sign in, grant the permission on the role, or check `authorize()`. | +| `AI_MODEL_INCOMPATIBLE` | 409 | No model has the required capabilities. | Declare the capability on a model, e.g. `streaming` for streams. | +| `AI_PRICING_MISSING` | 409 | The model has no pricing while a budget cap applies. | Add `pricing` to the model. | +| `AI_USER_LIMIT_REACHED` | 429 | The user's monthly AI points are used up. | Wait for `resetsAt`, or give the role more points. | +| `AI_DAILY_LIMIT_REACHED` | 429 | The user reached today's limit for this permission. | Wait for `resetsAt`, or raise the daily limit. | +| `AI_RATE_LIMITED` | 429 | Too many runs started in the last minute. | Try again in a minute. | +| `AI_CONCURRENCY_LIMITED` | 429 | Too many runs in flight. | Wait for a running request to finish. | +| `AI_BUDGET_EXHAUSTED` | 503 | The site budget (or the system budget) for this month is used up. | Wait for `resetsAt`, or raise the budget. | +| `AI_DUPLICATE_REQUEST` | 409 | The `idempotencyKey` was already used. | Send a fresh key for a new run. | +| `AI_PROVIDER_FAILED` | 502 | The provider returned an error. | Try again; check the provider key and status. | +| `AI_INVALID_OUTPUT` | 502 | The answer was empty or failed `parseText()` / `outputSchema`. | Try again; loosen the schema or tighten the prompt if it repeats. | +| `AI_TIMEOUT` | 504 | The provider took longer than `timeoutMs`. | Try again, or raise `timeoutMs`. | +| `AI_CANCELED` | 408 | The `signal` aborted the run. | Nothing; the reader stopped it. | + +VitNode charges a user only when they received output, meaning a valid result or streamed text that reached them before they stopped it. When the provider fails, times out or answers unusably, the site budget pays whatever the provider billed. [Who pays what](/docs/dev/ai/costs-and-limits#who-pays-what) has the full table. + +## Limits that refuse a run + +Four of those codes come from limits admins set. Core checks them before it sends anything to a provider, so a refusal costs nothing: + +| Limit | Set in | Code | +| :------------- | :--------------------------------------------------------- | :----------------------- | +| Monthly points | Role form → **Artificial intelligence (AI)** tab | `AI_USER_LIMIT_REACHED` | +| Daily count | Role form, the action's settings or `defaults.dailyLimit` | `AI_DAILY_LIMIT_REACHED` | +| Rate and concurrency | AI **Settings** sheet (10 runs a minute and 2 at once per user by default) | `AI_RATE_LIMITED`, `AI_CONCURRENCY_LIMITED` | +| Site budget | AI **Settings** sheet → **Monthly site budget** | `AI_BUDGET_EXHAUSTED` | + +[AI in the AdminCP](/docs/dev/ai/admin#ai-access) shows where each one lives, and [Costs and budgets](/docs/dev/ai/costs-and-limits) explains how points and budgets are counted. + +## Show errors in the browser + +Core's browser helpers (`requestAiAssist()`, `streamAiAction()`) throw an `AiRequestError`. Read its code with `aiErrorCodeOf()`, which returns `"unknown"` for anything else, and translate it with Core's ready-made messages in `core.global.ai_assist.error`: + +```tsx +import { aiErrorCodeOf } from '@vitnode/core/lib/ai/assist-client' +import { useTranslations } from 'use-intl' + +const tError = useTranslations('core.global.ai_assist.error') + +try { + // requestAiAssist(...) or streamAiAction(...) +} catch (caught) { + setError(tError(aiErrorCodeOf(caught))) // "You reached today's limit for this AI feature." +} +``` + +`setError` is your component's own state setter. When you call your own route with `fetcher`, read `code` from the JSON body of a non-2xx response instead. + +## Handle errors on the server + +Import the helpers from `@vitnode/core/api/lib/ai/errors`: + +| Export | What it is | +| :---------------- | :--------------------------------------------- | +| `AiError` | The error class, with `code` and `body`. | +| `isAiError(error)` | Whether a caught value is an `AiError`. | +| `AI_ERROR_CODES` | Every code, as an array. | +| `AI_ERROR_STATUS` | The HTTP status of each code. | + +Catch only the codes you can do something about, and rethrow the rest so the client still gets the right status. + +## Run an action in a background job + +A background job runs an action as the **system actor** with `c.get("ai").runAsSystem()`. System runs spend the site budget and the optional background jobs budget, never a member's points. + +First allow the system to run the action. Keep `user` so the AdminCP route still works: + +```ts title="plugins/forum/src/api/ai/actions.ts (excerpt)" +const summarizeTopicAiAction = defineAiAction({ + id: 'topic.summarize', + actors: ['user', 'system'], // [!code ++] + // ...the rest stays the same +}) +``` + +Then call it from a cron job and stop when the budget runs out, instead of failing on every topic: + +```ts title="plugins/forum/src/api/cron/summarize-topics.cron.ts" +import { isAiError } from '@vitnode/core/api/lib/ai/errors' +import { buildCron } from '@vitnode/core/api/lib/cron' + +import { SUMMARIZE_TOPIC_AI_ACTION } from '@/api/ai/actions' + +export const summarizeTopicsCron = buildCron({ + name: 'summarize-new-topics', + description: 'Summarize new forum topics', + schedule: '*/15 * * * *', + handler: async (c) => { + for (const topic of await loadTopicsWithoutSummary(c)) { + try { + const { output } = await c.get('ai').runAsSystem({ + action: SUMMARIZE_TOPIC_AI_ACTION, + input: { content: topic.content, title: topic.title }, + resource: { type: 'forum_topic', id: topic.id }, + }) + await saveTopicSummary(c, topic.id, output) + } catch (error) { + // Out of budget: stop now and try again on the next tick. + if (isAiError(error) && error.code === 'AI_BUDGET_EXHAUSTED') return + throw error + } + } + }, +}) +``` + +`loadTopicsWithoutSummary()` and `saveTopicSummary()` stand for your plugin's own queries; the forum has no summary column, so add one where you keep summaries. `resource` links each run in the AI logs to its topic. + +Register the job in a top-level module's `cronJobs`, for example the forum's `admin` module: `buildModule({ name: 'admin', cronJobs: [summarizeTopicsCron], ... })`. See [Cron jobs](/docs/dev/cron). + +## React to runs with events + +Each run that got past the budget reservation emits one event after it settles: + +| Event | Payload | +| :----------------- | :------------------------------------------------------- | +| `ai.run.completed` | `actionKey`, `actorType`, `runId`, `userId` | +| `ai.run.failed` | `actionKey`, `actorType`, `errorCode`, `runId`, `userId` | + +`userId` is `null` for system runs. Refusals before the reservation, such as a missing permission or a reached limit, emit nothing. A listener that throws never breaks the run. + +```ts title="plugins/forum/src/api/lib/ai-events.ts" +import { buildEventListener } from '@vitnode/core/api/lib/events' + +export const aiFailureListener = buildEventListener({ + event: 'ai.run.failed', + name: 'log-ai-failures', + handler: async (c, payload) => { + if (!payload.actionKey.startsWith('@vitnode/forum:')) return + await c + .get('log') + .warn(`AI run ${payload.runId} failed: ${payload.errorCode}`) + }, +}) +``` + +Add it to a top-level module's `events` array, for example `buildModule({ name: 'admin', events: [aiFailureListener], ... })`. See [Events](/docs/dev/events) and [Built-in events](/docs/dev/events/built-in-events#ai-events). diff --git a/apps/web/content/docs/dev/ai/fields-and-editor.mdx b/apps/web/content/docs/dev/ai/fields-and-editor.mdx index 4dc8111f82..e35796c17e 100644 --- a/apps/web/content/docs/dev/ai/fields-and-editor.mdx +++ b/apps/web/content/docs/dev/ai/fields-and-editor.mdx @@ -1,30 +1,21 @@ --- -title: AI in Fields & the Editor -description: Add an AI suggestion button to a Content Engine text field with one option, use Quick Ask in the rich text editor, validate rich-text translations, track which translations are outdated, and offer an optional pre-publication review. +title: Field suggestions +description: Add an AI suggestion button to a Content Engine text or textarea field in the VitNode AdminCP. Point the field's ai option at a registered AI action, and editors review each suggestion before it is used. icon: TextCursorInput --- import { TypeTable } from 'fumadocs-ui/components/type-table' -Give a Content Engine `text` or `textarea` field an `ai` option and the AdminCP form shows an **AI** button next to it. The button runs a registered [AI action](/docs/dev/ai/actions) with the other fields as input, shows the suggestion for review, and writes nothing until the editor accepts it. +Give a Content Engine `text` or `textarea` field an `ai` option and the AdminCP form shows an **AI** button next to it. The button runs a registered [AI action](/docs/dev/ai/actions) with other fields of the item as input, shows the suggestion for review, and writes nothing until the editor accepts it. You write no route, no button and no form code. -```ts title="plugins/example/src/content/article.ts" -excerpt: field.textarea({ - ai: { - action: '@vitnode/example:article.excerpt', - mode: 'suggestion', - sourceFields: ['title', 'code'], - }, - maxLength: 500, - nullable: true, -}), -``` +On this page the `newsroom` plugin suggests a story teaser (`excerpt`) from the story's title and reference code. It is the real code from the `newsroom` plugin. -You write no route, no button and no form code. Core provides all of it. +## Before you begin -## Add AI to a field in your plugin +- **A language model with the `text` capability** in `ai.models`. See [AI setup](/docs/dev/ai). +- **A Content Engine content type with a `text` or `textarea` field** to fill, and the fields it should be written from. The example uses `newsroom.story`; see [Create a content type](/docs/content-engine/create-a-content-type) to make one. -The `@vitnode/example` plugin suggests an article teaser from its title and product code. Here is the whole feature. +## Add AI to a field @@ -32,27 +23,43 @@ The `@vitnode/example` plugin suggests an article teaser from its title and prod ### Define the action -The action's input is **one string per source field, plus `locale`** for the language being edited. Match `inputSchema` to that shape. +The field sends **one string per source field, plus `locale`** for the language being edited. Match `inputSchema` to that shape: -```ts title="plugins/example/src/api/ai/actions.ts" +```ts title="plugins/newsroom/src/api/ai/actions.ts" import { defineAiAction } from '@vitnode/core/api/lib/ai/action' +import { languageName } from '@vitnode/core/api/lib/ai/language-name' import { checkStaffPermission } from '@vitnode/core/api/lib/check-staff-permission' import { z } from 'zod' -import { articleContentType } from '@/content/article' +import { storyContentType } from '@/content/story' import { manifest } from '@/manifest.gen' -export const articleExcerptAiAction = defineAiAction({ - id: 'article.excerpt', - description: 'Suggests a teaser for an example article.', - promptVersion: 1, - permission: { key: 'article.excerpt', defaultGranted: true }, - requiredCapabilities: ['text'], +export const storyExcerptAiAction = defineAiAction({ + authorize: async ({ c }) => + await checkStaffPermission(c, { + module: storyContentType.permissionModule, + permission: 'can_edit', + plugin: manifest.id, + type: 'admin', + }), + buildPrompt: (input, { instructions }) => ({ + prompt: `Title: ${input.title}\nReference code: ${input.code}`, + system: [ + 'You write a one-sentence teaser for a newsroom story.', + `Write it in ${languageName(input.locale)}, under 200 characters.`, + 'Answer with the teaser only, without quotes.', + ...(instructions ? [instructions] : []), + ].join('\n'), + }), defaults: { maxInputCharacters: 400, maxOutputTokens: 120, timeoutMs: 20_000, }, + description: 'ai_actions.newsroom.story_excerpt.description', + icon: 'sparkles', + id: 'story.excerpt', + title: 'ai_actions.newsroom.story_excerpt.title', // [!code ++:5] inputSchema: z.object({ code: z.string().max(100), @@ -62,40 +69,49 @@ export const articleExcerptAiAction = defineAiAction({ output: 'text', outputSchema: z.string().min(1).max(500), parseText: (text) => text.trim().replace(/^["“](.*)["”]$/su, '$1'), - buildPrompt: (input, { instructions }) => ({ - prompt: `Title: ${input.title}\nProduct code: ${input.code}`, - system: [ - 'You write a one-sentence teaser for an article in a demo catalogue.', - `Write it in ${input.locale}, under 200 characters.`, - 'Answer with the teaser only, without quotes.', - ...(instructions ? [instructions] : []), - ].join('\n'), - }), - // The AI permission never replaces access to the content. - authorize: async ({ c }) => - await checkStaffPermission(c, { - module: articleContentType.permissionModule, - permission: 'can_edit', - plugin: manifest.id, - type: 'admin', - }), + permission: { defaultGranted: true, key: 'story.excerpt' }, + promptVersion: 1, + requiredCapabilities: ['text'], }) ``` -The real file turns the locale code into a language name with `Intl.DisplayNames`, because models write better for "Polish" than for "pl". +`authorize` asks for the story's own `can_edit`, because the AI permission never replaces access to the content. `languageName` turns the locale code into a language name, because models write better for "Polish" than for "pl". + + + + + +### Add the title and description + +`title` and `description` are message keys. Add their text under `ai_actions` and the plugin id, next to the plugin's existing messages: + +```json title="plugins/newsroom/src/locales/en.json (excerpt)" +{ + "ai_actions": { + "newsroom": { + "story_excerpt": { + "title": "Suggest story teaser", + "description": "Suggests a teaser for a newsroom story." + } + } + } +} +``` -### Register it +### Register the action -```ts title="plugins/example/src/config.api.ts" -export const exampleApiPlugin = () => +```ts title="plugins/newsroom/src/config.api.ts (excerpt)" +import { storyExcerptAiAction } from '@/api/ai/actions' // [!code ++] + +export const newsroomApiPlugin = () => buildApiPlugin({ manifest, - aiActions: [articleExcerptAiAction], // [!code ++] - modules: [adminModule /* ... */], + aiActions: [storyExcerptAiAction], // [!code ++] + // ...blocks, modules and the rest stay unchanged }) ``` @@ -103,15 +119,15 @@ export const exampleApiPlugin = () => -### Point the field at it +### Point the field at the action -Use the canonical key, `:`: +Use the canonical key, `:`. The newsroom plugin's id is `newsroom`: -```ts title="plugins/example/src/content/article.ts" +```ts title="plugins/newsroom/src/content/story.ts (excerpt)" excerpt: field.textarea({ // [!code ++:5] ai: { - action: '@vitnode/example:article.excerpt', + action: 'newsroom:story.excerpt', mode: 'suggestion', sourceFields: ['title', 'code'], }, @@ -126,13 +142,30 @@ excerpt: field.textarea({ ### Give editors access -The editor's role needs the action's permission and an AI points allowance. See [AI access](/docs/dev/ai/admin#ai-access). With `defaultGranted: true` the permission is on until a role says otherwise, but the default monthly points are `0`, so non-root editors still need an allowance. +A root role can use the button right away. Any other editor's role needs the action's permission and an AI points allowance. Open **Users → Roles**, edit the role and use its **Artificial intelligence (AI)** tab. With `defaultGranted: true` the permission is on until a role says otherwise, but the default monthly points are `0`. See [AI access](/docs/dev/ai/admin#ai-access). -### The `ai` field option +## Check the result + +Restart the dev server, open the newsroom stories in the AdminCP and create or edit a story. Fill in the title and the code, then click the **AI** button next to the excerpt field. + +1. **Generate** sends the source fields in the language being edited. While a source field is empty it stays disabled and names the field: _"Fill in … first."_ +2. The suggestion appears in a review panel with **Discard**, **Try again** and **Use suggestion**. +3. **Use suggestion** puts the text into the field, in the edited language only. Nothing is saved until you save the form, which still goes through validation and revisions. + +{/* Image prompt: The newsroom story form in the AdminCP with the AI suggestion panel open next to the Excerpt field, showing a one-sentence teaser and the Discard, Try again and Use suggestion buttons. */} + +The panel warns before stale text replaces anything: + +- If **the sources changed** since the suggestion was made, it says _"The text it was written from changed since. Try again for an up-to-date suggestion."_ +- If **the editor typed into the field** after asking, the accept button becomes **Replace my newer text**. + +The **AI** button appears only when this admin may start the action. That means AI is on, the action is enabled, a configured model can run it, and the admin's roles grant its permission. Each run shows up in **Debug Panel → AI logs**. Core records an accept or discard on the run for statistics only, and it never changes what was charged. Each [error code](/docs/dev/ai/errors#error-codes) has its own message, such as _"The AI provider failed. Nothing was charged to you."_ + +## The ai field option - Both actions share the permission `@vitnode/core:editor.assist` with - `defaultGranted: false`. Nobody but root sees Quick Ask until an admin allows - it for a role in **Artificial Intelligence (AI) → Access & limits** and gives - that role points. - - -How it behaves: - -- **Bounded context.** It sends the selection (up to 8,000 characters) and up to 1,500 characters before and after it. It never sends the whole document. -- **Streamed and previewed.** The answer streams into a preview. **Stop** cancels it; stopping before any text arrives charges the member nothing, while stopping after text has streamed in counts as a run. -- **Cost shown first.** With a selection, the panel shows the reservation bound, not a price: _"One request costs at most N AI points - usually much less."_ -- **Plain text only.** The answer becomes paragraphs and hard breaks. It is never parsed as HTML, so it cannot inject markup, links or scripts. -- **Stale selection.** If the selected text changed while the answer was written, **Replace selection** is disabled; **Insert below** still works. -- **One-step undo.** Applying is one editor transaction. The toast's **Undo** or Ctrl+Z restores the text exactly. -- **Nothing is saved** until the form is saved. -- **Content language.** It writes in the language of the field being edited (the multi-language tab), not the interface language. -- **Streaming models only.** Streamed runs use only models that declare the `streaming` capability; without one, Quick Ask fails with `AI_MODEL_INCOMPATIBLE`. - -Quick Ask uses the AdminCP routes on `/admin` pages and the member routes everywhere else. - -### Streaming and estimate routes - -| Route | Session | -| :------------------------------- | :---------- | -| `POST /admin/ai/assist/stream` | AdminCP | -| `POST /admin/ai/assist/estimate` | AdminCP | -| `POST /ai/stream` | Site member | -| `POST /ai/estimate` | Site member | - -The stream routes accept the same body as `/ai/run` and answer `application/x-ndjson`, one JSON object per line: - -```text -{"t":"A tighter "} -{"t":"version of the paragraph."} -{"done":{"runId":812,"chargedPoints":"0.42","costKnown":true,"modelId":"default"}} -``` - -A failure after the stream started arrives as `{"e":{"code":"AI_TIMEOUT","message":"…"}}`. Limits and budgets are checked before the first byte, so refusals are ordinary JSON error responses. Only `text` actions stream. - -`/estimate` answers `{ maxPoints, maxUsd }`: the most the run could cost, or `null` when no pricing bounds it. - -Use the browser helpers from `@vitnode/core/lib/ai/stream-client` in your own editor tools: - -```ts -import { - aiAssistScope, - streamAiAction, -} from '@vitnode/core/lib/ai/stream-client' - -const controller = new AbortController() -const summary = await streamAiAction({ - action: '@vitnode/core:editor.selection.rewrite', - input: { after, before, locale, operation: 'shorten', selection }, - onDelta: (delta) => setPreview((text) => text + delta), - scope: aiAssistScope(), // "admin" on /admin pages, otherwise "user" - signal: controller.signal, // controller.abort() stops paying -}) -``` - -`estimateAiAction({ action, input, scope })` resolves to the maximum points as a string, or `null`. The editor-side helpers `takeQuickAskSnapshot()`, `isQuickAskStale()` and `applyQuickAskResult()` live in `@vitnode/core/components/tiptap/quick-ask/quick-ask-editor`. - -## Rich-text translation keeps its structure - -Asking a model to "preserve the HTML" is not a guarantee. The blog's `field.translate` action checks it instead: - -```ts title="plugins/blog/src/api/ai/actions.ts" -import { assertSameHtmlStructure } from '@vitnode/core/api/lib/ai/html-structure' - -parseText: (text, input) => { - if (input.format === 'text') return unquote(text) - const translated = text.trim() - assertSameHtmlStructure(input.text, translated) // [!code highlight] - - return translated -}, -``` - -`assertSameHtmlStructure(source, translated)` compares every tag in order, with its attributes. Text and the translatable attributes `alt`, `title`, `aria-label` and `placeholder` may change; tags, links and other attributes may not. A difference throws `HtmlStructureError`, the run fails with `AI_INVALID_OUTPUT`, and the editor is not charged. - -`htmlStructureDifference()` returns the first difference as a string (or `null`) when you want to report it instead of throwing. - -## Translation freshness - -For each translated field, VitNode stores a fingerprint of **the source text it was translated from** in `core_ai_translation_sources`. Comparing that fingerprint with the current source shows exactly which fields are outdated. VitNode never uses timestamps for this. - -| Status | Meaning | -| :---------- | :------------------------------------------------------------------------ | -| `fresh` | Translated from the source as it is now. | -| `edited` | As above, and a person changed the translation since. Their changes stay. | -| `outdated` | The source changed after the translation was made. | -| `untracked` | No record of what it was translated from, so no claim either way. | -| `missing` | Nothing translated yet. | - -Use `useTranslationFreshness()` inside a Content Engine form: - -```tsx title="plugins/blog/src/views/admin/article/editor/article-editor.tsx" -import { - useContentForm, - useContentFormValues, - useTranslationFreshness, -} from '@vitnode/core/content/admin-form' - -const { defaultLocale } = useContentForm() -const values = useContentFormValues() - -const freshness = useTranslationFreshness({ - contentTypeId, - fields: ['title', 'content', 'excerpt'], - itemId, // undefined while creating - source: defaultLocale ?? 'en', - values, -}) - -freshness.freshnessOf('pl') // { title: 'fresh', content: 'outdated', excerpt: 'missing' } -freshness.outdatedFields('pl') // ['content'] -freshness.editedByPerson('pl', 'content') // true when a person changed the AI text - -// After placing an AI translation into the form: -freshness.rememberAiTranslation('pl', 'content') -// When a person confirms the translations still match the source: -freshness.markReviewed('pl', freshness.outdatedFields('pl')) -``` - -`rememberAiTranslation()` and `markReviewed()` record nothing immediately. They are written when the form is saved, through the form's `onSaved` hook, so a record always describes text that was actually kept. Your own form code can subscribe the same way: `useContentForm().onSaved?.(async ({ itemId }) => { … })` returns an unsubscribe function. - -The blog uses this to show which languages are outdated, to update only outdated fields that no person edited, and to let an editor mark a translation as reviewed. - -The records are read and written with `GET` and `PUT /admin/ai/translation-sources`. Both require `can_edit` on the content type's own permission module. - -## Pre-publication review - -The blog's `@vitnode/blog:article.review` action is an optional read-through in the article editor's side panel. It is an `output: "object"` action, so it needs a `structured-output` model. It returns: - -```ts -{ - summary: string, - suggestions: Array<{ - area: 'clarity' | 'completeness' | 'structure' | 'tone', - message: string, - priority: 'high' | 'low', - }>, // at most 8 -} -``` - -- It **never verifies facts**. The prompt forbids judging whether facts are true, because the model has nothing to check them against. -- It **never gates publishing**. The deterministic readiness checks decide; publishing works the same with or without a review. - -It is a good template for structured, advisory AI in your own plugin. - -## Learn more +## Related diff --git a/apps/web/content/docs/dev/ai/index.mdx b/apps/web/content/docs/dev/ai/index.mdx index 040f760a8e..bdbe3d5205 100644 --- a/apps/web/content/docs/dev/ai/index.mdx +++ b/apps/web/content/docs/dev/ai/index.mdx @@ -1,18 +1,41 @@ --- -title: AI Setup +title: AI setup description: Configure Vercel AI SDK models in the VitNode API config, declare what each model can do and what it costs, then use them with c.get("ai"). icon: Sparkles --- +import { Tab, Tabs } from 'fumadocs-ui/components/tabs' import { TypeTable } from 'fumadocs-ui/components/type-table' VitNode integrates the [Vercel AI SDK](https://ai-sdk.dev). Register models in `vitnode.api.config.ts` once, then use them inside any API handler via `c.get("ai")`. ## Quick start -### 1. Configure models in the API config +### 1. Pick a provider -A model can be an AI Gateway id string or a direct provider instance: +A model is either a **provider instance** from an AI SDK provider package or an **AI Gateway id** string such as `'openai/gpt-4o-mini'`. + +- **Provider package.** Install it in your API app and set its key, for example `OPENAI_API_KEY` for `@ai-sdk/openai`. + + + +```bash tab="bun" +bun i @ai-sdk/openai +``` + +```bash tab="pnpm" +pnpm i @ai-sdk/openai +``` + +```bash tab="npm" +npm i @ai-sdk/openai +``` + + + +- **AI Gateway.** Install nothing. The AI SDK sends id strings through the [Vercel AI Gateway](https://vercel.com/docs/ai-gateway), which reads `AI_GATEWAY_API_KEY` from your `.env` file. + +### 2. Configure models in the API config ```ts title="apps/api/src/vitnode.api.config.ts" import { openai } from '@ai-sdk/openai' @@ -25,7 +48,7 @@ export const vitNodeApiConfig = buildApiConfig({ { id: 'default', name: 'GPT-4o Mini', - model: openai('gpt-4o-mini'), + model: openai('gpt-4o-mini'), // or the gateway id 'openai/gpt-4o-mini' capabilities: ['text', 'structured-output', 'streaming'], pricing: { rates: { inputPerMillion: '0.15', outputPerMillion: '0.60' }, @@ -36,29 +59,25 @@ export const vitNodeApiConfig = buildApiConfig({ }) ``` -Set `OPENAI_API_KEY` in your `.env` file. - -`capabilities` and `pricing` are optional, but [AI actions](/docs/dev/ai/actions) rely on them: capabilities decide which model can run an action, and pricing turns token usage into money. See [Model capabilities](#model-capabilities) and [Costs, Points & Budgets](/docs/dev/ai/costs-and-limits). - ---- +`capabilities` and `pricing` are optional, but [AI actions](/docs/dev/ai/actions) rely on them. Capabilities decide which model can run an action, and pricing turns token usage into money. See [Model capabilities](#model-capabilities) and [Costs and budgets](/docs/dev/ai/costs-and-limits). -### 2. Use it in a route handler +### 3. Use it in a route handler -For user-facing features, run a registered [AI action](/docs/dev/ai/actions). It checks permissions, limits and budgets, and records the cost: +For features people can trigger, run a registered [AI action](/docs/dev/ai/actions). It checks permissions, limits and budgets, and records the cost: ```ts handler: async (c) => { // [!code ++:4] const { output } = await c.get('ai').run({ - action: EXCERPT_AI_ACTION, + action: SUMMARIZE_TOPIC_AI_ACTION, input: c.req.valid('json'), }) - return c.json({ text: output }) + return c.json({ text: output }, 200) } ``` -For internal tooling, you can also resolve the raw model and call the SDK directly. See [AI Usage](/docs/dev/ai/usage). +`SUMMARIZE_TOPIC_AI_ACTION` is the action key you create in [AI actions](/docs/dev/ai/actions). For internal tooling, you can also resolve the raw model and call the SDK directly. See [Direct SDK calls](/docs/dev/ai/usage). --- @@ -109,7 +128,7 @@ Every [AI action](/docs/dev/ai/actions) declares the capabilities it needs. When Prices live only in this config. The AdminCP has no price editor, so a price change is a code change you deploy. A malformed price stops the server at boot. -Without pricing, a call's cost stays **unknown**, never zero. Under a budget cap, a model with no pricing is refused with `AI_PRICING_MISSING`. Tiers, flat fees and gateway vs direct pricing are covered in [Costs, Points & Budgets](/docs/dev/ai/costs-and-limits#model-pricing). +Without pricing, a call's cost stays **unknown**, never zero. Under a budget cap, a run on a model with no pricing fails with `AI_PRICING_MISSING`. [Costs and budgets](/docs/dev/ai/costs-and-limits#model-pricing) covers tiers, flat fees and gateway vs direct pricing. ### Model options @@ -186,7 +205,7 @@ const embedModel = c.get('ai').embeddingModel() const capabilities = c.get('ai').capabilities('reasoning') // ['text', 'image-input', ...] ``` -`model()`, `embeddingModel()`, `imageModel()` and `models()` work as before. Admins can assign a different model (and a fallback) to each action in the AdminCP; an assigned model that lacks a required capability is refused. +`embeddingModel()` throws when no `embeddingModels` are configured, and `imageModel()` when no `imageModels` are. Admins can assign a different model (and a fallback) to each action in the AdminCP. Core refuses an assigned model that lacks a required capability. --- @@ -234,38 +253,33 @@ a picker can hide itself instead of offering nothing. - diff --git a/apps/web/content/docs/dev/ai/meta.json b/apps/web/content/docs/dev/ai/meta.json index 264d9cc643..18daa6038d 100644 --- a/apps/web/content/docs/dev/ai/meta.json +++ b/apps/web/content/docs/dev/ai/meta.json @@ -4,11 +4,20 @@ "icon": "Sparkles", "pages": [ "index", + "---Actions---", "actions", + "streaming", + "errors", + "action-options", + "---Features---", + "fields-and-editor", + "custom-ui", + "quick-ask", + "translations", + "automatic-alt", + "---Operate---", "costs-and-limits", "admin", - "automatic-alt", - "fields-and-editor", "usage" ] } diff --git a/apps/web/content/docs/dev/ai/quick-ask.mdx b/apps/web/content/docs/dev/ai/quick-ask.mdx new file mode 100644 index 0000000000..63bf3ebe9d --- /dev/null +++ b/apps/web/content/docs/dev/ai/quick-ask.mdx @@ -0,0 +1,85 @@ +--- +title: Quick Ask +description: Turn on Quick Ask, the AI button in the VitNode rich text editor that shortens, corrects, simplifies, changes the tone of or continues the selected text, and learn how it limits context, cost and markup. +icon: WandSparkles +--- + +Quick Ask is the **Quick Ask (AI)** button in the toolbar of VitNode's rich text editor. It rewrites the selected text or writes more of it, streams the answer into a preview, and changes nothing until the editor applies it. It is built into Core and switched off for everyone except root roles until an admin allows it. + +## Before you begin + +- **A language model with the `text` and `streaming` capabilities** in `ai.models`. Quick Ask always streams, so without a `streaming` model it fails with `AI_MODEL_INCOMPATIBLE`. See [AI setup](/docs/dev/ai#model-capabilities). +- **A model with `pricing`** if the roles you allow have a points allowance. See [Model pricing](/docs/dev/ai#model-pricing). +- **An admin with `ai:can_manage`** to change a role's AI access. + +## Turn on Quick Ask for a role + + + + + +### Allow the editor.assist permission + +Open **Users → Roles**, edit the role and switch to the **Artificial intelligence (AI)** tab. Find the row with **Rewrite selected text** and **Quick Ask** and set it to _Allow_. Both actions share the permission `@vitnode/core:editor.assist`, which has `defaultGranted: false`. + + + + + +### Give the role AI points + +On the same tab, give the role a monthly points allowance or **Unlimited points**. The default allowance is `0`, so a member of an allowed role without points gets `AI_USER_LIMIT_REACHED`. Save the role. + + + + + +## Check the result + +Sign in with a member of that role and open any form with a rich text field, such as an article in the AdminCP. The toolbar shows **Quick Ask (AI)**. Select a sentence, open Quick Ask and choose **Shorten**. The shorter text streams into a preview with **Replace selection** and **Insert below**. The run appears in **Debug Panel → AI logs** as `@vitnode/core:editor.selection.rewrite`. + +## What it can do + +| Operation | Action key | Does | +| :--------------- | :--------------------------------------- | :--------------------------------------------------------- | +| Shorten | `@vitnode/core:editor.selection.rewrite` | Tighter text, same meaning. | +| Correct writing | `@vitnode/core:editor.selection.rewrite` | Spelling, grammar and punctuation only. | +| Simplify | `@vitnode/core:editor.selection.rewrite` | Simpler words, shorter sentences. | +| Change the tone | `@vitnode/core:editor.selection.rewrite` | Friendly, formal, confident or neutral. | +| Continue writing | `@vitnode/core:editor.quick-ask` | One or two paragraphs from where the text ends. | +| Ask anything | `@vitnode/core:editor.quick-ask` | A custom request about the selection (max 500 characters). | + +An admin can switch either action off, or give it its own model, on **Artificial Intelligence (AI) → Actions**. + +## How it behaves + +- **Bounded context.** It sends the selection (up to 8,000 characters) and up to 1,500 characters before and after it, never the whole document. +- **Cost shown first.** With a selection, the panel shows the reservation bound, not a price: _"One request costs at most N AI points - usually much less."_ +- **Stop is cheap.** **Stop** cancels the stream. Stopping before any text arrives charges nothing; stopping after text has streamed in counts as a run. +- **Plain text only.** The answer becomes paragraphs and hard breaks. The editor never parses it as HTML, so it cannot inject markup, links or scripts. +- **Stale selection.** If the selected text changed while the answer was written, **Replace selection** is disabled and **Insert below** still works. +- **One-step undo.** Applying is one editor transaction. The toast's **Undo** or Ctrl+Z restores the text exactly. +- **Nothing is saved** until you save the form. +- **Content language.** It writes in the language of the field being edited (the multi-language tab), not the interface language. + +Quick Ask calls Core's AdminCP stream routes on `/admin` pages and the member routes everywhere else. See [Streaming](/docs/dev/ai/streaming#built-in-stream-routes). + +## Build your own editor tool + +Your own Tiptap tools can reuse the same pieces: + +- `streamAiAction()` and `estimateAiAction()` from `@vitnode/core/lib/ai/stream-client` run and price a text action. See [Streaming](/docs/dev/ai/streaming). +- From `@vitnode/core/components/tiptap/quick-ask/quick-ask-editor`: `takeQuickAskSnapshot(editor)` captures the selection with its context (`{ before, selection, after, from, to }`), `isQuickAskStale(editor, snapshot)` tells whether the selection changed since, and `applyQuickAskResult(editor, { from, to, mode, text })` inserts the answer as one undoable transaction, with `mode` `"replace"` or `"below"`. + +```ts +import { streamAiAction } from '@vitnode/core/lib/ai/stream-client' + +await streamAiAction({ + action: '@vitnode/core:editor.selection.rewrite', + input: { after, before, locale, operation: 'shorten', selection }, + onDelta: (delta) => setPreview((text) => text + delta), + scope: 'admin', +}) +``` + +`before`, `selection` and `after` come from `takeQuickAskSnapshot(editor)`, `locale` is the content language being edited, and `setPreview` is your component's state setter. diff --git a/apps/web/content/docs/dev/ai/streaming.mdx b/apps/web/content/docs/dev/ai/streaming.mdx new file mode 100644 index 0000000000..1036cd73a7 --- /dev/null +++ b/apps/web/content/docs/dev/ai/streaming.mdx @@ -0,0 +1,183 @@ +--- +title: Streaming +description: Stream a VitNode AI action's text to the browser as the model writes it, with streamAiAction() and the built-in stream routes or c.get("ai").stream() in your own route, and learn what a stopped stream costs. +icon: Radio +--- + +A streamed AI action shows its text while the model is still writing, so a long answer feels quick and the reader can stop it early. This page streams the **Summarize topic** action from [AI actions](/docs/dev/ai/actions) into an AdminCP component, without writing a new API route. + +Only `output: "text"` actions stream. An object action fails with `AI_INVALID_INPUT` when streamed. + +## Before you begin + +- **The Summarize topic action** registered in the forum plugin, from [AI actions](/docs/dev/ai/actions). You don't need the route you built there. +- **A model with the `streaming` capability.** A streamed run adds `streaming` to the action's `requiredCapabilities`, so only models that declare it can answer. See [Model capabilities](/docs/dev/ai#model-capabilities). + +## Stream an action into a component + + + + + +### Declare a streaming model + +Make sure at least one model in your API config lists `streaming`: + +```ts title="apps/api/src/vitnode.api.config.ts (excerpt)" +ai: { + models: [ + { + id: 'default', + name: 'GPT-4o Mini', + model: openai('gpt-4o-mini'), + capabilities: ['text', 'structured-output', 'streaming'], // [!code highlight] + }, + ], +}, +``` + +Without one, a streamed run fails with `AI_MODEL_INCOMPATIBLE` before Core sends anything to a provider. + + + + + +### Call streamAiAction() from the component + +`streamAiAction()` posts to Core's generic stream route, which runs any registered user action, and calls `onDelta` with each piece of text. Add a component like this to an AdminCP page of your plugin: + +```tsx title="plugins/forum/src/views/admin/summarize-topic.tsx" +import { Button } from '@vitnode/core/components/ui/button' +import { aiErrorCodeOf } from '@vitnode/core/lib/ai/assist-client' +import { + aiAssistScope, + streamAiAction, +} from '@vitnode/core/lib/ai/stream-client' +import React from 'react' + +export const SummarizeTopic = ({ + content, + title, +}: { + content: string + title: string +}) => { + const [summary, setSummary] = React.useState('') + const [error, setError] = React.useState(null) + const [pending, setPending] = React.useState(false) + const controller = React.useRef(null) + + const onSummarize = async () => { + controller.current = new AbortController() + setSummary('') + setError(null) + setPending(true) + try { + await streamAiAction({ + action: '@vitnode/forum:topic.summarize', + input: { content, title }, + onDelta: (delta) => setSummary((text) => text + delta), + scope: aiAssistScope(), // "admin" on /admin pages, otherwise "user" + signal: controller.current.signal, + }) + } catch (caught) { + setError(aiErrorCodeOf(caught)) // e.g. "AI_USER_LIMIT_REACHED" + } finally { + setPending(false) + } + } + + return ( +
        +
        + + +
        +

        + {summary} +

        + {error &&

        {error}

        } +
        + ) +} +``` + +`streamAiAction()` resolves to `{ runId, chargedPoints, costKnown }` once the stream ends. It throws an `AiRequestError` for a refusal before the stream starts and for a failure inside it; `aiErrorCodeOf()` reads its code. To show a translated message instead of the code, translate it with the `core.global.ai_assist.error` messages, as the blog's review panel does. + +
        + +
        + +## Check the result + +Open the AdminCP page with the component and click **Summarize**. The summary appears piece by piece under the buttons. **Debug Panel → AI logs** shows a `@vitnode/forum:topic.summarize` run with status `succeeded`. + +Click **Summarize** again and then **Stop** while text is arriving. The text stops, and the AI logs show the run as `canceled`. + +## Stream from your own route + +When the stream needs server-side work first, such as loading the topic from the database, call `c.get("ai").stream()` in your own route. It takes the same options as `run()`: + +```ts title="plugins/forum/src/api/modules/admin/ai/routes/summarize-stream.route.ts (handler excerpt)" +handler: async (c) => { + const { textStream } = await c.get('ai').stream({ + action: SUMMARIZE_TOPIC_AI_ACTION, + input: c.req.valid('json'), + signal: c.req.raw.signal, // stop paying when the reader leaves + }) + + return new Response(textStream.pipeThrough(new TextEncoderStream()), { + headers: { 'Content-Type': 'text/plain; charset=utf-8' }, + }) +} +``` + +`stream()` resolves to `{ textStream, result, runId }`: + +- `textStream` is a `ReadableStream` of text deltas. +- `result` resolves to `{ output, runId, usage }` once the stream ends, the same shape `run()` returns, and rejects with an `AiError` if the full text fails `parseText()` or `outputSchema`. + +Core checks permissions, limits and budgets before `stream()` resolves, so a refusal is an ordinary JSON error response. To answer in the same NDJSON format as Core's routes, return `aiNdjsonStream(c, request)` with `AI_NDJSON_HEADERS`, both from `@vitnode/core/api/lib/ai/stream-response`. + +## Built-in stream routes + +`streamAiAction()` and `estimateAiAction()` call these Core routes. Their body is `{ action, input, idempotencyKey?, resource?, sourceFingerprint? }`, the same as the [run routes](/docs/dev/ai/custom-ui#built-in-run-routes). + +| Route | Session | Answers | +| :------------------------------- | :---------- | :-------------------------- | +| `POST /admin/ai/assist/stream` | AdminCP | NDJSON text, then a summary | +| `POST /admin/ai/assist/estimate` | AdminCP | `{ maxPoints, maxUsd }` | +| `POST /ai/stream` | Site member | NDJSON text, then a summary | +| `POST /ai/estimate` | Site member | `{ maxPoints, maxUsd }` | + +The stream routes answer `application/x-ndjson`, one JSON object per line: + +```text +{"t":"A tighter "} +{"t":"version of the paragraph."} +{"done":{"runId":812,"chargedPoints":"0.42","costKnown":true,"modelId":"default"}} +``` + +A failure after the stream started arrives as a last line `{"e":{"code":"AI_TIMEOUT","message":"…"}}`. + +`/estimate` answers the most the run could cost, or `null` when no pricing bounds it. In the browser, `estimateAiAction({ action, input, scope })` resolves to `maxPoints` as a string, or `null`. + +## What a stopped stream costs + +A stream makes one attempt on the primary model, with no retries and no fallback model. When the reader stops it, the run settles as `canceled`: + +- If no text had reached the reader yet, they pay nothing. +- If some text had arrived, Core charges their points and daily count as for a delivered run. + +## Related + +- [Quick Ask](/docs/dev/ai/quick-ask) is the rich text editor feature built on these routes. +- [AI errors and limits](/docs/dev/ai/errors) lists every error code a stream can end with. diff --git a/apps/web/content/docs/dev/ai/translations.mdx b/apps/web/content/docs/dev/ai/translations.mdx new file mode 100644 index 0000000000..d5d61d6264 --- /dev/null +++ b/apps/web/content/docs/dev/ai/translations.mdx @@ -0,0 +1,98 @@ +--- +title: Translations +description: Make AI translations safe in VitNode. Reject rich-text translations that change the HTML structure with assertSameHtmlStructure(), and track which translated fields are outdated with useTranslationFreshness(). +icon: Languages +--- + +Two helpers make AI translation of Content Engine fields trustworthy. `assertSameHtmlStructure()` rejects a rich-text translation that changed the markup, and `useTranslationFreshness()` shows which translated fields no longer match their source. The `@vitnode/blog` plugin uses both in its article editor; the examples below are its code. + +## Before you begin + +- **A text AI action that translates**, registered in your plugin. See [AI actions](/docs/dev/ai/actions). The blog's is `@vitnode/blog:field.translate` in `plugins/blog/src/api/ai/actions.ts`. +- **A Content Engine content type with localized fields** and a custom AdminCP form built with `@vitnode/core/content/admin-form`, for freshness tracking. + +## Keep the rich-text structure + +Asking a model to "preserve the HTML" is not a guarantee. Check it in the action's `parseText()` instead. The blog's translate action accepts plain text or a rich-text document, and compares the HTML it sent with the HTML it got back: + +```ts title="plugins/blog/src/api/ai/actions.ts (excerpt)" +import { assertSameHtmlStructure } from '@vitnode/core/api/lib/ai/html-structure' +import { richTextToHtml } from '@vitnode/core/content/rich-text' + +const translateSource = (input: TranslateAiInput) => + input.format === 'richText' + ? { format: 'html' as const, text: richTextToHtml(input.document) } + : { format: 'text' as const, text: input.text } + +// inside defineAiAction({ ... }) +parseText: (text, input) => { + if (input.format === 'text') return unquote(text) + const translated = text.trim() + assertSameHtmlStructure(translateSource(input).text, translated) // [!code highlight] + + return translated +}, +``` + +`TranslateAiInput` is the type of the action's `inputSchema`, and `unquote()` is the blog's helper that strips surrounding quotes from a plain-text answer. + +`assertSameHtmlStructure(source, translated)` compares every tag in order, with its attributes. Text and the translatable attributes `alt`, `title`, `aria-label` and `placeholder` may change; tags, links and other attributes may not. A difference throws `HtmlStructureError`, so the run fails with `AI_INVALID_OUTPUT` and the editor is not charged. + +`htmlStructureDifference(source, translated)` returns the first difference as a string, or `null`, when you want to report it instead of throwing. + +## Track outdated translations + +For each translated field, VitNode stores a fingerprint of **the source text it was translated from** in `core_ai_translation_sources`. Comparing that fingerprint with the current source shows exactly which fields are outdated, without relying on timestamps. + +| Status | Meaning | +| :---------- | :------------------------------------------------------------------------ | +| `fresh` | Translated from the source as it is now. | +| `edited` | As above, and a person changed the translation since. Their changes stay. | +| `outdated` | The source changed after the translation was made. | +| `untracked` | No record of what it was translated from, so no claim either way. | +| `missing` | Nothing translated yet. | + +Call `useTranslationFreshness()` inside a Content Engine form: + +```tsx title="plugins/blog/src/views/admin/article/editor/article-editor.tsx (excerpt)" +import { + useContentForm, + useContentFormValues, + useTranslationFreshness, +} from '@vitnode/core/content/admin-form' + +const { defaultLocale } = useContentForm() +const values = useContentFormValues() + +const freshness = useTranslationFreshness({ + contentTypeId, // e.g. 'blog.post' + fields: ['title', 'content', 'excerpt'], + itemId, // undefined while creating + source: defaultLocale ?? 'en', + values, +}) + +freshness.freshnessOf('pl') // { title: 'fresh', content: 'outdated', excerpt: 'missing' } +freshness.outdatedFields('pl') // ['content'] +freshness.editedByPerson('pl', 'content') // true when a person changed the AI text + +// After placing an AI translation into the form: +freshness.rememberAiTranslation('pl', 'content') +// When a person confirms the translations still match the source: +freshness.markReviewed('pl', freshness.outdatedFields('pl')) +``` + +`contentTypeId` and `itemId` are the content type and the item the form edits; the blog's editor receives them as props. + +`rememberAiTranslation()` and `markReviewed()` record nothing immediately. The form writes them when it saves, through its `onSaved` hook, so a record always describes text that was actually kept. Your own form code can subscribe the same way: `useContentForm().onSaved?.(async ({ itemId }) => { … })` returns an unsubscribe function. + +`GET` and `PUT /admin/ai/translation-sources` in `@vitnode/core` read and write the records. Both require `can_edit` on the content type's own permission module. + +## Check the result + +In the blog's article editor, translate a field into another language with AI and save. Then change the same field in the default language and save again. The editor lists that language as outdated, and `freshness.outdatedFields()` returns the field. + +## Related + +- [Custom UI](/docs/dev/ai/custom-ui) runs your translate action from your own form button. +- [Field suggestions](/docs/dev/ai/fields-and-editor) adds the no-code AI button to a single field. diff --git a/apps/web/content/docs/dev/ai/usage.mdx b/apps/web/content/docs/dev/ai/usage.mdx index ef37947dcd..624e7b885e 100644 --- a/apps/web/content/docs/dev/ai/usage.mdx +++ b/apps/web/content/docs/dev/ai/usage.mdx @@ -1,6 +1,6 @@ --- -title: AI Usage -description: Generate text, stream responses, get structured output and create embeddings with the Vercel AI SDK and the VitNode model registry. +title: Direct SDK calls +description: Call the Vercel AI SDK directly in VitNode API routes. Resolve a model with c.get("ai").model(), then generate text, stream a response, get structured output with Output.object or create embeddings. icon: Bot --- @@ -29,61 +29,71 @@ VitNode uses the [Vercel AI SDK](https://ai-sdk.dev). Resolve a model from `c.ge ## Model resolvers -| Resolver | Returned type | Used by | -| :-------------------------------- | :--------------- | :--------------------------------------------- | -| `c.get("ai").model(id?)` | `LanguageModel` | `generateText`, `streamText`, `generateObject` | -| `c.get("ai").embeddingModel(id?)` | `EmbeddingModel` | `embed`, `embedMany` | -| `c.get("ai").imageModel(id?)` | `ImageModel` | `generateImage` | +| Resolver | Returned type | Used by | +| :-------------------------------- | :--------------- | :--------------------------- | +| `c.get("ai").model(id?)` | `LanguageModel` | `generateText`, `streamText` | +| `c.get("ai").embeddingModel(id?)` | `EmbeddingModel` | `embed`, `embedMany` | +| `c.get("ai").imageModel(id?)` | `ImageModel` | `generateImage` | + +Without an `id`, each resolver returns the first model of its list in the [AI config](/docs/dev/ai). The examples below use the `ai` package that VitNode Core depends on (AI SDK 7). --- ## 1. Text generation -Generate text in a Hono API route: +Generate text in a staff-only API route. This one belongs to the `site-notes` plugin from the [learning path](/docs/dev/working-with-users/staff-permissions); add it to the plugin's admin `notes` module like any other route: -```ts title="plugins/blog/src/api/modules/posts/routes/summarize.route.ts" +```ts title="plugins/site-notes/src/api/modules/admin/notes/summarize.route.ts" import { generateText } from 'ai' import { z } from 'zod' import { buildRoute } from '@/api/builders' export const summarizeRoute = buildRoute({ + adminStaffPermission: { module: 'notes', permission: 'can_view' }, route: { method: 'post', + description: 'Summarize a text with the default AI model.', path: '/summarize', request: { body: { + required: true, content: { - 'application/json': { schema: z.object({ text: z.string().min(1) }) }, + 'application/json': { schema: z.object({ text: z.string().min(1).max(20_000) }) }, }, }, }, responses: { - 200: { description: 'Text summary' }, + 200: { + content: { + 'application/json': { schema: z.object({ summary: z.string() }) }, + }, + description: 'Text summary', + }, }, }, handler: async (c) => { const { text } = c.req.valid('json') - // [!code ++:6] + // [!code ++:5] const { text: summary } = await generateText({ model: c.get('ai').model(), system: 'You are a concise summary assistant.', - prompt: `Summarize the following content: - -${text}`, + prompt: `Summarize the following content:\n\n${text}`, }) - return c.json({ summary }) + return c.json({ summary }, 200) }, }) ``` +`adminStaffPermission` keeps the route to staff who hold `notes` → `can_view`. Swap in a permission your own plugin declares. + --- ## 2. Streaming responses -Stream the model's answer to the client as it arrives: +Stream the model's answer to the client as plain text: ```ts import { streamText } from 'ai' @@ -92,21 +102,24 @@ handler: async (c) => { const result = streamText({ model: c.get('ai').model(), prompt: 'Write an introduction to WebSockets.', + abortSignal: c.req.raw.signal, }) // [!code ++:1] - return result.toDataStreamResponse() + return result.toTextStreamResponse() } ``` +For a chat UI built on the AI SDK's `useChat`, return `result.toUIMessageStreamResponse()` instead. To stream a managed action with budgets and history, see [Streaming](/docs/dev/ai/streaming). + --- -## 3. Structured output (`generateObject`) +## 3. Structured output -Get typed JSON back from the model, shaped by a Zod schema: +Get typed JSON back from the model by passing an `output` setting to `generateText`. The model must support structured output: ```ts -import { generateObject } from 'ai' +import { generateText, Output } from 'ai' import { z } from 'zod' const postMetadataSchema = z.object({ @@ -115,19 +128,21 @@ const postMetadataSchema = z.object({ estimatedReadingMinutes: z.number(), }) -// [!code ++:6] -const { object } = await generateObject({ +// [!code ++:5] +const { output } = await generateText({ model: c.get('ai').model(), - schema: postMetadataSchema, + output: Output.object({ schema: postMetadataSchema }), prompt: 'Generate SEO metadata for an article on Postgres indexing.', }) ``` +`output` is typed from the schema. `generateObject` still exists in AI SDK 7 but is deprecated in favour of this form. + --- ## 4. Generating embeddings -Turn text into a vector embedding for semantic search: +Turn text into a vector embedding. This needs at least one entry in `ai.embeddingModels`, or `embeddingModel()` throws. See [Multiple models and embeddings](/docs/dev/ai#multiple-models--embeddings). ```ts import { embed } from 'ai' @@ -143,13 +158,13 @@ const { embedding } = await embed({ diff --git a/apps/web/content/docs/dev/architecture.mdx b/apps/web/content/docs/dev/architecture.mdx index df99a0d4ca..461c99a542 100644 --- a/apps/web/content/docs/dev/architecture.mdx +++ b/apps/web/content/docs/dev/architecture.mdx @@ -4,75 +4,71 @@ description: How VitNode combines TanStack Start for UI and isomorphic routing w icon: Blocks --- -VitNode separates concerns between two core layers: +A VitNode app is two layers in one project. TanStack Start renders the pages, and a Hono API answers every request for data. Read this page to know which layer owns a job before you write code for it. -1. **TanStack Start**: Frontend UI, SSR, isomorphic routing, and client caching. -2. **Hono API**: Backend routing, session management, staff permissions, and database operations. +1. **TanStack Start**: the UI, server-side rendering (SSR), routing and the client cache. +2. **Hono API**: API routes, sessions, staff permissions and database access. {/* Image prompt: VitNode architectural flow diagram displaying TanStack Start (SSR, route loaders, TanStack Query) communicating across an HTTP/RPC boundary with Hono API (middleware, sessions, Drizzle ORM) and PostgreSQL/Redis storage. Dark theme, 1600x900. */} -## System Boundaries +## Who owns what -| Responsibility | TanStack Start (Web App) | Hono (API) | -| :-------------------- | :-------------------------------------------- | :-------------------------------------------------------------- | -| **Routing** | Page URLs, dynamic parameters, nested layouts | REST/RPC endpoints under `/api/*` | -| **Data Fetching** | Route loaders and the universal `fetcher` | Query execution via Drizzle ORM | -| **State & Cache** | TanStack Query client cache | Redis domain cache & database storage | -| **Security Boundary** | UI guards (redirecting unauthenticated users) | **Enforces authentication, permissions, CSRF, and rate limits** | +| Responsibility | TanStack Start (web app) | Hono (API) | +| :---------------- | :-------------------------------------------- | :-------------------------------------------------------------- | +| **Routing** | Page URLs, dynamic parameters, nested layouts | API routes under `/api/*` | +| **Data fetching** | Route loaders and the universal `fetcher` | Queries through Drizzle ORM | +| **State & cache** | TanStack Query client cache | Redis domain cache and the database | +| **Security** | UI guards (redirecting signed-out visitors) | **Enforces authentication, permissions, CSRF and rate limits** | - - Route guards (`beforeLoad`) enhance UX by redirecting visitors early, but the - Hono API is the true security boundary. All private endpoints strictly verify - cookies and permissions on every request. + + Route guards (`beforeLoad`) improve the experience by redirecting visitors + early, but anyone can call the API directly. Every private endpoint checks the + session cookie and permissions on every request. ---- - -## End-to-End Request Flow - -When a user visits a page (e.g. `/blog`): +## Request flow -| Phase | Runtime | Action | -| :------------------------ | :--------------------- | :---------------------------------------------------------------- | -| **1. Request** | Browser | Visitor navigates to `/blog` | -| **2. Routing** | Server (SSR) / Browser | TanStack Router matches route and executes `loader` | -| **3. Query Warming** | Server / Browser | `context.queryClient.query` executes isomorphic fetcher | -| **4. RPC Call** | Server / Browser | The universal `fetcher` calls the Hono endpoint by plugin id | -| **5. API Middleware** | Server (Hono) | Verifies session cookie, applies rate limits, injects `c.get(db)` | -| **6. Handler & Database** | Server (Hono) | Handler validates input and queries PostgreSQL via Drizzle | -| **7. Response** | Server / Browser | JSON data hydrates TanStack Query cache and paints component | +When a visitor opens a page such as `/blog`: ---- - -## The Request Pipeline +| Step | Runs on | What happens | +| :--------------------- | :--------------------- | :------------------------------------------------------------------- | +| **1. Request** | Browser | The visitor navigates to `/blog` | +| **2. Routing** | Server (SSR) / browser | TanStack Router matches the route and runs its `loader` | +| **3. Query warming** | Server / browser | `context.queryClient.query` runs the query, which calls the fetcher | +| **4. API call** | Server / browser | The universal `fetcher` calls the Hono endpoint by plugin id | +| **5. API middleware** | Server (Hono) | Reads the session cookie, applies rate limits, provides `c.get("db")` | +| **6. Handler** | Server (Hono) | The route validates its input and queries PostgreSQL through Drizzle | +| **7. Response** | Server / browser | The JSON fills the TanStack Query cache and the component renders | -Before route matching, every request passes through the middleware -`createVitNodeStart` installs - in this order, and an app cannot get in front of -any of it: +## Request middleware -| Order | Middleware | Applies to | -| :---- | :----------------- | :------------------------------------------------------------------------------ | -| 1 | **CSRF** | Server function calls (`handlerType === 'serverFn'`) | -| 2 | **Locale** | Page requests: canonical `308` redirects and the locale cookie | -| 3 | **Document cache** | HTML responses: forced `Cache-Control: private, no-store` plus security headers | -| 4 | Your own | Whatever `requestMiddleware` lists | +Before a page route matches, every request passes through the middleware that +`createVitNodeStart` installs in `src/start.ts`, in this order: -`/api/*` reaches the same middleware and passes through untouched - no redirect, -no rewrite, no cache directive - so the Hono bridge sees the request exactly as -the client sent it and keeps its own caching policy. See -[Configuration](/docs/dev/configuration). +1. **CSRF**, for server function calls. +2. **Locale**, for page requests: canonical `308` redirects, the locale cookie, + and, on HTML responses, `Cache-Control: private, no-store` plus the security + headers. +3. **Your own** `requestMiddleware`, in the order you list it. ---- +An app cannot put its own middleware in front of the first two. `/api/*` +passes through untouched, so the Hono API sees the request exactly as the client +sent it and keeps its own caching policy. The details, and how to add your own +middleware, are in [Configuration](/docs/dev/configuration#request-pipeline). -## Plugin System Architecture +## Plugins -VitNode is built around modular plugins located in `plugins/*`: +Features live in plugins, each a package in `plugins/*` or `node_modules`: -- **Independent Packages**: Plugins compile to their own `dist/` with isolated dependencies. -- **Unified Manifest**: Routes, AdminCP navigation, and database models are registered declaratively. -- **Zero Overhead**: Inactive plugins contribute no code or overhead to production bundles. +- **Separate packages.** A plugin compiles to its own `dist/` folder. Its + imports of React, Hono, Drizzle and other packages stay imports, so the app + installs them once. +- **Declared, not wired by hand.** The plugin declares its routes, AdminCP + navigation and database tables, and the app's build generates the imports. +- **Only configured plugins ship.** A plugin that is not listed in + `vitnode.config.ts` and `vitnode.api.config.ts` adds no code to the build. -## Learn More +## Related { `countNotesByAuthor` stands for your existing database query. On a hit, `remember` returns the stored value and skips the query. On a miss, it runs the query, stores the result for five minutes and returns it. -Keys are prefixed with the plugin that owns the route. `notes:stats` is stored as `vitnode:cache:@acme/site-notes:notes:stats`, so it cannot collide with another plugin's key. Values are stored as JSON, so a `Date` comes back as a string. +Keys are prefixed with the plugin that owns the route. `notes:stats` is stored as `vitnode:cache:site-notes:notes:stats`, so it cannot collide with another plugin's key. Values are stored as JSON, so a `Date` comes back as a string. @@ -69,11 +69,11 @@ The TTL is a safety net, not the invalidation. Without `delete`, visitors see ou 2. Call the stats route, then list your plugin's keys: ```bash - redis-cli --scan --pattern 'vitnode:cache:@acme/site-notes:*' + redis-cli --scan --pattern 'vitnode:cache:site-notes:*' ``` - The output includes `vitnode:cache:@acme/site-notes:notes:stats`. Calling the route again within five minutes does not run `countNotesByAuthor`. + The output includes `vitnode:cache:site-notes:notes:stats`. Calling the route again within five minutes does not run `countNotesByAuthor`. 3. Create a note and run the same command. The key is gone until the next call to the stats route. -For every method, including `get`, `set`, `flush` and locks, see the [Cache reference](/docs/dev/cache/reference). +For every method, including `get`, `set`, `flush` and locks, see the [API cache reference](/docs/dev/cache/reference). diff --git a/apps/web/content/docs/dev/cache/app.mdx b/apps/web/content/docs/dev/cache/app.mdx index 2add96c38e..97c4718fb6 100644 --- a/apps/web/content/docs/dev/cache/app.mdx +++ b/apps/web/content/docs/dev/cache/app.mdx @@ -6,7 +6,12 @@ icon: AppWindow The app cache in VitNode is TanStack Query. A plugin route's loader fills a query entry during SSR or navigation, the page reads that same entry without another request, and a write invalidates it so the next read is fresh. -This guide builds a notes list for an `@acme/site-notes` plugin with a `notes` API module. How the API call itself works is covered in [Data fetching](/docs/dev/fetcher). +This guide moves the `site-notes` list page onto the query cache. How the API call itself works is covered in [Fetcher](/docs/dev/fetcher). + +## Before you begin + +- The `/site-notes/notes` page from [Plugin page](/docs/dev/plugins/page). Its route is already declared in `src/routes.ts`. +- For the last step, the AdminCP form and its `POST /api/site-notes/admin/notes` route from [AdminCP pages](/docs/dev/plugins/admin). @@ -15,32 +20,33 @@ This guide builds a notes list for an `@acme/site-notes` plugin with a `notes` A Create one `queryOptions` helper for the list. The loader, the page and the mutation all use it, so they always agree on the key. -```ts title="plugins/site-notes/src/features/notes/notes-query.ts" +```ts title="plugins/site-notes/src/pages/notes-query.ts" import { queryOptions } from '@tanstack/react-query' import { fetcher } from '@vitnode/core/tanstack/fetcher' -export const notesQueryKey = ['@acme/site-notes', 'notes'] as const +import { manifest } from '@/manifest.gen' + +export const NOTES_QUERY_KEY = [manifest.id, 'notes'] as const -export const notesQuery = () => +export const notesQueryOptions = () => queryOptions({ - queryKey: notesQueryKey, + queryKey: NOTES_QUERY_KEY, queryFn: async () => { const response = await fetcher({ - plugin: '@acme/site-notes', + plugin: manifest.id, method: 'get', module: 'notes', path: '/', }) - - if (response.status !== 200) { - throw new Error(`Loading notes answered ${response.status}.`) - } + if (!response.ok) throw new Error('Could not load notes.') return await response.json() }, }) ``` +If you followed [AdminCP pages](/docs/dev/plugins/admin), this file already exists. + Start every key with your plugin ID. `invalidateQueries` matches keys by prefix, so a generic first segment such as `'notes'` would let one plugin invalidate another plugin's data. @@ -48,29 +54,36 @@ Start every key with your plugin ID. `invalidateQueries` matches keys by prefix, ### Warm the query in the route loader -Create the page with `defineRoute` from `@vitnode/core/tanstack/plugin-routes`. Its loader context includes `queryClient`. The framework-neutral `definePluginRoute` from `@vitnode/core/routing` only receives `locale`. +Replace the loader in `src/pages/notes-page.tsx` with `defineRoute` from `@vitnode/core/tanstack/plugin-routes`. Its loader context includes `queryClient`; the framework-neutral `definePluginRoute` from `@vitnode/core/routing` only receives `locale`. ```tsx title="plugins/site-notes/src/pages/notes-page.tsx" import { useSuspenseQuery } from '@tanstack/react-query' import { defineRoute } from '@vitnode/core/tanstack/plugin-routes' +import { useTranslations } from 'use-intl' -import { notesQuery } from '../features/notes/notes-query' +import { notesQueryOptions } from './notes-query' export const route = defineRoute({ load: async ({ context }) => { - await context.queryClient.query({ ...notesQuery(), staleTime: 'static' }) // [!code ++] + await context.queryClient.query({ ...notesQueryOptions(), staleTime: 'static' }) // [!code ++] }, }) const NotesPage = () => { - const { data: notes } = useSuspenseQuery(notesQuery()) + const t = useTranslations('site-notes.notes') + const { data: notes } = useSuspenseQuery(notesQueryOptions()) return ( -
          - {notes.map((note) => ( -
        • {note.title}
        • - ))} -
        +
        +

        {t('title')}

        +
          + {notes.map(note => ( +
        • + {note.title} +
        • + ))} +
        +
        ) } @@ -88,24 +101,26 @@ The router also runs this loader when a visitor rests on a link to the page, and VitNode's query client does not refetch when a component mounts or the tab regains focus. A cached list stays as it is until your code invalidates it, so invalidate it in every mutation that changes notes: -```ts title="plugins/site-notes/src/features/notes/use-create-note.ts" +```ts title="plugins/site-notes/src/pages/use-create-note.ts" import { useMutation, useQueryClient } from '@tanstack/react-query' import { fetcher } from '@vitnode/core/tanstack/fetcher' import { toast } from 'sonner' -import { notesQueryKey } from './notes-query' +import { manifest } from '@/manifest.gen' + +import { NOTES_QUERY_KEY } from './notes-query' export const useCreateNote = () => { const queryClient = useQueryClient() return useMutation({ - mutationFn: async (title: string) => { + mutationFn: async (values: { title: string; body: string }) => { const response = await fetcher({ - plugin: '@acme/site-notes', + plugin: manifest.id, method: 'post', - module: 'notes', + module: 'admin/notes', path: '/', - args: { body: { title } }, + args: { body: values }, }) if (response.status !== 201) { @@ -113,7 +128,7 @@ export const useCreateNote = () => { } }, onSuccess: async () => { - await queryClient.invalidateQueries({ queryKey: notesQueryKey }) // [!code ++] + await queryClient.invalidateQueries({ queryKey: NOTES_QUERY_KEY }) // [!code ++] toast.success('Note created', { description: 'It now appears in the notes list.', }) @@ -122,6 +137,8 @@ export const useCreateNote = () => { } ``` +The AdminCP form from [AdminCP pages](/docs/dev/plugins/admin) does the same in its `onSubmit`. + Invalidate the narrowest key that covers everything the write could change. For a list with paging or sorting, that is the list's root key: the changed row may have moved to a different page.
        @@ -135,8 +152,8 @@ Invalidate the narrowest key that covers everything the write could change. For ## Check the result -1. Open the notes page with the browser's Network tab open. The list renders without a browser request for notes, because the loader filled the cache during SSR. +1. Open `http://localhost:3000/site-notes/notes` with the browser's Network tab open. The list renders without a browser request for notes, because the loader filled the cache during SSR. 2. Navigate to another page and back with a link. No new request for notes is sent. -3. Create a note. You see one `POST` request, then one `GET` request that refetches the list, and the new note appears. +3. Create a note in the AdminCP, then open the notes page in the same tab. The new note is there, because the write invalidated the cached list. To cache the database work behind the `notes` endpoint as well, continue with [Cache API data](/docs/dev/cache/api). diff --git a/apps/web/content/docs/dev/cache/index.mdx b/apps/web/content/docs/dev/cache/index.mdx index dd7a958668..fba4907a54 100644 --- a/apps/web/content/docs/dev/cache/index.mdx +++ b/apps/web/content/docs/dev/cache/index.mdx @@ -12,15 +12,15 @@ VitNode caches in three places. The app keeps API responses in TanStack Query, s | --------------------------------------------------------------- | -------------------------- | -------------------------------------------- | | Render SSR data without refetching it, and skip repeat requests | App cache (TanStack Query) | [Cache app data](/docs/dev/cache/app) | | Stop running the same slow database query for every visitor | API cache (Redis) | [Cache API data](/docs/dev/cache/api) | -| Look up `remember`, `delete`, `flush` and the other methods | API cache (Redis) | [Cache reference](/docs/dev/cache/reference) | +| Look up `remember`, `delete`, `flush` and the other methods | API cache (Redis) | [API cache reference](/docs/dev/cache/reference) | | Render a public page once and send that HTML to every visitor | Page cache | [Cache server-rendered pages](/docs/dev/cache/pages) | -Most features use both. The API caches the expensive part once, and each visitor's app keeps its own copy of the response. +Most features use both. The API caches the expensive part once, and each visitor's app keeps its own copy of the response. [How it differs from other caches](/docs/dev/cache/pages-invalidation#how-it-differs-from-other-caches) compares all three layers side by side. [`fetcher()`](/docs/dev/fetcher) forwards the visitor's cookies, so a stored response could be one visitor's data served to another. That is why caching - lives in the two layers above and not in the fetcher. + lives in the layers above and not in the fetcher. Content Engine public reads are not cached on the server. To cache them in your app or a separate front end, see [Content Engine caching](/docs/content-engine/caching). diff --git a/apps/web/content/docs/dev/cache/pages-invalidation.mdx b/apps/web/content/docs/dev/cache/pages-invalidation.mdx index 33be1545b9..27c5320280 100644 --- a/apps/web/content/docs/dev/cache/pages-invalidation.mdx +++ b/apps/web/content/docs/dev/cache/pages-invalidation.mdx @@ -1,5 +1,5 @@ --- -title: How cached pages stay fresh +title: Page freshness description: How VitNode's page cache expires HTML with TTL and stale-while-revalidate, keeps personal data out of shared pages, and invalidates articles when Content Engine content changes. icon: RefreshCw --- diff --git a/apps/web/content/docs/dev/cache/pages-reference.mdx b/apps/web/content/docs/dev/cache/pages-reference.mdx index 570987628e..4583ac1a82 100644 --- a/apps/web/content/docs/dev/cache/pages-reference.mdx +++ b/apps/web/content/docs/dev/cache/pages-reference.mdx @@ -78,9 +78,10 @@ import { setPageCacheTags } from '@vitnode/core/api/lib/page-cache' setPageCacheTags(c, ['events:list']) ``` -```ts title="after the write commits" +```ts title="plugins/events/src/api/modules/events/routes/update.route.ts" import { invalidatePageCacheTags } from '@vitnode/core/api/lib/page-cache' +// in the handler, after the write commits const result = await invalidatePageCacheTags(['events:list'], { mode: 'soft' }) if (result.status === 'failed') { await c.get('log').error('Cached event pages could not be invalidated.') diff --git a/apps/web/content/docs/dev/cache/pages.mdx b/apps/web/content/docs/dev/cache/pages.mdx index 502afe80b2..e2c672cde8 100644 --- a/apps/web/content/docs/dev/cache/pages.mdx +++ b/apps/web/content/docs/dev/cache/pages.mdx @@ -1,5 +1,5 @@ --- -title: Cache server-rendered pages +title: Cache pages description: Reuse a public page's server-rendered HTML for every visitor with a route cache policy, and invalidate it automatically when Content Engine content changes. icon: FileClock --- @@ -8,7 +8,11 @@ import { Tab, Tabs } from 'fumadocs-ui/components/tabs' The page cache stores the complete HTML a public page renders and sends the same copy to the next visitors. Use it for pages that look the same for everyone, such as a blog article, when they still have to be rendered on the server because their content lives in the database. -A route is never cached unless it asks to be. This guide caches the blog plugin's article page, `/blog/article/:slug`. +A route is never cached unless it asks to be. This guide walks through the policy on the blog plugin's article page, `/blog/article/:slug`, and applies the same steps to a route of your own. + +## Before you begin + +- The `@vitnode/blog` plugin installed, with at least one published article. The check below uses the slug `hello-world`; use one of your own articles' slugs. @@ -31,6 +35,8 @@ export const routes = definePluginRoutes([ `maxAge: 300` keeps a rendered article fresh for 5 minutes. `staleWhileRevalidate: 60` adds one more minute in which a visitor may still get the previous copy while a fresh one renders in the background. [How cached pages stay fresh](/docs/dev/cache/pages-invalidation#ttl-and-stale-while-revalidate) walks through the timeline. +The blog plugin already ships this policy on `/blog/article/:slug`, `/blog` and `/blog/category/:slug`, so with the blog there is nothing to add. Write the same `cache` on a public page of your own plugin. + `index()` routes accept the same option. A layout, an AdminCP page and a route with `requires` cannot be cached, and the build fails if one declares a policy. @@ -115,7 +121,7 @@ The build's route summary shows the policy that each route actually got. See [Bu ## Check the result -Request a published article twice and look at the `x-vitnode-cache` header: +Request a published article twice and look at the `x-vitnode-cache` header. Replace `hello-world` with your article's slug: ```bash curl -s -o /dev/null -D - http://localhost:3000/blog/article/hello-world | grep -i 'x-vitnode-cache\|cache-control' diff --git a/apps/web/content/docs/dev/cache/reference.mdx b/apps/web/content/docs/dev/cache/reference.mdx index 2d6ea39f60..742d1990d9 100644 --- a/apps/web/content/docs/dev/cache/reference.mdx +++ b/apps/web/content/docs/dev/cache/reference.mdx @@ -1,5 +1,5 @@ --- -title: Cache reference +title: API cache reference description: Every method on c.get("cache") in VitNode API routes - signatures, return values, TTLs, key prefixes, serialization and behavior without Redis. icon: List --- @@ -29,7 +29,7 @@ icon: List | `get`, `set`, `has`, `remember`, `delete`, `flush` | `vitnode:cache:{pluginId}:{key}` | | `acquireLock`, `releaseLock` | `vitnode:cache:__system__:lock:{key}` | -The plugin ID comes from the route that handles the request. Lock keys are not prefixed with it, so include your plugin ID in a lock key, for example `@acme/site-notes:rebuild-stats`. +The plugin ID comes from the route that handles the request. Lock keys are not prefixed with it, so include your plugin ID in a lock key, for example `site-notes:rebuild-stats`. ## Serialization diff --git a/apps/web/content/docs/dev/captcha/cloudflare.mdx b/apps/web/content/docs/dev/captcha/cloudflare.mdx index d6e30d2bfe..bdec36c612 100644 --- a/apps/web/content/docs/dev/captcha/cloudflare.mdx +++ b/apps/web/content/docs/dev/captcha/cloudflare.mdx @@ -169,7 +169,12 @@ public middleware route: ```bash curl http://localhost:3000/api/@vitnode/core/middleware -# {"isEmail":false,"sso":[],"captcha":{"siteKey":"0x4AAAAAAA...","type":"cloudflare_turnstile"}} +``` + +The response now has a `captcha` field (abridged): + +```json +{ "captcha": { "siteKey": "0x4AAAAAAA...", "type": "cloudflare_turnstile" } } ``` Then open `/register`. A Turnstile widget renders just above the **Register** @@ -266,7 +271,7 @@ dark mode with no configuration of its own. `.env` only takes effect after you restart the dev server.
        -## Next +## Related `, a wizard, a component that is not an `AutoForm` at all - `useCaptcha` is the same three values `AutoForm` uses internally. There is no captcha _adapter_ to write: the two providers live in core, and -`type` is a closed union. What this page is about is the client half. +`type` is a closed union. This page is about the client half. The example is +the `site-notes` contact form from the [Captcha overview](/docs/dev/captcha), +rebuilt without `AutoForm`. -import { File, Files, Folder } from 'fumadocs-ui/components/files' -import { TypeTable } from 'fumadocs-ui/components/type-table' +## Before you begin + +- A captcha provider configured in the API config ([Captcha](/docs/dev/captcha#1-configure-a-provider)). +- A route with `withCaptcha: true`, such as `POST /api/site-notes/contact` from [Gate your own route](/docs/dev/captcha#2-gate-your-own-route). +- The form renders inside a VitNode app, under `VitNodeRootProviders` (see the gotchas below). ## Quick start Three values, and a `
        ` for the widget to land in. -```tsx title="plugins/contact/src/views/contact/contact-form.tsx" +```tsx title="plugins/site-notes/src/pages/contact-form.tsx" import type React from 'react' import { useCaptcha } from '@vitnode/core/hooks/use-captcha' // [!code ++] @@ -109,19 +117,21 @@ building it. ### Gate the route -Nothing on the client makes a route require a token - `withCaptcha: true` on the -route does, in a [plugin](/docs/dev/plugins/create) exactly as in core. +Nothing on the client makes a route require a token. `withCaptcha: true` on the +route does, in a [plugin](/docs/dev/plugins/create) exactly as in core. This is +the route from the overview, trimmed to the relevant part: -```ts title="plugins/my-plugin/src/api/modules/contact/routes/create.route.ts" -export const createContactRoute = buildRoute({ +```ts title="plugins/site-notes/src/api/modules/contact/send.route.ts" +import { buildRoute } from '@/api/builders' + +export const sendContactRoute = buildRoute({ route: { + withCaptcha: true, // [!code ++] method: 'post', path: '/', - description: 'Send a message to the site owner', - withCaptcha: true, // [!code ++] - // ... + // request.body and responses as in the overview }, - handler: async (c) => c.text('Message sent', 201), + handler: async (c) => c.body(null, 201), }) ``` @@ -133,12 +143,12 @@ export const createContactRoute = buildRoute({ The site key is public. A plugin page can read it with the shared TanStack Query definition before rendering its form: -```tsx title="plugins/contact/src/pages/contact-page.tsx" +```tsx title="plugins/site-notes/src/pages/contact-page.tsx" import { useMiddlewareConfigQuery } from '@vitnode/core/tanstack/auth' -import { ContactForm } from '../views/contact-form' +import { ContactForm } from './contact-form' -export const ContactScreen = () => { +export const ContactPage = () => { const { data: config } = useMiddlewareConfigQuery() return @@ -171,9 +181,11 @@ change. and the server one, and it spells the `x-vitnode-captcha-token` header for you. An empty string sends no header at all. -```ts title="src/site/contact/send-message.ts" +```ts title="plugins/site-notes/src/pages/send-message.ts" import { fetcher } from '@vitnode/core/tanstack/fetcher' +import { manifest } from '@/manifest.gen' + export const sendMessage = async ({ captchaToken, ...body @@ -182,7 +194,7 @@ export const sendMessage = async ({ message: string }) => await fetcher({ - plugin: '@vitnode/my-plugin', + plugin: manifest.id, method: 'post', module: 'contact', path: '/', @@ -199,7 +211,7 @@ export const sendMessage = async ({ Call the route with no token. It answers `400` before your handler runs: ```bash -curl -i -X POST 'http://localhost:3000/api/@vitnode/my-plugin/contact' \ +curl -i -X POST 'http://localhost:3000/api/site-notes/contact' \ -H 'Content-Type: application/json' \ -d '{"message":"Hello from curl"}' ``` @@ -223,12 +235,12 @@ A minimal captcha-gated feature is four files, and only one of them knows the hook exists. - - - + + + - - + + @@ -266,7 +278,7 @@ hook exists. that knows whether the token was any good. -## Next +## Related ---- +### 2. Gate your own route -### 2. Protect Custom API Routes +Add `withCaptcha: true` to the route definition. This `site-notes` route takes a message for the site owner: -Enable captcha verification on any custom Hono endpoint with `withCaptcha: true`: +```ts title="plugins/site-notes/src/api/modules/contact/send.route.ts" +import { z } from "@hono/zod-openapi" -```ts title="plugins/blog/src/api/modules/comments/routes/create.route.ts" import { buildRoute } from "@/api/builders" -export const createCommentRoute = buildRoute({ +export const sendContactRoute = buildRoute({ route: { withCaptcha: true, // [!code ++] method: "post", path: "/", - responses: { 201: { description: "Comment created" } }, + request: { + body: { + content: { + "application/json": { + schema: z.object({ message: z.string().trim().min(1).max(2000) }), + }, + }, + }, + }, + responses: { 201: { description: "Message sent." } }, }, handler: async (c) => { - // Execution only reaches here if the captcha token is valid + // Only reached with a valid captcha token + const { message } = c.req.valid("json") + await c.get("log").debug(`Contact message: ${message}`) + + return c.body(null, 201) }, }) ``` ---- +Put it in a `contact` module and add that module to `buildApiPlugin` in `src/config.api.ts`, the same way as in [API modules](/docs/dev/plugins/api/modules). The route is then `POST /api/site-notes/contact`. Without a token it answers `400` before the handler runs. -### 3. Render the Frontend Widget +### 3. Render the widget Pass the app's captcha config to `AutoForm`. It loads the widget, keeps the submit button disabled until the captcha is ready, and hands `onSubmit` a fresh token to forward as the fetcher's `captchaToken`: -```tsx title="plugins/blog/src/views/comment-form.tsx" +```tsx title="plugins/site-notes/src/pages/contact-form.tsx" import { AutoForm } from "@vitnode/core/components/form/auto-form" import { AutoFormTextarea } from "@vitnode/core/components/form/fields/textarea" import { useMiddlewareConfigQuery } from "@vitnode/core/tanstack/auth" import { fetcher } from "@vitnode/core/tanstack/fetcher" import { z } from "zod" -const formSchema = z.object({ text: z.string().min(1) }) +import { manifest } from "@/manifest.gen" -export const CommentForm = () => { +const formSchema = z.object({ message: z.string().min(1) }) + +export const ContactForm = () => { const { data: config } = useMiddlewareConfigQuery() return ( @@ -83,16 +98,16 @@ export const CommentForm = () => { captcha={config.captcha} // [!code ++] fields={[ { - id: "text", - component: (props) => , + id: "message", + component: (props) => , }, ]} formSchema={formSchema} onSubmit={async (values, form, { captchaToken }) => { await fetcher({ - plugin: "@acme/blog", + plugin: manifest.id, method: "post", - module: "comments", + module: "contact", path: "/", captchaToken, // [!code ++] args: { body: values }, @@ -103,11 +118,9 @@ export const CommentForm = () => { } ``` -`useMiddlewareConfigQuery` suspends until the config loads, so render the form under a `Suspense` boundary. Outside `AutoForm`, use the `useCaptcha` hook from `@vitnode/core/hooks/use-captcha`: it returns `isReady`, `getToken()` and `onReset()`, and for Turnstile your form must contain an element with `id="vitnode_captcha"`. - ---- +`useMiddlewareConfigQuery` suspends until the config loads, so render the form under a `Suspense` boundary. A real form would also show a toast and reset after a successful send. For a form that is not an `AutoForm`, see [Custom forms](/docs/dev/captcha/custom-adapter). -## Supported Providers +## Supported providers { /> -## Learn More +## Related diff --git a/apps/web/content/docs/dev/captcha/recaptcha.mdx b/apps/web/content/docs/dev/captcha/recaptcha.mdx index f1994f93a9..10d870ca9a 100644 --- a/apps/web/content/docs/dev/captcha/recaptcha.mdx +++ b/apps/web/content/docs/dev/captcha/recaptcha.mdx @@ -173,7 +173,12 @@ public middleware route: ```bash curl http://localhost:3000/api/@vitnode/core/middleware -# {"isEmail":false,"sso":[],"captcha":{"siteKey":"6Lc...","type":"recaptcha_v3"}} +``` + +The response now has a `captcha` field (abridged): + +```json +{ "captcha": { "siteKey": "6Lc...", "type": "recaptcha_v3" } } ``` Then open `/register`. Google's badge appears in the bottom corner of the page, @@ -204,11 +209,15 @@ Useful when the console's numbers and your form's behaviour disagree. | Threshold | `0.5`, hardcoded in `captchaMiddleware` | | Token travels as | the header `x-vitnode-captcha-token` | -The visitor's IP is forwarded as `remoteip`. It is the address of the socket the -request arrived on, falling back to `127.0.0.1` when the runtime exposes no -connection info. Proxy headers like `x-forwarded-for` are deliberately ignored - -the caller writes them - so behind a reverse proxy this is your proxy's address -and the score reflects your server rather than the person filling the form. +The visitor's IP is forwarded as `remoteip`. It is the client address VitNode +resolved for the request, the same one the rate limiter uses: the address your +web server signed for a server-rendered request, otherwise `X-Forwarded-For` +read past `VITNODE_TRUSTED_PROXY_HOPS` proxies, otherwise the socket address +(`127.0.0.1` when the runtime exposes none). Behind a reverse proxy with the +default of `0` hops, that is the proxy's address, so the score reflects your +server rather than the person filling the form. [Real visitor +IPs](/docs/dev/advanced/rate-limiter#real-visitor-ips-for-server-rendered-pages) +shows how to set it up. ## Gotchas @@ -241,7 +250,7 @@ and the score reflects your server rather than the person filling the form. `.env` only takes effect after you restart the dev server. -## Next +## Related
    @@ -49,9 +49,9 @@ export const BlogHeader = ({ count, name }: { count: number; name: string }) => --- -## ICU Syntax Reference +## ICU syntax -### 1. Variables & Numbers +### Variables and numbers ```json { @@ -60,7 +60,7 @@ export const BlogHeader = ({ count, name }: { count: number; name: string }) => } ``` -### 2. Cardinal Pluralization +### Plurals ```json { @@ -68,7 +68,7 @@ export const BlogHeader = ({ count, name }: { count: number; name: string }) => } ``` -### 3. Select / Enums +### Select ```json { @@ -78,18 +78,18 @@ export const BlogHeader = ({ count, name }: { count: number; name: string }) => --- -## Where Message Files Live +## Where message files live | Folder | Audience | Purpose | | :--- | :--- | :--- | | `src/locales/{locale}.json` | **Frontend** | Browser UI, page text, and AdminCP navigation | -| `src/locales/api/{locale}.json` | **API Server** | Transactional emails and backend validation errors | +| `src/locales/api/{locale}.json` | **API Server** | Emails and other output with no UI. API responses return i18n keys, which the frontend translates | -## Learn More +## Related diff --git a/apps/web/content/docs/dev/i18n/meta.json b/apps/web/content/docs/dev/i18n/meta.json index a929e83729..c1e6625c58 100644 --- a/apps/web/content/docs/dev/i18n/meta.json +++ b/apps/web/content/docs/dev/i18n/meta.json @@ -1,5 +1,5 @@ { - "title": "Internationalization (I18n)", + "title": "i18n", "description": "Learn how to make your VitNode plugins multilingual with our I18n guide.", "icon": "Globe", "pages": [ @@ -7,6 +7,7 @@ "messages", "pages", "localized-urls", + "localized-urls-advanced", "namespaces", "server", "..." diff --git a/apps/web/content/docs/dev/i18n/namespaces.mdx b/apps/web/content/docs/dev/i18n/namespaces.mdx index f7902fdbfb..50597a9f3a 100644 --- a/apps/web/content/docs/dev/i18n/namespaces.mdx +++ b/apps/web/content/docs/dev/i18n/namespaces.mdx @@ -49,9 +49,10 @@ asked for. ## The per-plugin prefix rule -**Every top-level key a plugin ships is the plugin's own id.** A key outside it -collides with core and with every other plugin, so VitNode ignores it rather than -letting two packages fight over one path. +**Keep everything a plugin ships under the plugin's own id.** VitNode merges +every package's locale file into one tree and does not filter keys, so a +top-level key outside your id shares one path with core and every other plugin, +and whichever package loads last wins. ```json title="plugins/my-plugin/src/locales/en.json" { @@ -66,7 +67,14 @@ Scoped ids work exactly the same way - `@vitnode/blog` is one path segment even though it contains a slash, so `@vitnode/blog.admin` is a legal two-segment namespace. - +Two kinds of top-level keys are expected outside your id, because core looks +them up by a fixed name: + +- **AI action labels** live under `ai_actions..*`, for example + `ai_actions.@vitnode/core.alt_generate.title`. +- **Staff permission labels**, described below. + + A staff permission's label is a **flat top-level key**, not a branch: `@vitnode/blog:posts:can_view`. There is nothing to slice a namespace out of, so the AdminCP asks for those keys as if they were namespaces and has to chunk @@ -171,7 +179,7 @@ import { MAX_NAMESPACES } from '@vitnode/core/tanstack/i18n' `, which only exists once the provider has. -## Next +## Related import('./pages/about-page')), - messages: ['@vitnode/blog.about'], // [!code ++] - }), -]) +```ts title="plugins/site-notes/src/routes.ts" +page('/site-notes/notes', { + component: lazy(() => import('./pages/notes-page')), + messages: ['site-notes.notes'], // [!code ++] +}), ``` -Read strings directly with `useTranslations`: +## Read the strings + +Read them with `useTranslations` from `use-intl`, scoped to the same namespace: -```tsx title="plugins/blog/src/pages/about-page.tsx" +```tsx title="plugins/site-notes/src/pages/notes-page.tsx" import { useTranslations } from 'use-intl' -const AboutPage = () => { - const t = useTranslations('@vitnode/blog.about') +const NotesPage = () => { + const t = useTranslations('site-notes.notes') return

    {t('title')}

    } -export default AboutPage +export default NotesPage ``` -And define the messages in `plugins/blog/src/locales/en.json`: +## Define the strings -```json title="plugins/blog/src/locales/en.json" +Put them under the plugin's id in `src/locales/en.json`: + +```json title="plugins/site-notes/src/locales/en.json" { - "@vitnode/blog": { - "about": { - "title": "About Our Blog" + "site-notes": { + "notes": { + "title": "Site notes", + "empty": "No notes yet." } } } ``` +If a key renders as its own name, such as `site-notes.notes.title`, the route does not declare the namespace that holds it. + Host messages are for the site shell. A product page should declare its plugin namespace as the route's `messages` and keep its locale JSON beside the route. -## Placeholders and Pluralization - -VitNode supports ICU message syntax out of the box: - -```json title="plugins/blog/src/locales/en.json" -{ - "cart": { - "greeting": "Hello, {name}!", - "items": "{count, plural, =0 {No items} one {1 item} other {# items}}" - } -} -``` - -In your React component: - -```tsx -const t = useTranslations('cart') - -return ( -
    -

    {t('greeting', { name: 'Alex' })}

    -

    {t('items', { count: 3 })}

    -
    -) -``` +For placeholders, plurals and rich text in your strings, see [Messages](/docs/dev/i18n/messages). For a page's ``, translate in `head` with `t`; see [Route module](/docs/dev/routing/route-module#translated-metadata). <Callout type="info" title="Global namespaces"> `core.global` is provided by the root shell to every route, supplying shared strings for dialogs, toasts, and buttons. </Callout> -## Learn More +## Related <Cards> <Card @@ -102,7 +77,7 @@ return ( /> <Card title="API i18n" - description="Translate emails and API error responses" + description="Translate emails and other output with no UI" href="/docs/dev/i18n/server" /> </Cards> diff --git a/apps/web/content/docs/dev/i18n/server.mdx b/apps/web/content/docs/dev/i18n/server.mdx index b63720197e..30f177c84b 100644 --- a/apps/web/content/docs/dev/i18n/server.mdx +++ b/apps/web/content/docs/dev/i18n/server.mdx @@ -1,21 +1,23 @@ --- -title: API i18n +title: API translations description: Translate emails and API responses with VitNode's request-scoped API translator. icon: Server --- -The API gets the same message tree the frontend does, reachable in any route as `c.get("i18n")`. +The API has its own translator, reachable in any route as `c.get("i18n")`. Use it for output that has no UI to translate it, such as an email. Anything the web app shows, return as an i18n key and let the frontend translate it, so the text follows the visitor's language and a cached response works in every language. ```ts const t = await c.get('i18n').getTranslator() -return c.json({ message: t('core.global.save') }) +const subject = t('welcome.email.subject') ``` -<Callout title="Nothing is copied" type="info"> - The API reads locale files straight out of `node_modules`. There is no build - step to remember and no `src/locales/` folder to keep in sync - install a - plugin and its translations are available on the server immediately. +<Callout title="Package strings come from the packages" type="info"> + The API reads each installed package's server strings straight from the + package, so a plugin's translations are available as soon as it is + installed. Your app's own rewordings and extra languages are the one part you + register yourself, as `i18n.messages` in `vitnode.api.config.ts`; see + [Overriding strings](/docs/dev/i18n#overriding-strings). </Callout> The server loads a different, smaller set of strings than the frontend. Packages ship two trees - the frontend's UI copy in `src/locales/`, and the server's strings (emails) in `src/locales/api/` - and the API only ever loads the second. An API-only app never pulls in the admin UI's messages. @@ -159,7 +161,7 @@ export const yourPlugin = () => manifest, localeFiles: { // [!code ++:2] - en: '@acme/your-plugin/locales/en.json', + en: 'your-plugin/locales/en.json', }, messages, }) diff --git a/apps/web/content/docs/dev/index.mdx b/apps/web/content/docs/dev/index.mdx index 7c9e658a73..7fec0aa67c 100644 --- a/apps/web/content/docs/dev/index.mdx +++ b/apps/web/content/docs/dev/index.mdx @@ -1,105 +1,60 @@ --- title: Introduction -description: Build a TanStack Start and Hono application with VitNode plugins, then deploy and operate it confidently. +description: Learn VitNode development step by step. Set up an app, build the site-notes plugin with an API, a page, a database table, a permission and an AdminCP screen, then explore the framework. icon: Power --- -import { - BookOpenIcon, - PackagePlusIcon, - RocketIcon, - ServerIcon, -} from 'lucide-react' -import { Tab, Tabs } from 'fumadocs-ui/components/tabs' +import { BookOpenIcon, LayersIcon, ServerIcon } from "lucide-react"; -VitNode is a **TanStack Start** front end, a **Hono** API, Postgres, and an -AdminCP. Its important rule is pleasantly simple: product features live in -**plugins**. A plugin owns its pages, APIs, data, translations, and AdminCP -extensions, so your host app does not become a drawer full of mystery cables. - -{/* Image prompt: Clean dark-theme architecture diagram. A TanStack Start application and Hono API sit in the center, Postgres below, and three colorful plugin packages connect to routes, API modules, data, and AdminCP. Keep labels large and legible, 1600x900. */} +VitNode is a **TanStack Start** front end, a **Hono** API, PostgreSQL and an AdminCP. Product features live in **plugins**. A plugin owns its pages, API, tables, translations and AdminCP screens, so the app itself stays small. <Callout type="info" title="Canary documentation"> These pages follow VitNode 2.0 on the `canary` branch. It moves quickly; when code and prose disagree, trust the code and send the prose a friendly PR. </Callout> -## Start with a running app - -<Cards> - <Card - icon={<RocketIcon />} - title="Get an app running" - description="Scaffold VitNode, migrate Postgres, and enter AdminCP." - href="/docs/dev/setup" - /> - <Card - icon={<PackagePlusIcon />} - title="Create a plugin" - description="Make the package that will own your first feature." - href="/docs/dev/plugins/create" - /> - <Card - icon={<ServerIcon />} - title="Deploy VitNode" - description="Build, migrate, and run your app on a server or container." - href="/docs/dev/deployments/self-hosted" - /> -</Cards> - -## Create an app +## Learn by building one plugin -<Tabs groupId="package-manager" persist items={["bun", "pnpm", "npm"]} label="Create a VitNode app"> +The **Learn** lessons build one small plugin, `site-notes`, which publishes short notes on your site. Each lesson starts from the previous one and says which files change and how to check the result. -```bash tab="bun" -bun create vitnode-app@canary -``` +| # | Lesson | What `site-notes` can do afterwards | +| --- | --- | --- | +| 1 | [Setup](/docs/dev/setup) | A VitNode app runs locally, with you as its administrator. | +| 2 | [Create plugin](/docs/dev/plugins/create) | The generated plugin shows a page at `/site-notes`. | +| 3 | [API modules](/docs/dev/plugins/api/modules) | `GET /api/site-notes/notes` returns sample notes. | +| 4 | [API routes](/docs/dev/plugins/api/routes) | `GET /api/site-notes/notes/{id}` validates its parameter and answers `404` for a missing note. | +| 5 | [Plugin page](/docs/dev/plugins/page) | `/site-notes/notes` lists the notes, loaded before the page renders. | +| 6 | [Database](/docs/dev/database) | Notes live in a `site_notes` PostgreSQL table. | +| 7 | [Permissions](/docs/dev/working-with-users/staff-permissions) | Only administrators with **Create notes** can add a note. | +| 8 | [AdminCP page](/docs/dev/plugins/admin) | Staff create notes from a **Notes** screen in the AdminCP. | -```bash tab="pnpm" -pnpm create vitnode-app@canary -``` +The same plugin then continues in [Events](/docs/dev/events), [Queue](/docs/dev/advanced/queue), [Email templates](/docs/dev/email/templates) and [Storage](/docs/dev/storage). -```bash tab="npm" -npm create vitnode-app@canary -``` - -</Tabs> - -Choose **Single App** for one TanStack Start app with Hono at `/api`, or a -monorepo when web and API deploy separately. If you plan to create plugins, -enable **Turborepo** during setup: the generator needs a workspace root. - -## The five-minute path - -1. Start Postgres (Docker is the low-drama local choice). -2. Run `db:migrate` to create core tables and your first administrator. -3. Run `dev`, then visit `http://localhost:3000/admin`. -4. Create a plugin for your first product page, API endpoint, content type, or - dashboard widget. - -Every command above is expanded in [Getting started](/docs/dev/setup). From -there, [Build your first plugin](/docs/guides/first-plugin) gives you a real -route to visit—not just a philosophical plugin. +<Callout title="Want tables, APIs and AdminCP screens generated for you?"> + The [Content Engine](/docs/content-engine) builds all of them from one + content type definition. Choose it for editorial content such as articles or + events; choose the lessons above to learn how a plugin works by hand. +</Callout> ## Find the right reference <Cards> <Card - icon={<PackagePlusIcon />} - title="Plugin routes" - description="Claim public or AdminCP URLs without adding host route files." - href="/docs/dev/routing" + icon={<LayersIcon />} + title="Architecture" + description="How the app, the API and plugins fit together." + href="/docs/dev/architecture" /> <Card - icon={<ServerIcon />} - title="Admin Control Panel" - description="Add plugin-owned screens, navigation, permissions, and widgets." - href="/docs/dev/plugins/admin" + icon={<BookOpenIcon />} + title="Routing" + description="Public and AdminCP pages owned by a plugin." + href="/docs/dev/routing" /> <Card - icon={<BookOpenIcon />} - title="Content Engine" - description="Define content once and receive data, API, and AdminCP tools." - href="/docs/content-engine" + icon={<ServerIcon />} + title="Deploy" + description="Build, migrate and run VitNode on your own server." + href="/docs/dev/deployments/self-hosted" /> </Cards> diff --git a/apps/web/content/docs/dev/meta.json b/apps/web/content/docs/dev/meta.json index 6858a8fe2c..99c9de4aef 100644 --- a/apps/web/content/docs/dev/meta.json +++ b/apps/web/content/docs/dev/meta.json @@ -4,16 +4,19 @@ "icon": "Braces", "root": true, "pages": [ - "---Start here---", + "---Learn---", "index", "setup", - "cli", - "deployments", + "plugins/create", + "plugins/api/modules", + "plugins/api/routes", + "plugins/page", + "database/index", + "working-with-users/staff-permissions", + "plugins/admin/index", + "---Framework---", "architecture", "configuration", - "navigation", - "mobile-navigation", - "---Framework---", "plugins", "routing", "fetcher", @@ -21,6 +24,8 @@ "server-functions", "database", "i18n", + "navigation", + "mobile-navigation", "performance", "---Extend VitNode---", "widgets", @@ -43,6 +48,8 @@ "websocket", "advanced", "---Operate---", + "cli", + "deployments", "updates", "debugging", "swagger", diff --git a/apps/web/content/docs/dev/mobile-navigation-bar.webp b/apps/web/content/docs/dev/mobile-navigation-bar.webp new file mode 100644 index 0000000000..f481c37a0e Binary files /dev/null and b/apps/web/content/docs/dev/mobile-navigation-bar.webp differ diff --git a/apps/web/content/docs/dev/mobile-navigation-menu.webp b/apps/web/content/docs/dev/mobile-navigation-menu.webp new file mode 100644 index 0000000000..4eec3bc66c Binary files /dev/null and b/apps/web/content/docs/dev/mobile-navigation-menu.webp differ diff --git a/apps/web/content/docs/dev/mobile-navigation.mdx b/apps/web/content/docs/dev/mobile-navigation.mdx index ea6c949554..c1d812890f 100644 --- a/apps/web/content/docs/dev/mobile-navigation.mdx +++ b/apps/web/content/docs/dev/mobile-navigation.mdx @@ -1,9 +1,13 @@ --- -title: Mobile Navigation +title: Mobile navigation description: How VitNode's menus behave on phones - a floating bottom bar of up to three links from vitnode.config.ts plus a Menu tab for the rest, and a side drawer behind the user button for the account. icon: Smartphone --- +import { ImgDocs } from "@/components/fumadocs/img"; +import mobileBar from "./mobile-navigation-bar.webp"; +import mobileMenu from "./mobile-navigation-menu.webp"; + On a phone, the desktop header's navigation has nowhere to go. So below the `sm` breakpoint (640px) VitNode splits it in two: your most-used links move to a **floating bottom bar** pinned to the bottom of the screen - the kind your thumb @@ -11,9 +15,14 @@ already knows from native apps - and everything else lives in a **side drawer** behind the user button at the top. Both menus come from the `navigation` option in `src/vitnode.config.ts`. See -[Main Navigation](/docs/dev/navigation) for the item options. +[Main navigation](/docs/dev/navigation) for the item options. -{/* Image prompt: A phone-sized light-mode screenshot of a community site. At the bottom floats a white, fully rounded bar with a soft shadow and four equal tabs - "Home", "Discover", "Search" and a hamburger "Menu" tab, with icons above small labels, the active "Search" tab tinted blue on a light gray pill. Top right in the header sits a small round user avatar. Neutral grays, blue accent, 390x844. */} +<ImgDocs + withoutBackground + className="*:max-w-sm" + src={mobileBar} + alt="A VitNode Discover page on a phone, with the bottom bar showing Forum, Discover (active, in blue), Search and Menu tabs" +/> ## Two menus, one config @@ -26,7 +35,7 @@ A page can sit in both menus at once - Search in the header _and_ in the bottom bar is a perfectly normal choice. ```ts title="src/vitnode.config.ts" -import { CompassIcon, MessagesSquareIcon, SearchIcon } from 'lucide-react' +import { CompassIcon, HouseIcon, SearchIcon } from 'lucide-react' export const vitNodeConfig = buildConfig({ // ... @@ -36,7 +45,7 @@ export const vitNodeConfig = buildConfig({ ], // [!code ++:5] mobileBottomBar: [ - { href: '/forum', icon: MessagesSquareIcon, title: 'app.menu.forum' }, + { href: '/', icon: HouseIcon, title: 'app.menu.home' }, { href: '/discover', icon: CompassIcon, title: 'app.menu.discover' }, { href: '/search', icon: SearchIcon, title: 'app.menu.search' }, ], @@ -44,6 +53,10 @@ export const vitNodeConfig = buildConfig({ }) ``` +The `discover` and `search` labels come with a new app. Add `"home": "Home"` +next to them under `app.menu` in `src/locales/app/en.json`, and in every other +language's file. + A fourth link stops the app at startup with a `[VitNode navigation]` error, so the bar never silently drops one. @@ -63,6 +76,13 @@ An empty bottom bar is not drawn at all. That is the default when `navigation.mobileBottomBar` is missing, so phones start with just the header and its side drawer until you add a link. +<ImgDocs + withoutBackground + className="*:max-w-sm" + src={mobileMenu} + alt="The Menu tab's drawer open from the bottom over a dimmed page, listing Docs, Plugins, Blog and Community with Discover highlighted under it" +/> + <Callout type="info" title="Give every link an icon and a short label"> At 320px each of the four tabs gets about 72px, which fits one word like "Discover" or "Settings" - longer labels are cut off with an ellipsis. A link diff --git a/apps/web/content/docs/dev/moderation/approval-detail.webp b/apps/web/content/docs/dev/moderation/approval-detail.webp new file mode 100644 index 0000000000..1e03e55c8e Binary files /dev/null and b/apps/web/content/docs/dev/moderation/approval-detail.webp differ diff --git a/apps/web/content/docs/dev/moderation/approvals-queue.webp b/apps/web/content/docs/dev/moderation/approvals-queue.webp new file mode 100644 index 0000000000..42ad3acb79 Binary files /dev/null and b/apps/web/content/docs/dev/moderation/approvals-queue.webp differ diff --git a/apps/web/content/docs/dev/moderation/configure-moderators.mdx b/apps/web/content/docs/dev/moderation/configure-moderators.mdx index 5f6bbc8c41..a829fa2ff9 100644 --- a/apps/web/content/docs/dev/moderation/configure-moderators.mdx +++ b/apps/web/content/docs/dev/moderation/configure-moderators.mdx @@ -8,7 +8,7 @@ import { ImgDocs } from "@/components/fumadocs/img"; import scopeEditor from "./moderation-scope-editor.webp"; import scopeDialog from "./moderation-scope-dialog.webp"; -You set up moderators on the same AdminCP screen as every other staff permission. This guide makes a user a moderator who handles blog reports everywhere and example article reports only in some categories. +You set up moderators on the same AdminCP screen as every other staff permission. This guide makes a user a moderator who handles blog reports everywhere and newsroom story reports only in some categories. ## Before you begin @@ -38,20 +38,20 @@ A permission that depends on another one stays locked until its dependency is ti ### Choose where a scoped permission applies -A scoped permission shows its scope under the checkbox: **All resources**, or the number of selected resources. Here the example plugin's **View reports** covers two categories and the management permission covers one: +A scoped permission shows its scope under the checkbox: **All resources**, or the number of selected resources. Here the newsroom plugin's **View reports** covers two categories and the management permission covers one: <ImgDocs withoutBackground className="*:max-w-full" src={scopeEditor} - alt="Edit permissions for SectionMod with the example plugin selected and the Articles module open. View reports shows 2 selected and Claim, assign, annotate and resolve reports shows 1 selected, each with a Change button" + alt="Edit permissions for Grace Hopper with the Newsroom plugin selected and its Stories module open. View reports shows 2 selected and Claim, assign, annotate and resolve reports shows 1 selected, each with a Change button" /> Choose **Change** to open the picker. **All resources** includes resources created later. **Selected resources** lists the family's resources with a search box, loading more on demand: <ImgDocs src={scopeDialog} - alt="Where can they View reports dialog with Selected resources chosen, Announcements and Help desk selected, a Search Categories box, and Cancel and Apply buttons" + alt="Where can they View reports dialog with Selected resources chosen, Local news and Culture selected, a Search Categories box, and Cancel and Apply buttons" /> Tick **Apply to every scoped permission in this module** to copy the same scope to the module's other scoped permissions. Each permission still saves its own scope, so you can narrow one afterwards. @@ -70,7 +70,7 @@ Choose **Save changes**. The moderator's permissions apply on their next request ## Check the result -Sign in as the moderator. The user menu now has a **Moderator CP** link to `/modcp/reports`, and the reports board shows blog reports plus example article reports from the two selected categories only. See [Process reports](/docs/dev/moderation/process-reports). +Sign in as the moderator. The user menu now has a **Moderator CP** link to `/modcp/reports`, and the reports board shows blog reports plus newsroom story reports from the two selected categories only. See [Process reports](/docs/dev/moderation/process-reports). ## When saving is refused diff --git a/apps/web/content/docs/dev/moderation/enable-reports.mdx b/apps/web/content/docs/dev/moderation/enable-reports.mdx index fbcfbbd30d..30988b015d 100644 --- a/apps/web/content/docs/dev/moderation/enable-reports.mdx +++ b/apps/web/content/docs/dev/moderation/enable-reports.mdx @@ -8,9 +8,11 @@ Readers report content with a **Report** button, and every report lands in the m | What readers report | Guide | | ------------------------------------------- | --------------------------------------------------------------------------------------- | -| Records of a Content Engine content type | [Report content](/docs/content-engine/reports): the `reports` option and `ReportButton` | +| Records of a Content Engine content type | [Reports](/docs/content-engine/reports): the `reports` option and `ReportButton` | | Anything else your plugin stores | [Custom report targets](/docs/dev/moderation/reference#custom-report-targets) with `defineReportTarget` | +For a content type, the switch is one line, `reports: true` on its definition. The Reports guide then adds the button and follows a report to the ModCP. Turning reports on grants nothing by itself, so nobody sees a report until a moderator holds `can_view_reports`. + Then set up the people who handle the reports: 1. [Manage report reasons](/docs/dev/moderation/manage-report-reasons) to choose what readers can pick. diff --git a/apps/web/content/docs/dev/moderation/moderation-scope-dialog.webp b/apps/web/content/docs/dev/moderation/moderation-scope-dialog.webp index 586b90efaa..3da33a72c0 100644 Binary files a/apps/web/content/docs/dev/moderation/moderation-scope-dialog.webp and b/apps/web/content/docs/dev/moderation/moderation-scope-dialog.webp differ diff --git a/apps/web/content/docs/dev/moderation/moderation-scope-editor.webp b/apps/web/content/docs/dev/moderation/moderation-scope-editor.webp index e3a0aaf48f..f442dc25cf 100644 Binary files a/apps/web/content/docs/dev/moderation/moderation-scope-editor.webp and b/apps/web/content/docs/dev/moderation/moderation-scope-editor.webp differ diff --git a/apps/web/content/docs/dev/moderation/process-reports.mdx b/apps/web/content/docs/dev/moderation/process-reports.mdx index 100af5ec6c..44a3d501c1 100644 --- a/apps/web/content/docs/dev/moderation/process-reports.mdx +++ b/apps/web/content/docs/dev/moderation/process-reports.mdx @@ -12,7 +12,7 @@ The **Moderator CP** at `/modcp/reports` is where moderators work through report ## Before you begin -You need a moderator entry with `can_view_reports` for the content type, and `can_manage_reports` to change anything. Hiding needs `can_hide`. See [Configure moderators](/docs/dev/moderation/configure-moderators). +You need a moderator entry with `can_view_reports` for the content type, and `can_manage_reports` to change anything. Hiding and restoring content needs `can_hide` (which depends on `can_view_hidden`) plus `can_manage_reports`, because the action is recorded in the report. See [Configure moderators](/docs/dev/moderation/configure-moderators). <Steps> diff --git a/apps/web/content/docs/dev/moderation/reference.mdx b/apps/web/content/docs/dev/moderation/reference.mdx index cda1f937a6..c31ff51a26 100644 --- a/apps/web/content/docs/dev/moderation/reference.mdx +++ b/apps/web/content/docs/dev/moderation/reference.mdx @@ -34,15 +34,15 @@ Added to the content type's `permissionModule` in the **moderator** catalog, unl Content actions also need `can_manage_reports`, because they are recorded in the report. -[Public write](/docs/content-engine/public-write-api#moderator-permission) adds `can_edit` to the same module. In a container collection with [publishing policies](/docs/content-engine/container-policies), `can_edit` and `can_approve` are scoped by the collection's family, whose id is the container definition id and whose resources are container ids and `"{id}:tree"` subtrees. See [Container-scoped permissions](/docs/dev/moderation/scoped-permissions#container-scoped-permissions). Generated entries merge with the report permissions; only a module the plugin declares itself in `permissionStaff.moderator` is left as declared. +[Write API](/docs/content-engine/public-write-api#moderator-permission) adds `can_edit` to the same module. In a container collection with [publishing policies](/docs/content-engine/container-policies), `can_edit` and `can_approve` are scoped by the collection's family, whose id is the container definition id and whose resources are container ids and `"{id}:tree"` subtrees. See [Container-scoped permissions](/docs/dev/moderation/scoped-permissions#container-scoped-permissions). Generated entries merge with the report permissions; only a module the plugin declares itself in `permissionStaff.moderator` is left as declared. ## Scoped grants A stored grant is `{ plugin, module, permission, scope? }`. `scope` is one of: ```ts -{ family: "example.category", mode: "all" } -{ family: "example.category", mode: "selected", ids: ["1", "2"] } +{ family: "newsroom.category", mode: "all" } +{ family: "newsroom.category", mode: "selected", ids: ["1", "2"] } ``` Declare a scoped permission with `{ permission, dependsOn?, scope: "family.id" }` in `permissionStaff.moderator`. Admin permissions cannot be scoped. Resource ids match `/^[A-Za-z0-9][A-Za-z0-9._:-]{0,63}$/`. diff --git a/apps/web/content/docs/dev/moderation/review-approvals.mdx b/apps/web/content/docs/dev/moderation/review-approvals.mdx index 603890ec62..fbb356b036 100644 --- a/apps/web/content/docs/dev/moderation/review-approvals.mdx +++ b/apps/web/content/docs/dev/moderation/review-approvals.mdx @@ -4,6 +4,10 @@ description: Approve or reject new content and edits waiting in the VitNode Mode icon: ClipboardCheck --- +import { ImgDocs } from "@/components/fumadocs/img"; +import approvalsQueue from "./approvals-queue.webp"; +import approvalDetail from "./approval-detail.webp"; + When a container's [publishing policy](/docs/content-engine/container-policies) asks for approval, new content and edits from members do not go live straight away. They wait in the **Approvals** queue of the Moderator CP at `/modcp/approvals` until a moderator says yes or no. [Approvals](/docs/content-engine/approvals) explains what is stored meanwhile and what authors see. ## Before you begin @@ -18,7 +22,12 @@ You need a moderator entry with `can_approve` for the content type, in a scope t Choose **Moderator CP** in the user menu, then **Approvals**. The number next to it counts what is still pending. -{/* Image prompt: Moderator CP approvals queue with status tabs (Pending, Approved, Rejected, Replaced, Cancelled), Type and Location filters, and a list of submissions showing the author avatar, a New or Edit badge, the content type, the title, the location and the date */} +<ImgDocs + withoutBackground + className="*:max-w-full" + src={approvalsQueue} + alt="The Moderator CP Approvals queue with Pending, Approved, Rejected, Replaced and Cancelled tabs, Type and Location filters, and two pending new topics by Ada Lovelace in General" +/> Each row shows who sent it, whether it is **New** content or an **Edit** of something already live, the content type (a topic or a comment, for example), where it lives and when it arrived. @@ -37,6 +46,13 @@ Open a row. For **New** content you see every proposed field. For an **Edit** yo **History** lists earlier submissions for the same content, with their outcomes and reasons. **View live** opens published content in a new tab when its content type has a page address (`search.pathTemplate`). +<ImgDocs + withoutBackground + className="*:max-w-full" + src={approvalDetail} + alt="A pending submission in the Moderator CP: the proposed title and message, its history, Approve and Reject buttons, and details with the author, type, location, kind and status" +/> + </Step> <Step> diff --git a/apps/web/content/docs/dev/moderation/scoped-permissions.mdx b/apps/web/content/docs/dev/moderation/scoped-permissions.mdx index f2f0ec9fd0..f73faad5ea 100644 --- a/apps/web/content/docs/dev/moderation/scoped-permissions.mdx +++ b/apps/web/content/docs/dev/moderation/scoped-permissions.mdx @@ -4,7 +4,7 @@ description: Limit VitNode moderator permissions to selected resources, such as icon: ListChecks --- -A scoped moderator permission applies to every resource of a **scope family**, or only to the resources an administrator picks. "View reports in every section" and "hide topics only in section B" are two grants of scoped permissions. This guide uses the example plugin, whose article reports are scoped by category. +A scoped moderator permission applies to every resource of a **scope family**, or only to the resources an administrator picks. "View reports in every section" and "hide topics only in section B" are two grants of scoped permissions. This guide uses the newsroom plugin, whose story reports are scoped by category. ## Before you begin @@ -18,29 +18,30 @@ Scopes build on [staff permissions](/docs/dev/working-with-users/staff-permissio A scope family tells the AdminCP which resources exist. Create it on the server with `defineModerationScope`: -```ts title="plugins/example/src/api/lib/moderation.ts" +```ts title="plugins/newsroom/src/api/lib/moderation.ts" import { defineModerationScope } from "@vitnode/core/api/moderation"; import { and, asc, gt, ilike, inArray } from "drizzle-orm"; -import { example_categories } from "@/database/categories"; +import { newsroom_categories } from "@/database/categories"; -export const exampleCategoryScope = defineModerationScope({ - id: "example.category", - label: "@vitnode/example.moderation.category.label", - description: "@vitnode/example.moderation.category.desc", +export const categoryModerationScope = defineModerationScope({ + id: "newsroom.category", + label: "newsroom.moderation.category.label", + description: "newsroom.moderation.category.desc", list: async (c, { cursor, limit, search }) => { const rows = await c .get("db") - .select({ id: example_categories.id, name: example_categories.name }) - .from(example_categories) + .select({ id: newsroom_categories.id, name: newsroom_categories.name }) + .from(newsroom_categories) .where( and( - cursor ? gt(example_categories.id, Number(cursor)) : undefined, - search ? ilike(example_categories.name, `%${search}%`) : undefined, + cursor ? gt(newsroom_categories.id, Number(cursor)) : undefined, + search ? ilike(newsroom_categories.name, `%${search}%`) : undefined, ), ) - .orderBy(asc(example_categories.id)) + .orderBy(asc(newsroom_categories.id)) .limit(limit + 1); + const page = rows.slice(0, limit); return { @@ -54,9 +55,9 @@ export const exampleCategoryScope = defineModerationScope({ const rows = await c .get("db") - .select({ id: example_categories.id, name: example_categories.name }) - .from(example_categories) - .where(inArray(example_categories.id, numeric)); + .select({ id: newsroom_categories.id, name: newsroom_categories.name }) + .from(newsroom_categories) + .where(inArray(newsroom_categories.id, numeric)); return rows.map(row => ({ id: String(row.id), label: row.name })); }, @@ -73,21 +74,21 @@ export const exampleCategoryScope = defineModerationScope({ Pass the family to `buildApiPlugin`, and translate its label: -```ts title="plugins/example/src/config.api.ts" +```ts title="plugins/newsroom/src/config.api.ts" buildApiPlugin({ manifest, - moderation: { scopes: [exampleCategoryScope] }, // [!code ++] + moderation: { scopes: [categoryModerationScope] }, // [!code ++] modules: [/* ... */], }); ``` -```json title="plugins/example/src/locales/en.json" +```json title="plugins/newsroom/src/locales/en.json" { - "@vitnode/example": { + "newsroom": { "moderation": { "category": { "label": "Categories", - "desc": "Limit this permission to articles in some categories." + "desc": "Limit this permission to stories in some categories." } } } @@ -104,14 +105,14 @@ Family ids are global. Two plugins registering the same id, or a permission nami On the server-side model, name the base-table column that holds the resource id: -```ts title="plugins/example/src/database/articles.ts" -export const articleContent = createContentModel(articleContentType, { - references: { category: () => example_categories.id }, - moderationScope: { column: "category", family: "example.category" }, // [!code ++] +```ts title="plugins/newsroom/src/database/stories.ts (excerpt)" +export const storyContent = createContentModel(storyContentType, { + references: { category: () => newsroom_categories.id }, + moderationScope: { column: "category", family: "newsroom.category" }, // [!code ++] }); ``` -The report permissions of `example.article` are now scoped by `example.category`. The server reads the column itself, so a client can never claim a record is in another category. The definition in `content/article.ts` stays browser-safe, without callbacks. +The report permissions of `newsroom.story` are now scoped by `newsroom.category`. The server reads the column itself, so a client can never claim a record is in another category. The definition in `content/story.ts` stays browser-safe, without callbacks. </Step> @@ -119,7 +120,7 @@ The report permissions of `example.article` are now scoped by `example.category` ## Check the result -Save the file while `pnpm dev` runs. Open **AdminCP → Staff → Moderators**, edit a moderator and select the example plugin. **View reports** and **Claim, assign, annotate and resolve reports** now show a scope under the checkbox, and **Change** opens the category picker. See [Configure moderators](/docs/dev/moderation/configure-moderators). +Save the file while `pnpm dev` runs. Open **AdminCP → Staff → Moderators**, edit a moderator and select the **Newsroom** plugin. **View reports** and **Claim, assign, annotate and resolve reports** now show a scope under the checkbox, and **Change** opens the category picker. See [Configure moderators](/docs/dev/moderation/configure-moderators). ## Container-scoped permissions @@ -178,12 +179,15 @@ When a plugin later makes an existing permission scoped, grants saved before tha Pass the resource when you check a scoped permission: ```ts +import { checkStaffPermission } from "@vitnode/core/api/lib/check-staff-permission"; + +// inside a route handler, where `c` is the Hono context const allowed = await checkStaffPermission(c, { type: "moderator", - plugin: "@vitnode/example", - module: "article", + plugin: "newsroom", + module: "story", permission: "can_view_reports", - resource: { family: "example.category", id: "2" }, + resource: { family: "newsroom.category", id: "2" }, }); ``` diff --git a/apps/web/content/docs/dev/navigation.mdx b/apps/web/content/docs/dev/navigation.mdx index 33f61f29be..230ff1a23b 100644 --- a/apps/web/content/docs/dev/navigation.mdx +++ b/apps/web/content/docs/dev/navigation.mdx @@ -1,5 +1,5 @@ --- -title: Main Navigation +title: Main navigation description: Define the header menu, its dropdowns and the phone bottom bar in vitnode.config.ts, with translated labels and a lucide icon per link. icon: PanelTop --- @@ -16,23 +16,28 @@ table, no API call and no AdminCP screen behind them, so the menu renders with the page, even on a [prerendered](/docs/dev/deployments/prerendering) one. To change it, edit the file and deploy. +A new app already ships two header links, **Discover** and **Search**, with +their labels in `src/locales/app/en.json`. The steps below group them under a +**Community** dropdown. + <Steps> <Step> ### Add the labels to your messages Menu labels are message keys, so they follow the visitor's language like the -rest of the UI. Put them in your app's locale file, under one namespace such as -`app.menu`. +rest of the UI. They live in your app's locale file, under one namespace such +as `app.menu`. Add a label for the dropdown and a description for one of its +links: ```json title="src/locales/app/en.json" { "app": { - // [!code ++:6] + // ... "menu": { - "community": "Community", + "community": "Community", // [!code ++] "discover": "Discover", - "discover_description": "Fresh posts from every corner of the site.", + "discover_description": "Fresh posts from every corner of the site.", // [!code ++] "search": "Search" } } @@ -45,18 +50,17 @@ Add the same keys to every other language's file. For Polish, that is </Step> <Step> -### Add header links +### Check the header links -Add a `navigation.header` array to the config. Each item needs an `href` and a -`title`, which is the full message key. +The header menu is the `navigation.header` array in the config. Each item needs +an `href` and a `title`, which is the full message key. A new app has these two: ```ts title="src/vitnode.config.ts" import { buildConfig } from '@vitnode/core/vitnode.config' -import { CompassIcon, SearchIcon } from 'lucide-react' // [!code ++] +import { CompassIcon, SearchIcon } from 'lucide-react' export const vitNodeConfig = buildConfig({ // ... - // [!code ++:6] navigation: { header: [ { href: '/discover', icon: CompassIcon, title: 'app.menu.discover' }, @@ -76,7 +80,8 @@ the ones that do not fit move into a **More** dropdown at the end. Give a header item an `items` array to turn it into a dropdown. The parent opens the dropdown on hover and still follows its own `href` when clicked. Each -child can carry a `description` key, shown under its label. +child can carry a `description` key, shown under its label. Replace the two +links with one **Community** item that holds them: ```ts title="src/vitnode.config.ts" import { CompassIcon, MessagesSquareIcon, SearchIcon } from 'lucide-react' @@ -84,7 +89,7 @@ import { CompassIcon, MessagesSquareIcon, SearchIcon } from 'lucide-react' navigation: { header: [ { - href: '/forum', + href: '/discover', icon: MessagesSquareIcon, title: 'app.menu.community', items: [ @@ -107,6 +112,10 @@ navigation: { withoutBackground /> +The screenshot is the vitnode.com header, whose dropdown holds a **Browse** link +from another plugin. Yours shows **Discover** with its description and +**Search** below it. + Dropdowns are one level deep. A child has no `items` of its own, and TypeScript says so. @@ -117,7 +126,7 @@ TypeScript says so. | Name | Type | Required | Default | Description | | :--------------- | :------------------------------ | :------- | :------ | :------------------------------------------------------------ | -| `href` | `string` | Yes | - | A path on this site (`/forum`) or an `https://` address | +| `href` | `string` | Yes | - | A path on this site (`/discover`) or an `https://` address | | `title` | `string` | Yes | - | Message key of the link label, such as `app.menu.discover` | | `description` | `string` | No | - | Message key of the text under the label inside a dropdown | | `icon` | `ComponentType<{ className? }>` | No | - | Drawn before the label, for example a `lucide-react` icon | @@ -182,7 +191,9 @@ Earlier versions stored the menu in the `core_navigation` table and edited it in **AdminCP → System → Navigation**. That screen, its API routes, the `navigation` staff permission and the `navigation.*` events are gone, and a migration drops the table. Copy your items into `vitnode.config.ts` before you -run `vitnode migrate`. +apply the new migrations. The next `dev` applies them in development, and +[`vitnode db migrate`](/docs/dev/cli/database#apply-migrations) does it in +production. Plugins no longer declare menu presets: remove the `navigation` array from `buildApiPlugin()` and the `navigation` keys from the plugin's locale files. diff --git a/apps/web/content/docs/dev/notifications/access.mdx b/apps/web/content/docs/dev/notifications/access.mdx index 58f2a89101..f5513afc1c 100644 --- a/apps/web/content/docs/dev/notifications/access.mdx +++ b/apps/web/content/docs/dev/notifications/access.mdx @@ -1,5 +1,5 @@ --- -title: Limit who sees a notification +title: Limit access description: Hide VitNode notifications from members who cannot see the content, with a batched access check on the notification type and remove() when content is deleted. icon: ShieldCheck --- @@ -14,9 +14,9 @@ This guide continues the `forum.topic_reply` type from [Publish notifications](/ ### Add the access check -Add `access` to the type. It receives a batch of candidate user ids and returns the ones that may see the notification: +Add `access` to the type. It receives a batch of candidate user ids and returns the ones that may see the notification. `forum_topic_members` stands for your plugin's own membership table, with a `topicId` and a `userId` column: -```ts title="plugins/forum/src/api/lib/notifications.ts" +```ts title="plugins/acme-forum/src/api/lib/notifications.ts" import { buildNotificationType } from '@vitnode/core/api/lib/notifications/registry' import { and, eq, inArray } from 'drizzle-orm' import { z } from 'zod' @@ -54,7 +54,7 @@ Answer the whole batch with one query. A batch holds up to 500 ids by default, s When a topic is deleted, take its notifications out of every inbox. Call `remove()` in the route that deletes the topic: -```ts title="plugins/forum/src/api/modules/topics/routes/delete.route.ts" +```ts title="plugins/acme-forum/src/api/modules/topics/routes/delete.route.ts" await c.get('notifications').remove({ subject: { type: 'forum.topic', id: topic.id }, }) @@ -62,7 +62,7 @@ await c.get('notifications').remove({ `remove()` matches the `subject` you passed to `publish()`, lowers the unread counts in the same transaction and returns how many items it deleted. To remove only some members, for example after kicking them from a private topic, add `type` and `userIds`: -```ts title="plugins/forum/src/api/modules/topics/routes/kick.route.ts" +```ts title="plugins/acme-forum/src/api/modules/topics/routes/kick.route.ts" await c.get('notifications').remove({ subject: { type: 'forum.topic', id: topic.id }, type: 'forum.topic_reply', diff --git a/apps/web/content/docs/dev/notifications/admincp.mdx b/apps/web/content/docs/dev/notifications/admincp.mdx index 4f8fb1847c..53e58280c8 100644 --- a/apps/web/content/docs/dev/notifications/admincp.mdx +++ b/apps/web/content/docs/dev/notifications/admincp.mdx @@ -1,5 +1,5 @@ --- -title: Notifications in the AdminCP +title: AdminCP tools description: Manage VitNode notifications in the AdminCP. Check activity, set what members get by default per type, send a test email, and pause, cancel, mark read or delete notifications for everyone. icon: LayoutDashboard --- diff --git a/apps/web/content/docs/dev/notifications/email-and-digests.mdx b/apps/web/content/docs/dev/notifications/email-and-digests.mdx index fb293970b0..d26ff588f1 100644 --- a/apps/web/content/docs/dev/notifications/email-and-digests.mdx +++ b/apps/web/content/docs/dev/notifications/email-and-digests.mdx @@ -18,7 +18,7 @@ Configure an [email adapter](/docs/dev/email). Without one, no type offers email Add `email: true` and pick the default mode for members who never change it: -```ts title="plugins/forum/src/api/lib/notifications.ts" +```ts title="plugins/acme-forum/src/api/lib/notifications.ts" export const topicReplyNotification = buildNotificationType({ id: 'forum.topic_reply', // other options unchanged @@ -37,7 +37,7 @@ export const topicReplyNotification = buildNotificationType({ Pass a function instead of `true` when the email needs its own subject or button label: -```ts title="plugins/forum/src/api/lib/notifications.ts" +```ts title="plugins/acme-forum/src/api/lib/notifications.ts" email: ({ data, t }) => ({ subject: t('@acme/forum.notifications.topic_reply.email_subject', { title: data.topicTitle, diff --git a/apps/web/content/docs/dev/notifications/inbox-and-realtime.mdx b/apps/web/content/docs/dev/notifications/inbox-and-realtime.mdx index 363baaf71a..02f8599183 100644 --- a/apps/web/content/docs/dev/notifications/inbox-and-realtime.mdx +++ b/apps/web/content/docs/dev/notifications/inbox-and-realtime.mdx @@ -44,7 +44,7 @@ The browser receives the same `{ unread, revision }` pair from three places: `unread` is always the absolute count. `revision` increases with every change, and the browser keeps the state with the highest revision, so a slow response never undoes a newer realtime update. Messages are sent only after commit, so a rolled-back change never reaches the browser. -WebSocket messages missed while a tab was offline are not replayed. Instead, the server sends the current count as the first message on every new connection, so a reconnect catches up without an extra request. While the socket is open, the browser never calls `/state`. Without a socket, for example when the API runs inside the web app on the same origin or the connection is down, it reads `/state` every 60 seconds while the tab is visible, and right away when a tab returns after more than 30 seconds in the background. +WebSocket messages missed while a tab was offline are not replayed. Instead, the server sends the current count as the first message on every new connection, so a reconnect catches up without an extra request. While the socket is open, the browser never calls `/state`. Without a socket, for example in a single app without `webSocket: true` or while the connection is down, it reads `/state` every 60 seconds while the tab is visible, and right away when a tab returns after more than 30 seconds in the background. When the count changes, open notification lists refresh once per burst: 1 second after the last change and at most 5 seconds after the first. diff --git a/apps/web/content/docs/dev/notifications/index.mdx b/apps/web/content/docs/dev/notifications/index.mdx index 0f6b253709..dfd76c2b98 100644 --- a/apps/web/content/docs/dev/notifications/index.mdx +++ b/apps/web/content/docs/dev/notifications/index.mdx @@ -23,7 +23,7 @@ Your plugin decides who might care about something. Core decides who actually re | Declares a notification type with `buildNotificationType` | Lists the type on every member's preferences page | | Passes candidate user ids as `recipients` | Drops the actor, deleted users and members who turned the type off | | Answers "who may see this?" in `access` | Calls `access` in batches, again before email and when the inbox loads | -| Returns plain text from `present` | Strips markup, drops unsafe links and translates per recipient | +| Returns plain text from `present` | Shows it as plain text (markup is never rendered), drops unsafe links and translates per recipient | | Sends one `idempotencyKey` per real-world event | Ignores repeats, retries failed work and never delivers twice | ## Notification or toast? diff --git a/apps/web/content/docs/dev/notifications/migration-and-troubleshooting.mdx b/apps/web/content/docs/dev/notifications/migration-and-troubleshooting.mdx index 5b4f73cea4..f6cca1559c 100644 --- a/apps/web/content/docs/dev/notifications/migration-and-troubleshooting.mdx +++ b/apps/web/content/docs/dev/notifications/migration-and-troubleshooting.mdx @@ -1,5 +1,5 @@ --- -title: Move from toasts to notifications +title: From toasts description: Upgrade a VitNode install to stored notifications and replace a plugin's realtime toasts with c.get("notifications").publish(), so members see them after a reload or when they come back. icon: ArrowRightLeft --- diff --git a/apps/web/content/docs/dev/notifications/notification-types.mdx b/apps/web/content/docs/dev/notifications/notification-types.mdx index ae8c94a772..0b886c1bdf 100644 --- a/apps/web/content/docs/dev/notifications/notification-types.mdx +++ b/apps/web/content/docs/dev/notifications/notification-types.mdx @@ -162,7 +162,7 @@ Core calls `access` once per delivery batch, again before each email or digest, ## `grouping` -```ts title="plugins/forum/src/api/lib/notifications.ts" +```ts title="plugins/acme-forum/src/api/lib/notifications.ts" grouping: { windowMinutes: 60, key: ({ data }) => `topic:${data.topicId}`, @@ -171,7 +171,7 @@ grouping: { Notifications with the same type and key, for the same member, in the same window join one inbox item, such as "Alex and 4 others replied to Shipping day". Windows are fixed UTC slots: with 60 minutes, 14:00 to 14:59 is one window. The key defaults to the subject. A notification without a subject, or a `key` that returns `null`, is never grouped. Use `actors`, `actorCount` and `eventCount` in `present` to word a grouped item: -```json title="plugins/forum/src/locales/api/en.json" +```json title="plugins/acme-forum/src/locales/api/en.json" "title": "{others, plural, =0 {{name} replied} one {{name} and # other replied} other {{name} and # others replied}} to {title}" ``` @@ -179,7 +179,7 @@ Notifications with the same type and key, for the same member, in the same windo Stored notifications outlive your code. When `schema` changes shape, increase `version` and make `migrate` upgrade the old data. Here version 1 stored `title`, and version 2 renames it to `topicTitle`: -```ts title="plugins/forum/src/api/lib/notifications.ts" +```ts title="plugins/acme-forum/src/api/lib/notifications.ts" export const topicReplyNotification = buildNotificationType({ id: 'forum.topic_reply', version: 2, diff --git a/apps/web/content/docs/dev/notifications/publishing.mdx b/apps/web/content/docs/dev/notifications/publishing.mdx index 5ec6d91767..7f974249d2 100644 --- a/apps/web/content/docs/dev/notifications/publishing.mdx +++ b/apps/web/content/docs/dev/notifications/publishing.mdx @@ -23,11 +23,13 @@ implementation: href: /docs/dev/notifications/email-and-digests --- -This guide notifies a topic's author when someone replies to their topic. You declare a notification type once, register it on your API plugin, and publish it from the route that saves the reply. The examples use a forum plugin with the id `@acme/forum`. +This guide notifies a topic's author when someone replies to their topic. You declare a notification type once, register it on your API plugin, and publish it from the route that saves the reply. The examples use a fictional forum plugin with the id `@acme/forum` in `plugins/acme-forum`, not the `@vitnode/forum` plugin that ships with VitNode. ## Before you begin -You need a plugin with an API part (`src/config.api.ts`) and a route that writes to the database. See [Create a plugin](/docs/dev/plugins/create) if you do not have one yet. +- A plugin with an API part (`src/config.api.ts`). See [Create a plugin](/docs/dev/plugins/create) if you do not have one yet. +- A route that writes to the database. Here it is a reply route that inserts into the plugin's own `forum_replies` table and has already loaded the `topic` (with its `authorId` and `title`). Swap in your own table and route. +- Two user accounts, so one can act and the other can receive the notification. <Steps> @@ -37,7 +39,7 @@ You need a plugin with an API part (`src/config.api.ts`) and a route that writes Create a file for your plugin's notification types: -```ts title="plugins/forum/src/api/lib/notifications.ts" +```ts title="plugins/acme-forum/src/api/lib/notifications.ts" import { buildNotificationType } from '@vitnode/core/api/lib/notifications/registry' import { z } from 'zod' @@ -67,7 +69,7 @@ export const topicReplyNotification = buildNotificationType({ The API renders notification text, so the strings belong in your plugin's API messages: -```json title="plugins/forum/src/locales/api/en.json" +```json title="plugins/acme-forum/src/locales/api/en.json" { "@acme/forum": { "notifications": { @@ -90,7 +92,7 @@ The API renders notification text, so the strings belong in your plugin's API me Pass the type and the API messages to `buildApiPlugin()`: -```ts title="plugins/forum/src/config.api.ts" +```ts title="plugins/acme-forum/src/config.api.ts" import { buildApiPlugin } from '@vitnode/core/api/lib/plugin' import { topicReplyNotification } from '@/api/lib/notifications' // [!code ++] @@ -117,7 +119,11 @@ Core collects the types of every installed plugin when the API starts, so restar In the handler that saves the reply, publish inside the same transaction: -```ts title="plugins/forum/src/api/modules/topics/routes/reply.route.ts" +```ts title="plugins/acme-forum/src/api/modules/topics/routes/reply.route.ts (excerpt)" +import { topicReplyNotification } from '@/api/lib/notifications' // [!code ++] +import { forum_replies } from '@/database/replies' + +// inside the route handler await c.get('db').transaction(async (tx) => { const [reply] = await tx .insert(forum_replies) @@ -136,7 +142,7 @@ await c.get('db').transaction(async (tx) => { }) ``` -`topic`, `user` and `content` come from your existing route. Because of `tx`, the notification is saved together with the reply: a rolled-back reply notifies nobody, and a saved reply always notifies, even if the server stops right after the commit. `subject` names what the notification is about. Core uses it to group notifications and to [remove them later](/docs/dev/notifications/access#remove-notifications-when-content-goes-away). +`forum_replies` is your plugin's replies table, and `topic`, `user` and `content` come from your existing route. Because of `tx`, the notification is saved together with the reply: a rolled-back reply notifies nobody, and a saved reply always notifies, even if the server stops right after the commit. `subject` names what the notification is about. Core uses it to group notifications and to [remove them later](/docs/dev/notifications/access#remove-notifications-when-content-goes-away). </Step> @@ -169,7 +175,7 @@ Use a random key only when every call really is a new message, such as an admini To notify the actor about their own work, set `allowSelf: true`: -```ts title="plugins/forum/src/api/modules/exports/routes/finish.route.ts" +```ts title="plugins/acme-forum/src/api/modules/exports/routes/finish.route.ts" await c.get('notifications').publish({ type: exportReadyNotification, recipients: [user.id], diff --git a/apps/web/content/docs/dev/notifications/queue-and-scale.mdx b/apps/web/content/docs/dev/notifications/queue-and-scale.mdx index f5ac359463..d917492158 100644 --- a/apps/web/content/docs/dev/notifications/queue-and-scale.mdx +++ b/apps/web/content/docs/dev/notifications/queue-and-scale.mdx @@ -29,13 +29,11 @@ The fan-out task walks the recipients in batches of `fanoutBatchSize`, 500 by de - **Large audiences do not block the queue.** One task stops after 20 seconds and queues a new task for the rest. - **No deadlocks between writers.** Every inbox write locks the affected members in user id order, so concurrent writers wait for each other instead. -The integration test `fanout.load.integration.test.ts` delivers one notification to 1,000 members with batches of 200, with two workers racing on the same event. It checks that every member gets exactly one item, that `access` runs about once per batch and that the number of queries stays below the number of recipients. - ## Recovery | Stuck work | Recovered by | | ---------------------------------------------- | --------------------------------------------------------------------------- | -| Queue task left processing by a stopped worker | The queue worker, after 15 minutes | +| Queue task left processing by a stopped worker | The queue worker, after 10 minutes (the task's lease, 600 s by default) | | Notification unfinished with no task left | `notifications-schedule`, after 10 minutes, by queuing a new task | | Email left in `sending` | `notifications-schedule`, after 15 minutes, by setting it back to `pending` | | Two workers on one notification | A row lock. The second waits, then continues from the saved progress | diff --git a/apps/web/content/docs/dev/notifications/reference.mdx b/apps/web/content/docs/dev/notifications/reference.mdx index cd60b24a08..87e993bca7 100644 --- a/apps/web/content/docs/dev/notifications/reference.mdx +++ b/apps/web/content/docs/dev/notifications/reference.mdx @@ -1,5 +1,5 @@ --- -title: Notifications API reference +title: API reference description: Reference for c.get("notifications") in VitNode (publish, remove and state), its errors, the member and AdminCP REST endpoints, and the realtime channel. icon: BookOpen --- @@ -91,7 +91,7 @@ Without `type` and `userIds`, `remove()` deletes every item about the subject fo ### `state` -```ts title="plugins/forum/src/api/modules/topics/routes/summary.route.ts" +```ts title="plugins/acme-forum/src/api/modules/topics/routes/summary.route.ts" const { unread, revision } = await c.get('notifications').state(user.id) ``` @@ -166,10 +166,13 @@ The body of `PUT /types/{type}` accepts any of these keys: "allowPush": true, "allowEmail": true, "email": "daily", - "memberCanEdit": false + "memberCanEdit": false, + "enabled": true } ``` +`enabled: false` turns the type off for everyone, unless it is mandatory. + `POST /reconcile` only reports by default: `dryRun` is `true` unless you send `false`. It returns `{ mismatched, corrected }`. ## Realtime channel diff --git a/apps/web/content/docs/dev/notifications/troubleshooting.mdx b/apps/web/content/docs/dev/notifications/troubleshooting.mdx index ae7200a61c..0c093d3a2d 100644 --- a/apps/web/content/docs/dev/notifications/troubleshooting.mdx +++ b/apps/web/content/docs/dev/notifications/troubleshooting.mdx @@ -1,5 +1,5 @@ --- -title: Troubleshooting notifications +title: Troubleshooting description: Fix common VitNode notification problems. A bell that does not update live, notifications that arrive late or never, missing emails, a wrong unread count and items that are no longer available. icon: Wrench --- @@ -15,7 +15,7 @@ Each section below starts from a symptom and lists the checks in the order that A publishing request starts delivery right after its response. Everything else is delivered by the queue worker, which your cron adapter runs every minute. -1. **The cron adapter is not calling the queue worker.** In development there is no `CRON_SECRET`, so queued tasks wait until you run them. See [Cron](/docs/dev/cron). +1. **Nothing runs the queue worker.** No cron adapter or external scheduler is configured, or every tick is refused. In production an unset or placeholder `CRON_SECRET` gets `403`, and the server console shows `[VitNode] Cron tick failed: 403 ...`. **Debug Panel → Cron & queue** (`/admin/core/debug/cron`) shows a red **Scheduler stopped** card once jobs are 15 minutes overdue. See [Cron](/docs/dev/cron#choose-what-ticks-the-clock). 2. **Notifications are paused.** The AdminCP notifications screen shows a **Notifications are paused** banner. See [Use the Settings sheet](/docs/dev/notifications/admincp#use-the-settings-sheet). 3. **The delivery task failed.** **Debug Panel → Queue** in the AdminCP (`/admin/core/debug/queue`) lists failed `notifications-fanout` tasks with their last error. Filter by the **Failed** status to find them. 4. **The member never gets it.** The actor is skipped unless you pass `allowSelf: true`, and members who turned the type off or fail your `access` check get nothing. See [Choose the recipients](/docs/dev/notifications/publishing#choose-the-recipients). diff --git a/apps/web/content/docs/dev/passkeys/admincp.mdx b/apps/web/content/docs/dev/passkeys/admincp.mdx index 13f1c63570..43e544ecbd 100644 --- a/apps/web/content/docs/dev/passkeys/admincp.mdx +++ b/apps/web/content/docs/dev/passkeys/admincp.mdx @@ -1,5 +1,5 @@ --- -title: AdminCP passkey sign-in +title: AdminCP sign-in description: Let staff sign in to the VitNode AdminCP with a user-verified passkey. How enrollment, sign-in and recovery work, why user verification is required, and how AdminCP sessions stay separate from public ones. icon: ShieldCheck --- diff --git a/apps/web/content/docs/dev/passkeys/index.mdx b/apps/web/content/docs/dev/passkeys/index.mdx index 547d133c37..74f9aae1bc 100644 --- a/apps/web/content/docs/dev/passkeys/index.mdx +++ b/apps/web/content/docs/dev/passkeys/index.mdx @@ -1,5 +1,5 @@ --- -title: Passkeys (WebAuthn) +title: Passkeys description: Let members sign in with Face ID, Touch ID, Windows Hello or a security key. Configure the RP ID and origins, run the migration, and learn how the WebAuthn ceremonies work in VitNode. icon: FingerprintPattern --- @@ -137,17 +137,16 @@ Every ceremony is two requests: **options** (the server issues a challenge) and | `PATCH` | `/{id}` | the passkey's owner | Renames it | | `DELETE` | `/{id}` | the passkey's owner | Removes it - refused with `409 last_recovery_method` if it's the account's last way in | -From the frontend, call them with the typed fetcher - `module: "users/passkeys"` - like any other core route: +From the frontend, call them with the universal [fetcher](/docs/dev/fetcher) and `module: "users/passkeys"`, like any other core route: ```ts -import { fetcherClient } from "@vitnode/core/lib/fetcher-client" +import { fetcher } from "@vitnode/core/tanstack/fetcher" -const res = await fetcherClient({ +const res = await fetcher({ plugin: "@vitnode/core", method: "get", module: "users/passkeys", path: "/", - options: { credentials: "include" }, }) ``` diff --git a/apps/web/content/docs/dev/performance.mdx b/apps/web/content/docs/dev/performance.mdx index 36751ff8a7..9da2904dfe 100644 --- a/apps/web/content/docs/dev/performance.mdx +++ b/apps/web/content/docs/dev/performance.mdx @@ -6,11 +6,11 @@ icon: Gauge import { Tab, Tabs } from 'fumadocs-ui/components/tabs' -TanStack Start splits components into separate lazy chunks by default, keeping the initial entry bundle light. Follow these key practices to maintain instant page loads. +This page collects the practices that keep a VitNode app's JavaScript and CSS small and its first paint early: what lands in the initial bundle, what to lazy-load, and how to measure the result. -## Performance Checklist +## Checklist -1. **Keep `head` metadata isolated**: Never import UI components into route `head`. +1. **Keep `head` metadata isolated**: in the app's file routes, never import UI components into `head`. 2. **Lazy-load heavy dialogs**: Use `React.lazy` + `Suspense` for complex editors and dialogs. 3. **Dynamic imports inside loaders**: Lazy-load heavy server/client utilities with `await import(...)`. 4. **Optimize images**: Use `loading="lazy"`, responsive widths, and explicit dimensions. @@ -18,9 +18,9 @@ TanStack Start splits components into separate lazy chunks by default, keeping t --- -## What Lands in the Client Entry Bundle +## What lands in the client entry bundle -TanStack Router automatically extracts route `component`, `errorComponent`, and `notFoundComponent` into lazy chunks: +This table is about your app's own file routes (`createFileRoute` in `src/routes`). TanStack Router splits a route's `component`, `errorComponent` and `notFoundComponent` into lazy chunks, and keeps the rest in the entry bundle: | Route Option | Read When | In Initial Bundle? | Optimization | | :----------------- | :-------------------- | :------------------ | :---------------------------------------------------------------------- | @@ -30,38 +30,38 @@ TanStack Router automatically extracts route `component`, `errorComponent`, and | `pendingComponent` | Route loading | **Yes** | Plain skeleton markup only - never a layout component from a UI library | | `component` | Render | **No (Lazy Chunk)** | Automatically code-split | +A plugin route is lighter by design. `routes.ts` holds only the declaration, and `load`, `head`, `breadcrumb` and the component all live in the page module that `lazy()` loads on demand. Only a plugin route's `pendingComponent` is in the entry bundle. See [Routing](/docs/dev/routing#how-a-plugin-route-works). + --- -## Best Practices +## Practices -### 1. Isolate Metadata from Components +### 1. Isolate metadata in file routes -Because `head` is evaluated in the main route bundle, importing strings from component files accidentally pulls entire component trees into the initial download: +An app file route's `head` is part of the entry bundle, so whatever its file imports at the top level ships to every visitor. Import the strings `head` needs from a small leaf module, not from the component file: -```tsx title="plugins/home/src/pages/home-page.tsx" -import { definePluginRoute } from '@vitnode/core/routing' +```tsx title="apps/web/src/routes/_main/pricing.tsx" +import { createFileRoute } from '@tanstack/react-router' +import { pageHead } from '@vitnode/core/tanstack/metadata' -// BAD: Pulls HomeRouteContent and all its heavy icons/charts into main entry -import { HOME_TITLE, HomeRouteContent } from '../views/home-content' // [!code --] +// BAD: pulls PricingPage and everything it imports into the entry bundle +import { PRICING_TITLE, PricingPage } from '@/site/pricing/pricing-page' // [!code --] -// GOOD: Metadata strings live in a lightweight leaf file -import { HomeRouteContent } from '../views/home-content' // [!code ++] -import { HOME_DESCRIPTION, HOME_TITLE } from '../views/metadata' // [!code ++] +// GOOD: the strings live in a leaf module with no UI imports +import { PricingPage } from '@/site/pricing/pricing-page' // [!code ++] +import { PRICING_DESCRIPTION, PRICING_TITLE } from '@/site/pricing/metadata' // [!code ++] -export const route = definePluginRoute({ - head: () => - pageHead({ - title: HOME_TITLE, - description: HOME_DESCRIPTION, - }), +export const Route = createFileRoute('/_main/pricing')({ + head: () => pageHead({ title: PRICING_TITLE, description: PRICING_DESCRIPTION }), + component: PricingPage, }) - -export default HomeRouteContent ``` -### 2. Lazy Dialogs and Heavy Form Editors +A plugin route needs none of this. Its `head` lives in the lazy page module and returns plain fields, `{ title, description, robots, alternates }`. See [Route module](/docs/dev/routing/route-module#head-arguments-and-result). + +### 2. Lazy dialogs and heavy form editors -Heavy editors (like Tiptap) or complex modals should be lazy-loaded with `React.lazy` and `Suspense`: +Lazy-load heavy editors (like Tiptap) and complex modals with `React.lazy` and `Suspense`. This example lazy-loads the AutoForm editor field, so a form downloads the editor only when it renders: ```tsx title="plugins/blog/src/views/admin/article-editor.tsx" import React, { Suspense } from 'react' @@ -74,21 +74,21 @@ const RichEditor = React.lazy(async () => })), ) -export const ArticleEditor = (props) => ( +export const ArticleEditor = (props: React.ComponentProps<typeof RichEditor>) => ( <Suspense fallback={<Spinner size="xl" />}> <RichEditor {...props} /> </Suspense> ) ``` -### 3. Dynamic Imports Inside Loaders +### 3. Dynamic imports inside loaders When a route loader requires a heavy calculation or parsing library, import it dynamically: ```tsx title="plugins/stats/src/pages/stats-page.tsx" -import { definePluginRoute } from '@vitnode/core/routing' +import { defineRoute } from '@vitnode/core/tanstack/plugin-routes' -export const route = definePluginRoute({ +export const route = defineRoute({ load: async () => { // Only downloaded when visitor navigates to /stats const { calculateStats } = await import('../features/stats/calculator') // [!code ++] @@ -97,7 +97,7 @@ export const route = definePluginRoute({ }) ``` -### 4. Lazy Images Below the Fold +### 4. Lazy images below the fold Always specify explicit aspect ratios and `loading="lazy"` on images outside the initial viewport, and ship WebP with a `srcSet` so a phone never downloads the desktop rendition: @@ -117,7 +117,7 @@ Always specify explicit aspect ratios and `loading="lazy"` on images outside the The one image that is the page's largest element on arrival - a hero screenshot - is the exception: give it `loading="eager"` and `fetchPriority="high"` so the browser asks for it first. -### 5. Skip Rendering Work for Offscreen Sections +### 5. Skip rendering work for offscreen sections A long marketing page can carry a hundred CSS animations, most of them below the fold. Give each section `content-visibility: auto` with a `contain-intrinsic-size` estimate and the browser skips style, layout and animation work for everything not near the viewport: @@ -131,11 +131,11 @@ A long marketing page can carry a hundred CSS animations, most of them below the The home page went from 133 running animations to 42 on arrival with this one rule, and the pages still scroll and deep-link normally. -### 6. Keep the Plugin Factory Light +### 6. Keep the plugin factory light -`vitnode.config.ts` is bundled with the document shell, so everything a plugin factory imports ships to every visitor. A factory that spreads `admin/content` or `admin/nav` drags the AdminCP editing stack - AutoForm, schemas, dialogs - into the public bundle. Keep it to `pluginId`, `messages` and `routes`; see [Plugin frontend modules](/docs/content-engine/plugin-files). +`vitnode.config.ts` is bundled with the document shell, so everything a plugin factory imports ships to every visitor. A factory that spreads `admin/content` or `admin/nav` drags the whole AdminCP editing stack into the public bundle, AutoForm, schemas and dialogs included. Keep it to `manifest`, `localeFiles`, `messages` and `routes`; see [Plugin frontend modules](/docs/content-engine/plugin-files). -### 7. Route-Scoped Stylesheets +### 7. Route-scoped stylesheets Tailwind emits one utility per class it finds in the sources you list. A documentation UI kit scanned globally pays its CSS on the home page too. Give it a stylesheet of its own and load it from the layout route that renders it: @@ -174,7 +174,7 @@ export const Route = createFileRoute('/_main')({ }) ``` -### 8. Group Shared Vendor Modules +### 8. Group shared vendor modules Rolldown splits every module shared by two routes into its own file, which for an icon library means dozens of 300-byte requests per page. Group the libraries that are shared by design into one chunk each: @@ -194,7 +194,7 @@ build: { Group only libraries whose shared surface is small. A UI primitive library shared by every AdminCP screen turns into a single 280 KB chunk that public pages then download for a tooltip. -### 9. Load JavaScript After the First Paint +### 9. Load JavaScript after the first paint TanStack's `HeadContent` puts a `modulepreload` link for every chunk the page needs into `<head>`, ahead of the stylesheet. On a slow phone those downloads compete with the CSS the browser has to wait for, so the first paint arrives late. Render the document with `DocumentHead` and `DocumentScripts` from `@vitnode/core/tanstack/layout` instead: @@ -222,15 +222,15 @@ import { </html> ``` -In a production build the HTML and CSS load first, and a small inline script adds the preloads and the app entry right after the browser paints. In a hidden tab it adds them immediately. Hydration starts a frame or two later than before. During `vite dev` both components render the stock `HeadContent` and `Scripts`. New apps from `create-vitnode-app` already use them. +In a production build the HTML and CSS load first, and a small inline script adds the preloads and the app entry right after the browser paints. In a hidden tab it adds them immediately. Hydration starts a frame or two later than before. During `vitnode dev` both components render the stock `HeadContent` and `Scripts`. New apps from `create-vitnode-app` already use them. --- -## Measuring Bundle Size +## Measure the bundle -Analyze your client bundle with Vite: +Build the app. `vitnode build` prints a bundle size report with every client file, its gzip size, warnings for oversized chunks and a comparison with your previous build: -<Tabs groupId="package-manager" persist items={["bun", "pnpm", "npm"]} label="Build and analyze"> +<Tabs groupId="package-manager" persist items={["bun", "pnpm", "npm"]} label="Build the app"> ```bash tab="bun" bun run build @@ -246,19 +246,19 @@ npm run build </Tabs> -Inspect the output chunk sizes in `apps/web/.output/public/assets/`, then load a page in the browser's network panel and check what the document shell actually requests - a chunk the manifest never preloads can still arrive through a static import. Keep any single lazy chunk below **150 KB** for optimal mobile performance. +[Bundle size report](/docs/dev/cli/bundle-size) explains each part of the report, its size thresholds and `--analyze`. Then load a page with the browser's network panel open and check what the document shell actually requests, since a chunk the manifest never preloads can still arrive through a static import. -## Learn More +## Related <Cards> <Card - title="Data Fetching" - description="Warming TanStack Query and cache management" + title="Fetcher" + description="Call API routes and warm TanStack Query in loaders" href="/docs/dev/fetcher" /> <Card - title="Loading States" - description="Pre-built skeletons and instant navigation UI" + title="Loading states" + description="Skeletons, the progress bar and button spinners" href="/docs/dev/routing/loading" /> </Cards> diff --git a/apps/web/content/docs/dev/plugins/admin/admin-notes-page.webp b/apps/web/content/docs/dev/plugins/admin/admin-notes-page.webp new file mode 100644 index 0000000000..ba25e8260d Binary files /dev/null and b/apps/web/content/docs/dev/plugins/admin/admin-notes-page.webp differ diff --git a/apps/web/content/docs/dev/plugins/admin/dashboard-notes-widget.webp b/apps/web/content/docs/dev/plugins/admin/dashboard-notes-widget.webp new file mode 100644 index 0000000000..eb25e3ce81 Binary files /dev/null and b/apps/web/content/docs/dev/plugins/admin/dashboard-notes-widget.webp differ diff --git a/apps/web/content/docs/dev/plugins/admin/dashboard-widgets.mdx b/apps/web/content/docs/dev/plugins/admin/dashboard-widgets.mdx index 68d5946f94..1ae7a03099 100644 --- a/apps/web/content/docs/dev/plugins/admin/dashboard-widgets.mdx +++ b/apps/web/content/docs/dev/plugins/admin/dashboard-widgets.mdx @@ -1,92 +1,195 @@ --- -title: Dashboard Widgets -description: Add a configurable AdminCP dashboard widget from a VitNode plugin with sizing, permissions, and optional settings. +title: Dashboard widgets +description: Add a configurable AdminCP dashboard widget to a VitNode plugin, with real data, a settings form, translated strings, sizing and an optional staff permission. icon: LayoutDashboard --- -import { BarChart3Icon, Settings2Icon } from 'lucide-react' +import { ImgDocs } from "@/components/fumadocs/img"; +import notesWidget from "./dashboard-notes-widget.webp"; -Dashboard widgets are **AdminCP plugin extensions**. Define the component and -list it on the plugin's `adminNav` export, next to its sidebar entries; the -AdminCP dashboard then discovers it automatically. +import { KeyRoundIcon, Settings2Icon } from 'lucide-react' -{/* Image prompt: VitNode AdminCP dashboard with a “Site notes” statistics widget in a draggable grid. Show the widget panel, the corner resize bracket on each card, and the settings gear. Dark theme, 1600x900. */} +A dashboard widget is a card a plugin adds to the AdminCP dashboard. Each +administrator decides which widgets to show, where, and how big. In this guide +the `site-notes` plugin gets a **Notes** widget that shows how many notes exist +and the newest one, with a setting to hide the newest note. + + +## Before you begin + +- The `site-notes` AdminCP page from [AdminCP page](/docs/dev/plugins/admin). This guide reuses its `src/pages/notes-query.ts`, its `@tanstack/react-query` dependency and its `src/admin/nav.tsx`. <Steps> <Step> ### Build the widget component -```tsx title="plugins/site-notes/src/views/admin/widgets/stats-widget.tsx" +Create `src/admin/notes-widget.tsx`. It reads the same notes query as the +AdminCP page, so the count is real and refreshes with the list: + +```tsx title="plugins/site-notes/src/admin/notes-widget.tsx" import type { AdminDashboardWidgetProps } from '@vitnode/core/lib/plugin' -export const StatsWidget = ({ settings }: AdminDashboardWidgetProps) => ( - <div className="flex flex-col gap-1 p-2"> - <span className="text-muted-foreground text-sm">Published notes</span> - <strong className="text-3xl tabular-nums"> - {String(settings.total ?? 42)} - </strong> - </div> -) +import { useQuery } from '@tanstack/react-query' +import { Skeleton } from '@vitnode/core/components/ui/skeleton' +import { useTranslations } from 'use-intl' + +import { notesQueryOptions } from '@/pages/notes-query' + +export const NotesWidget = ({ settings }: AdminDashboardWidgetProps) => { + const t = useTranslations('site-notes.admin.dashboard.widgets.notes') + const { data: notes, isError, isPending } = useQuery(notesQueryOptions()) + const showLatest = settings.showLatest !== false + + if (isPending) return <Skeleton className="m-2 h-9 w-24" /> + if (isError) return <p className="text-destructive p-2 text-sm">{t('load_error')}</p> + + return ( + <div className="flex flex-col gap-1 p-2"> + <strong className="text-3xl tabular-nums"> + {t('count', { count: notes.length })} + </strong> + {showLatest && notes[0] ? ( + <p className="text-muted-foreground text-sm leading-relaxed text-pretty"> + {t('latest', { title: notes[0].title })} + </p> + ) : null} + </div> + ) +} +``` + +`settings` holds what an administrator saved for this copy of the widget, and +is `{}` until they save anything, so the widget treats a missing `showLatest` +as on. + + </Step> + <Step> + +### Add a settings form + +Create `src/admin/notes-widget-settings.tsx`. The dashboard renders it in the +widget's panel while an administrator edits the layout. +`useWidgetSettingsDialog()` gives it `save`, which stores the settings, shows a +toast and refreshes the widget: + +```tsx title="plugins/site-notes/src/admin/notes-widget-settings.tsx" +import type { AdminDashboardWidgetProps } from '@vitnode/core/lib/plugin' + +import { AutoForm } from '@vitnode/core/components/form/auto-form' +import { AutoFormSwitch } from '@vitnode/core/components/form/fields/switch' +import { useWidgetSettingsDialog } from '@vitnode/core/views/admin/views/core/dashboard/grid/widget-settings-context' +import { useTranslations } from 'use-intl' +import { z } from 'zod' + +export const NotesWidgetSettings = ({ settings }: AdminDashboardWidgetProps) => { + const t = useTranslations('site-notes.admin.dashboard.widgets.notes') + const { save } = useWidgetSettingsDialog() + + const formSchema = z.object({ + showLatest: z.boolean().default(settings.showLatest !== false), + }) + + return ( + <AutoForm + fields={[ + { + id: 'showLatest', + component: props => ( + <AutoFormSwitch {...props} label={t('settings.show_latest')} /> + ), + }, + ]} + formSchema={formSchema} + onSubmit={async values => { + await save(values) + }} + submitButtonProps={{ children: t('settings.save') }} + /> + ) +} ``` +Skip this step, and `settingsComponent` in the next one, if your widget has +nothing to configure. + </Step> <Step> ### Register it on the plugin's `adminNav` -Add it to the `adminNav` your plugin exports from its `admin/nav` module (the -same object that holds its [sidebar entries](/docs/dev/plugins/admin)). VitNode -collects every plugin's `adminNav` into the app's generated `admin-nav.gen.ts`, -and the AdminCP shell hands the widgets to the dashboard. +Add a `dashboard.widgets` list to the `adminNav` in `src/admin/nav.tsx`, next to +the sidebar `nav` from the previous lesson. VitNode collects every plugin's +`adminNav` into the app's generated `admin-nav.gen.ts`, and the AdminCP shell +hands the widgets to the dashboard. ```tsx title="plugins/site-notes/src/admin/nav.tsx" import type { AdminNavPluginSource } from '@vitnode/core/lib/plugin' -import { BarChart3Icon } from 'lucide-react' +import { StickyNoteIcon } from 'lucide-react' +import { NotesWidget } from '@/admin/notes-widget' // [!code ++] +import { NotesWidgetSettings } from '@/admin/notes-widget-settings' // [!code ++] import { manifest } from '@/manifest.gen' -import { StatsWidget } from '@/views/admin/widgets/stats-widget' export const adminNav = { pluginId: manifest.id, admin: { + // [!code ++:13] dashboard: { widgets: [ - // [!code ++:8] { - component: StatsWidget, + id: 'notes', + component: NotesWidget, + settingsComponent: NotesWidgetSettings, + icon: <StickyNoteIcon />, defaultEnabled: true, - defaultRows: 1, defaultSpan: 1, - icon: <BarChart3Icon />, - id: 'stats', + defaultRows: 1, }, ], }, + nav: [ + // ...the Notes link from the previous lesson + ], }, } satisfies AdminNavPluginSource ``` -The full widget id is namespaced by the plugin, so `stats` will not collide with -another plugin’s idea of a stats card. A good thing—statistics are dramatic -enough already. +The board namespaces the id as `site-notes:notes`, so `notes` never collides +with another plugin's idea of a notes card. With `defaultEnabled`, the dashboard +puts the widget at the end of the board for every administrator who has not +seen it yet. Anyone who removes it keeps it removed. </Step> <Step> -### Name it +### Add the strings -The widget's title comes from your plugin's frontend messages, under -`admin.dashboard.widgets.{id}`. A `desc` is optional and shows in the widget panel. +The dashboard loads the `site-notes.admin.dashboard.widgets` namespace. The +widget's title is `notes.title` and its optional `desc` shows in the widget +panel; the other keys are the ones the two components use. Add them under +`site-notes.admin` in `src/locales/en.json`, next to `nav` and `notes`: ```json title="plugins/site-notes/src/locales/en.json" { - "@acme/site-notes": { + "site-notes": { "admin": { + // ...nav and notes from the previous lesson + // [!code ++:14] "dashboard": { "widgets": { - "stats": { "title": "Note stats", "desc": "How many notes are live." } + "notes": { + "title": "Notes", + "desc": "How many notes exist, and the newest one.", + "count": "{count, plural, one {# note} other {# notes}}", + "latest": "Latest: {title}", + "load_error": "The notes could not be loaded.", + "settings": { + "show_latest": "Show the newest note", + "save": "Save settings" + } + } } } } @@ -95,37 +198,68 @@ The widget's title comes from your plugin's frontend messages, under ``` </Step> - <Step> +</Steps> -### Add a permission or settings UI when needed +## Check the result -Add `permission` to hide a widget from staff who should not see it. Add -`settingsComponent` when administrators need to save preferences; both stay in -the plugin alongside the widget. +Let the plugin rebuild (or run its `build:plugins` script), then reload the +AdminCP dashboard at `/admin/core`. + +1. A **Notes** card sits at the end of the board, showing the number of notes + and "Latest:" with the newest title. With no notes yet it reads `0 notes`. + +<ImgDocs + withoutBackground + className="*:max-w-full" + src={notesWidget} + alt="The site-notes Notes widget at the end of the AdminCP dashboard, reading 6 notes and Latest: Lift maintenance, next to core's Send a notification widget" +/> + +2. Press **Edit layout**, click the card, switch off **Show the newest note** and + choose **Save settings**. A toast confirms the save and the card shows only + the count. +3. Create a note on the plugin's AdminCP page, then come back. The count has + gone up by one. + +## Show a widget only to some staff + +`permission` hides a widget from administrators without that staff permission: ```tsx title="plugins/site-notes/src/admin/nav.tsx" { - component: StatsWidget, - // [!code ++:4] - permission: { module: 'site_notes', permission: 'can_view_stats' }, - settingsComponent: StatsSettings, - id: 'stats', + id: 'notes', + component: NotesWidget, + permission: { module: 'notes', permission: 'can_create' }, // [!code ++] + // ... } ``` - </Step> -</Steps> +- **It only hides the card.** The data still comes from the API, so a widget + that shows private data must read it from a route guarded with + `adminStaffPermission`, as the create endpoint in + [Permissions](/docs/dev/working-with-users/staff-permissions) is. The notes + list is public, so the Notes widget needs no guard. +- **Declare the permission** in `buildApiPlugin({ permissionStaff })`, as + `site-notes` already does for `notes.can_create`. An undeclared permission + cannot be granted in the staff editor, so only root administrators would see + the widget. `vitnode plugin validate` checks this for sidebar links, not for + widgets. ## Widget options -| Option | What it controls | -| ----------------------------- | ------------------------------------------------- | -| `defaultSpan` / `defaultRows` | Initial grid size from 1 to 3 columns or rows. | -| `minSpan` | Narrowest allowed width. | -| `defaultEnabled` | Whether a new dashboard receives the widget. | -| `allowMultiple` | Whether an admin may place more than one copy. | -| `permission` | Staff permission required to see the widget. | -| `settingsComponent` | Plugin form shown in the widget's panel while the dashboard is being edited. | +| Name | Type | Required | Default | Description | +| :------------------ | :-------------------------------------------------- | :------- | :------------- | :----------------------------------------------------------------------------------------------------------------------------------- | +| `id` | `string` | Yes | - | Unique within the plugin; the board uses `{pluginId}:{id}`. Also names the strings under `admin.dashboard.widgets.{id}` | +| `component` | `ComponentType<AdminDashboardWidgetProps>` | Yes | - | Renders the widget's body. Gets `settings` and `widgetId` (the id of this copy on the board) | +| `icon` | `ReactNode` | No | - | Shown in the widget panel | +| `defaultSpan` | `1 \| 2 \| 3` | No | `minSpan` | Width in grid columns when first placed | +| `defaultRows` | `1 \| 2 \| 3` | No | `1` | Height in grid rows when first placed | +| `minSpan` | `1 \| 2 \| 3` | No | `1` | The narrowest an administrator can resize it to | +| `defaultEnabled` | `boolean` | No | `false` | Placed automatically for administrators who have never seen it | +| `allowMultiple` | `boolean` | No | `false` | Lets an administrator place more than one copy, each with its own settings | +| `category` | `string` | No | the plugin | Groups the widget in the panel, titled by `admin.dashboard.widgets.categories.{category}` (the raw value when that key is missing) | +| `permission` | `{ module, permission }` | No | - | Hides the widget from administrators without this staff permission of the declaring plugin | +| `settingsComponent` | `ComponentType<AdminDashboardWidgetProps>` | No | - | A form for this copy's `settings`, shown while an administrator edits the dashboard. Saves with `useWidgetSettingsDialog().save` | ## How admins arrange them @@ -145,15 +279,17 @@ New widgets fade in and scroll into view, and screen readers hear where a widget The drop gap uses `defaultSpan` and `defaultRows`, so pick them to match what the widget looks like the moment it is added. +## Related + <Cards> <Card icon={<Settings2Icon />} - title="AdminCP pages" - description="Add plugin-owned routes and browser-safe sidebar navigation." + title="AdminCP page" + description="Add a plugin-owned AdminCP screen and its sidebar link." href="/docs/dev/plugins/admin" /> <Card - icon={<BarChart3Icon />} + icon={<KeyRoundIcon />} title="Staff permissions" description="Define permissions before exposing staff-only capabilities." href="/docs/dev/working-with-users/staff-permissions" diff --git a/apps/web/content/docs/dev/plugins/admin/index.mdx b/apps/web/content/docs/dev/plugins/admin/index.mdx index 19cea48428..fa060b0a6f 100644 --- a/apps/web/content/docs/dev/plugins/admin/index.mdx +++ b/apps/web/content/docs/dev/plugins/admin/index.mdx @@ -1,131 +1,342 @@ --- -title: AdminCP Pages -description: Add plugin-owned AdminCP pages, navigation, permissions, and dashboard extensions to the VitNode admin panel. -icon: PanelsTopLeft +title: AdminCP page +description: Add an AdminCP page to the site-notes VitNode plugin that lists notes, creates them through a permission-guarded endpoint, refreshes the list and shows a toast, with a sidebar link only permitted staff see. +icon: LayoutDashboard --- -import { LayoutDashboardIcon, RouteIcon, ShieldCheckIcon } from 'lucide-react' -import { Tab, Tabs } from 'fumadocs-ui/components/tabs' +import { Tab, Tabs } from "fumadocs-ui/components/tabs"; -AdminCP is a plugin surface. Give the plugin an `area: 'admin'` route and a -browser-safe navigation declaration. The host supplies the panel shell; your -plugin supplies the useful bit. +import { ImgDocs } from "@/components/fumadocs/img"; +import adminPage from "./admin-notes-page.webp"; + +A plugin can add its own screens to the AdminCP, inside its sidebar, breadcrumb and session guard. In this lesson you give staff a **Notes** page that lists the notes and creates new ones through the guarded endpoint from the previous lesson. + +## Before you begin + +- The `POST /api/site-notes/admin/notes` endpoint and the **Create notes** permission from [Permissions](/docs/dev/working-with-users/staff-permissions), plus the restricted second administrator you created there. <Steps> - <Step> -### Claim an AdminCP route +<Step> -```ts title="plugins/site-notes/src/routes.ts" -import { definePluginRoutes, lazy, page } from '@vitnode/core/routing' +### Add TanStack Query -export const routes = definePluginRoutes([ - // [!code ++:4] - page('/admin/site-notes/settings', { - area: 'admin', - component: lazy(() => import('./pages/admin-settings-page')), - }), -]) +The page reads the list with `useQuery` and refreshes it after a create. Add the dependency to the plugin from the workspace root: + +<Tabs groupId="package-manager" persist items={["bun", "pnpm", "npm"]} label="Add TanStack Query"> + +```bash tab="bun" +bun add @tanstack/react-query --cwd plugins/site-notes +``` + +```bash tab="pnpm" +pnpm --filter site-notes add @tanstack/react-query +``` + +```bash tab="npm" +npm install @tanstack/react-query --workspace site-notes +``` + +</Tabs> + +</Step> + +<Step> + +### Share the list query + +Create `src/pages/notes-query.ts`. Every list of notes uses this query key, so one invalidation refreshes them all: + +```ts title="plugins/site-notes/src/pages/notes-query.ts" +import { queryOptions } from "@tanstack/react-query"; +import { fetcher } from "@vitnode/core/tanstack/fetcher"; + +import { manifest } from "@/manifest.gen"; + +export const NOTES_QUERY_KEY = [manifest.id, "notes"] as const; + +export const notesQueryOptions = () => + queryOptions({ + queryKey: NOTES_QUERY_KEY, + queryFn: async () => { + const response = await fetcher({ + plugin: manifest.id, + method: "get", + module: "notes", + path: "/", + }); + if (!response.ok) throw new Error("Could not load notes."); + + return await response.json(); + }, + }); +``` + +</Step> + +<Step> + +### Create the page + +Create `src/pages/admin-notes-page.tsx`. The form posts to the guarded endpoint; the list reads the public one: + +```tsx title="plugins/site-notes/src/pages/admin-notes-page.tsx" +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { AutoForm } from "@vitnode/core/components/form/auto-form"; +import { AutoFormInput } from "@vitnode/core/components/form/fields/input"; +import { AutoFormTextarea } from "@vitnode/core/components/form/fields/textarea"; +import { definePluginRoute } from "@vitnode/core/routing"; +import { useAdminPermission } from "@vitnode/core/tanstack/admin"; +import { fetcher } from "@vitnode/core/tanstack/fetcher"; +import { toast } from "sonner"; +import { useTranslations } from "use-intl"; +import { z } from "zod"; + +import { manifest } from "@/manifest.gen"; + +import { NOTES_QUERY_KEY, notesQueryOptions } from "./notes-query"; + +const formSchema = z.object({ + title: z.string().trim().min(3).max(200), + body: z.string().trim().min(1).max(2000), +}); + +const CreateNoteForm = () => { + const t = useTranslations("site-notes.admin.notes"); + const queryClient = useQueryClient(); + + return ( + <AutoForm + fields={[ + { + id: "title", + component: props => <AutoFormInput {...props} label={t("form.title")} />, + }, + { + id: "body", + component: props => <AutoFormTextarea {...props} label={t("form.body")} />, + }, + ]} + formSchema={formSchema} + onSubmit={async (values, form) => { + const response = await fetcher({ + plugin: manifest.id, + method: "post", + module: "admin/notes", + path: "/", + args: { body: values }, + }); + + if (!response.ok) { + toast.error(t("error.title"), { description: t("error.desc") }); + + return; + } + + const note = await response.json(); + form.reset(); + await queryClient.invalidateQueries({ queryKey: NOTES_QUERY_KEY }); + toast.success(t("created.title"), { + description: t("created.desc", { title: note.title }), + }); + }} + submitButtonProps={{ children: t("form.submit") }} + /> + ); +}; + +const AdminNotesPage = () => { + const t = useTranslations("site-notes.admin.notes"); + const canCreate = useAdminPermission({ + plugin: manifest.id, + module: "notes", + permission: "can_create", + }); + const { data: notes, isPending, isError } = useQuery(notesQueryOptions()); + + return ( + <div className="flex flex-col gap-6 p-4"> + <header className="flex flex-col gap-2"> + <h1 className="text-2xl font-semibold tracking-tight text-balance"> + {t("title")} + </h1> + <p className="text-muted-foreground leading-relaxed text-pretty"> + {t("desc")} + </p> + </header> + + {canCreate ? ( + <section className="flex max-w-xl flex-col gap-4"> + <h2 className="text-lg font-semibold">{t("form.heading")}</h2> + <CreateNoteForm /> + </section> + ) : ( + <p className="text-muted-foreground leading-relaxed">{t("no_permission")}</p> + )} + + <section aria-busy={isPending} className="flex flex-col gap-3"> + <h2 className="text-lg font-semibold">{t("list")}</h2> + {isPending ? ( + <p className="text-muted-foreground" role="status"> + {t("loading")} + </p> + ) : isError ? ( + <p className="text-destructive">{t("load_error")}</p> + ) : ( + <ul className="flex flex-col divide-y rounded-lg border"> + {notes.map(note => ( + <li className="flex flex-col gap-1 p-3" key={note.id}> + <span className="font-medium">{note.title}</span> + <span className="text-muted-foreground text-sm">{note.body}</span> + </li> + ))} + </ul> + )} + </section> + </div> + ); +}; + +const AdminNotesBreadcrumb = () => { + const t = useTranslations("site-notes.admin.notes"); + + return <span>{t("title")}</span>; +}; + +export const route = definePluginRoute({ + breadcrumb: AdminNotesBreadcrumb, + head: () => ({ robots: "noindex, nofollow" }), +}); + +export default AdminNotesPage; ``` - </Step> - <Step> +- **`AutoForm`** validates with the same limits as the endpoint and shows the submit button's loading state, so a note cannot be sent twice. +- **After a successful create**, the form resets, `invalidateQueries` refetches the list and a toast names the new note. A refused request (`403`, `400`) shows an error toast instead. +- **`useAdminPermission`** only decides whether the form is shown. The endpoint's `adminStaffPermission` is what actually refuses a create. + +</Step> -### Render the plugin page +<Step> -```tsx title="plugins/site-notes/src/pages/admin-settings-page.tsx" -const AdminSettingsPage = () => ( - <div className="flex flex-col gap-2 p-4"> - <h2 className="text-2xl font-semibold">Site notes settings</h2> - <p className="text-muted-foreground">Manage the notes plugin.</p> - </div> -) +### Declare the route + +Add the page to `src/routes.ts` with `area: "admin"`, which renders it inside the AdminCP shell behind the AdminCP session: + +```ts title="plugins/site-notes/src/routes.ts" + page("/site-notes/notes", { + component: lazy(() => import("./pages/notes-page")), + messages: ["site-notes.notes"], + }), -export default AdminSettingsPage + // [!code ++:5] + page("/admin/site-notes", { + area: "admin", + component: lazy(() => import("./pages/admin-notes-page")), + messages: ["site-notes.admin.notes"], + }), +]); ``` - </Step> - <Step> +An admin route cannot declare a staff permission. Any AdminCP user can open the URL; the page above adapts its content, and the API decides what they can change. + +</Step> + +<Step> -### Add a sidebar entry +### Link it from the sidebar -Export `adminNav` from the plugin. Its small browser-safe shape is what the -generated AdminCP registry imports—do not edit `admin-nav.gen.ts` yourself. +Create `src/admin/nav.tsx`. VitNode finds the file by name and adds a **Site Notes** group to the sidebar: ```tsx title="plugins/site-notes/src/admin/nav.tsx" -import type { AdminNavPluginSource } from '@vitnode/core/lib/plugin' -import { SettingsIcon } from 'lucide-react' +import type { AdminNavPluginSource } from "@vitnode/core/lib/plugin"; + +import { StickyNoteIcon } from "lucide-react"; + +import { manifest } from "@/manifest.gen"; export const adminNav = { - pluginId: '@acme/site-notes', + pluginId: manifest.id, admin: { nav: [ - // [!code ++:6] { - href: '/admin/site-notes/settings', - icon: <SettingsIcon />, - id: 'settings', - permission: { module: 'site_notes', permission: 'can_manage_settings' }, + id: "notes", + href: "/admin/site-notes", + icon: <StickyNoteIcon />, + permission: { module: "notes", permission: "can_create" }, }, ], }, -} satisfies AdminNavPluginSource +} satisfies AdminNavPluginSource; ``` -The generator’s `./*` package export already exposes `admin/nav`; no manual -`package.json` export is required, and nothing changes in `config.tsx`. The -generated `admin-nav.gen.ts` imports `admin/nav` directly, and keeping the -factory to plain data is what keeps the sidebar registration out of every -public page's bundle. +`permission` hides the link from administrators without it. It must be a permission the plugin declares in `permissionStaff`, or `vitnode plugin validate` fails. - </Step> - <Step> +</Step> -### Verify the screen +<Step> -<Tabs groupId="package-manager" persist items={["bun", "pnpm", "npm"]} label="Run the AdminCP extension"> +### Add the strings -```bash tab="bun" -bun dev -``` +Add an `admin` group under `site-notes` in `src/locales/en.json`. `admin.nav.notes` is the sidebar label; the rest belongs to the page. Keep `home` and `notes`, and the permission labels at the root: -```bash tab="pnpm" -pnpm dev +```json title="plugins/site-notes/src/locales/en.json" +{ + "site-notes": { + "admin": { + "nav": { "notes": "Notes" }, + "notes": { + "title": "Notes", + "desc": "Write the notes shown at /site-notes/notes.", + "list": "All notes", + "loading": "Loading notes…", + "load_error": "The notes could not be loaded.", + "no_permission": "You can read the notes, but creating them needs the Create notes permission.", + "form": { + "heading": "New note", + "title": "Title", + "body": "Note", + "submit": "Create note" + }, + "created": { + "title": "Note created", + "desc": "\"{title}\" is live on the notes page." + }, + "error": { + "title": "Note not created", + "desc": "Check the fields and your permissions, then try again." + } + } + } + } +} ``` -```bash tab="npm" -npm run dev -``` +</Step> -</Tabs> +</Steps> -Visit `http://localhost:3000/admin/site-notes/settings` as a staff account with -the declared permission. +## Check the result -{/* Image prompt: VitNode AdminCP settings page contributed by a plugin. Show the existing admin sidebar with “Site notes” selected, a compact settings screen, breadcrumb, and staff-permission badge. Dark theme, 1440x900. */} +1. Sign in to the AdminCP as the first account. The sidebar has **Site Notes → Notes**. Open it, enter `Fire drill on Wednesday` and `The alarm goes off at 11:00. Meet in the car park.`, and choose **Create note**. A **Note created** toast appears, the form clears and the note tops **All notes**. It is also on `/site-notes/notes`. - </Step> -</Steps> +<ImgDocs + withoutBackground + className="*:max-w-full" + src={adminPage} + alt="The Site Notes Notes page in the AdminCP with the New note form, a Note created toast for Fire drill on Wednesday, and All notes listing it first" +/> + +2. Sign in as the restricted administrator without **Create notes**. The sidebar has no **Site Notes** group. Opening `/admin/site-notes` directly shows the list and the line "creating them needs the Create notes permission" instead of the form. +3. Grant **Create notes** to that account, as in the previous lesson. After a reload, the link and the form appear, and creating a note works. + +Run `pnpm vitnode plugin validate site-notes` in `apps/web` once more. It lists **AdminCP navigation** among the passing checks. + +## What you built + +`site-notes` now has a public page backed by PostgreSQL, a validated API, a staff permission enforced on the server and an AdminCP screen. Where to go from here: -## Extend the panel - -<Cards> - <Card - icon={<LayoutDashboardIcon />} - title="Dashboard widgets" - description="Add a plugin widget to the admin dashboard’s configurable grid." - href="/docs/dev/plugins/admin/dashboard-widgets" - /> - <Card - icon={<ShieldCheckIcon />} - title="Staff permissions" - description="Define the permissions that gate staff actions and navigation." - href="/docs/dev/working-with-users/staff-permissions" - /> - <Card - icon={<RouteIcon />} - title="Plugin routes" - description="Configure route areas, namespaces, guards, and parameters." - href="/docs/dev/routing" - /> -</Cards> +- [Dashboard widgets](/docs/dev/plugins/admin/dashboard-widgets): put a summary on the AdminCP dashboard. +- [Events](/docs/dev/events): announce `site-notes` changes to other plugins. +- [Queue](/docs/dev/advanced/queue): move slow work out of the request. +- [Content Engine](/docs/content-engine): generate tables, APIs and AdminCP screens from one definition instead of writing them. diff --git a/apps/web/content/docs/dev/plugins/admin/meta.json b/apps/web/content/docs/dev/plugins/admin/meta.json index d7696b1da6..c6a5292783 100644 --- a/apps/web/content/docs/dev/plugins/admin/meta.json +++ b/apps/web/content/docs/dev/plugins/admin/meta.json @@ -1,6 +1,9 @@ { - "title": "Admin Control Panel", + "title": "AdminCP", "description": "Add plugin-owned AdminCP pages, navigation, permissions and dashboard widgets", "icon": "LayoutDashboard", - "pages": ["index", "dashboard-widgets"] + "pages": [ + "index", + "dashboard-widgets" + ] } diff --git a/apps/web/content/docs/dev/plugins/api/modules.mdx b/apps/web/content/docs/dev/plugins/api/modules.mdx index 254dd44ed6..4bba4b7452 100644 --- a/apps/web/content/docs/dev/plugins/api/modules.mdx +++ b/apps/web/content/docs/dev/plugins/api/modules.mdx @@ -1,209 +1,141 @@ --- -title: API Modules -description: Add a typed Hono API module to a VitNode plugin, register it once, and keep endpoint ownership with the feature. +title: API modules +description: Add a notes module to the site-notes VitNode plugin, register it in config.api.ts, and call its typed endpoint through the host API. icon: Box --- -import { NetworkIcon, RouteIcon, ShieldCheckIcon } from 'lucide-react' +An API module groups a plugin's Hono routes under one URL segment. In this lesson you add a `notes` module to `site-notes` whose endpoint returns two sample notes. A later lesson swaps the sample data for a database table. -Start with [a plugin](/docs/dev/plugins/create), not a host endpoint. A module -groups the plugin's Hono routes under one URL prefix and gives OpenAPI a tidy -place to describe them. +## Before you begin -<Callout type="info" title="A generated plugin already has one"> - `create-vitnode-app --plugin` writes a `hello` module, its `config.api.ts` and - a page that calls it. Read on for what each piece does—then rename them, or add - a second module beside them. -</Callout> - -{/* Image prompt: Dark-theme API ownership diagram. A Site notes plugin contains a Hono route, notes module, and config.api file; the app API configuration composes the plugin once. Show resulting GET endpoint, 1600x900. */} +- The `site-notes` plugin from [Create plugin](/docs/dev/plugins/create), with `pnpm dev` running at the workspace root. `dev` rebuilds the plugin every time you save. <Steps> - <Step> -### Define one plugin endpoint +<Step> -Route and module files import their builders from the plugin's own -`src/api/builders.ts`, which binds them to the generated `manifest`. That is -where the plugin id comes from, so no route or module spells it: +### Describe a note -```ts title="plugins/site-notes/src/api/builders.ts" -import { bindApiPlugin } from '@vitnode/core/api/lib/bind-api-plugin' +Create `src/api/modules/notes/notes.schema.ts`. The schema is the response shape, and OpenAPI documents it too: -import { manifest } from '@/manifest.gen' +```ts title="plugins/site-notes/src/api/modules/notes/notes.schema.ts" +import { z } from "@hono/zod-openapi"; -export const { buildModule, buildRoute } = bindApiPlugin(manifest) +export const zodNote = z.object({ + id: z.number(), + title: z.string(), + body: z.string(), +}); ``` +Then create the sample data the endpoint returns for now: + +```ts title="plugins/site-notes/src/api/modules/notes/sample-notes.ts" +/** Placeholder notes until the Database lesson stores real ones. */ +export const sampleNotes = [ + { id: 1, title: "Welcome to site notes", body: "Short updates for everyone on the site." }, + { id: 2, title: "Office closed on Friday", body: "We are back on Monday at 9:00." }, +]; +``` + +</Step> + +<Step> + +### Create the list route + +Create `src/api/modules/notes/list.route.ts`. `buildRoute` comes from the plugin's own `src/api/builders.ts`, which the generator wrote, so the route is tied to `site-notes` without naming it: + ```ts title="plugins/site-notes/src/api/modules/notes/list.route.ts" -import { z } from '@hono/zod-openapi' +import { z } from "@hono/zod-openapi"; + +import { buildRoute } from "@/api/builders"; -import { buildRoute } from '@/api/builders' +import { zodNote } from "./notes.schema"; +import { sampleNotes } from "./sample-notes"; export const listNotesRoute = buildRoute({ route: { - method: 'get', - path: '/', + method: "get", + path: "/", responses: { - // [!code ++:7] 200: { - content: { - 'application/json': { - schema: z.object({ notes: z.array(z.string()) }), - }, - }, - description: 'Published site notes.', + content: { "application/json": { schema: z.array(zodNote) } }, + description: "Every note, newest first.", }, }, }, - handler: (c) => c.json({ notes: ['Hello plugin'] }), -}) + handler: c => c.json(sampleNotes, 200), +}); ``` - </Step> - <Step> +The `responses` schema is a contract. `c.json()` only accepts a body and status that match it. -### Group it and export the plugin API +</Step> -```ts title="plugins/site-notes/src/api/modules/notes/notes.module.ts" -import { buildModule } from '@/api/builders' +<Step> -import { listNotesRoute } from './list.route' +### Group it in a module -export const notesModule = buildModule({ - name: 'notes', - routes: [listNotesRoute], // [!code ++] -}) -``` +Create `src/api/modules/notes/notes.module.ts`. The module's `name` is the URL segment: -```ts title="plugins/site-notes/src/config.api.ts" -import { buildApiPlugin } from '@vitnode/core/api/lib/plugin' +```ts title="plugins/site-notes/src/api/modules/notes/notes.module.ts" +import { buildModule } from "@/api/builders"; -import { notesModule } from './api/modules/notes/notes.module' -import { manifest } from './manifest.gen' +import { listNotesRoute } from "./list.route"; -export const siteNotesApiPlugin = () => - buildApiPlugin({ - manifest, - modules: [notesModule], // [!code ++] - }) +export const notesModule = buildModule({ + name: "notes", + routes: [listNotesRoute], +}); ``` -This file, and not `config.tsx`: the API config reaches your handlers, database -and secrets, while `config.tsx` is read by the browser build. A module -registered in the wrong one is shipped to every visitor. - -`buildApiPlugin` keeps everything it was given as a literal type - the plugin -id, the tuple of modules, and every module nested inside them. That type is -what the [fetcher](/docs/dev/fetcher) infers routes from, so a route you add -here is callable, and checked, the moment you save. +</Step> - </Step> - <Step> +<Step> -### Export the type an application registers +### Register the module -Your pages call the endpoint through the universal fetcher, and the fetcher -looks the plugin up in the API plugin registry. Export one reduced type beside -your factory - `create-vitnode-app --plugin` writes this for you: +Open `src/config.api.ts` and add `notesModule` next to the generated `helloModule`. This is an excerpt; keep the rest of the file, including the `VitNodeApiPlugin` type at the bottom: ```ts title="plugins/site-notes/src/config.api.ts" -import type { ApiPluginContract } from '@vitnode/core/api/lib/plugin' - -import { buildApiPlugin } from '@vitnode/core/api/lib/plugin' +import { helloModule } from "./api/modules/hello/hello.module"; +import { notesModule } from "./api/modules/notes/notes.module"; // [!code ++] export const siteNotesApiPlugin = () => - buildApiPlugin({ manifest, modules: [notesModule] }) - -// [!code ++:3] -export type VitNodeApiPlugin = ApiPluginContract< - ReturnType<typeof siteNotesApiPlugin> -> + buildApiPlugin({ + manifest, + modules: [helloModule], // [!code --] + modules: [helloModule, notesModule], // [!code ++] + }); ``` -`ApiPluginContract` keeps the four things a call needs - the literal plugin id, -every module path you serve, your module tree and your route definitions - and -drops everything else. Your Hono application, content models, event listeners, -queue tasks, WebSockets, search indexers and messages are runtime concerns, so -an app that installs your plugin never resolves them to type-check a call. - -`import type` is the whole point: the factory is never executed here, so none of -the Hono, database or secret code behind it is ever reached by a browser build. - -<Callout type="warn" title="A plugin never registers itself"> - Your package augments nothing. The app that installs you writes the entry into - its own generated `src/api-registry.gen.ts`, from the plugins it configured. A - package that registered itself would put its routes into the registry of every - project that merely installed it. - - To type-check your own pages before any app has installed the plugin, the - scaffold writes `test-fixtures/api-registry.d.ts` - the same entry an app - generates, kept outside `src` and out of the published package. -</Callout> +The generator already added a `siteNotesApiPlugin()` call to the app's `vitnode.api.config.ts`. Every module you list here is served under `/api/site-notes/`, so nothing changes in the app. - </Step> - <Step> +</Step> -### Compose it in the app API config - -The app decides which installed plugins are active. Add the factory to the Hono -config that serves your app (`apps/web` for a single app, or `apps/api` when it -is separate): +</Steps> -```ts title="apps/web/src/vitnode.api.config.ts" -import { siteNotesApiPlugin } from '@acme/site-notes/config.api' // [!code ++] +## Check the result -export const vitNodeApiConfig = buildApiConfig({ - plugins: [siteNotesApiPlugin()], // [!code ++] -}) +```bash +curl http://localhost:3000/api/site-notes/notes ``` -The endpoint is now `GET /api/@acme/site-notes/notes`. OpenAPI picks it up too; -one less hand-written map to maintain. This is the one place `siteNotesApiPlugin()` -is ever called - everywhere else it is a type. - - </Step> - <Step> - -### Call it - -```ts title="plugins/site-notes/src/features/notes/notes-query.ts" -import { fetcher } from '@vitnode/core/tanstack/fetcher' +```json +[ + { "id": 1, "title": "Welcome to site notes", "body": "Short updates for everyone on the site." }, + { "id": 2, "title": "Office closed on Friday", "body": "We are back on Monday at 9:00." } +] +``` -const response = await fetcher({ - plugin: '@acme/site-notes', // [!code ++] - method: 'get', - module: 'notes', - path: '/', -}) +The endpoint also appears in the app's API reference; see [Swagger](/docs/dev/swagger). -const { notes } = await response.json() -``` +## How the fetcher knows your routes -Nothing but the plugin id crosses into the browser bundle. `module`, `path`, -`method` and the response are inferred from the module tree registered above, -and a typo in any of them is a compile error. +Pages call plugin APIs through the [fetcher](/docs/dev/fetcher), which infers `module`, `path`, the arguments and the response from a registry of plugin types. You never edit that registry by hand: - </Step> -</Steps> +- **In the plugin**, every `vitnode build` and `vitnode dev` (both run by `pnpm dev`) writes `types/api-registry.gen.d.ts`. It registers only this plugin, as a type import of `VitNodeApiPlugin` from `src/config.api.ts`, so the plugin's own pages type-check before any app installs it. The plugin's `tsconfig.json` includes `types/`, `.npmignore` keeps the folder out of the published package, and you commit it like `src/manifest.gen.ts`. +- **In the app**, `vitnode dev` and `vitnode build` write `src/api-registry.gen.ts` from the plugins the app configured. That file, not the plugin's, decides what the app's pages can call. -<Cards> - <Card - icon={<RouteIcon />} - title="Validate inputs" - description="Use Zod schemas for params, queries, request bodies, and typed responses." - href="/docs/dev/plugins/api/routes" - /> - <Card - icon={<ShieldCheckIcon />} - title="Protect staff APIs" - description="Require a staff permission before an AdminCP action reaches its handler." - href="/docs/dev/working-with-users/staff-permissions" - /> - <Card - icon={<NetworkIcon />} - title="Call the API" - description="Use VitNode's typed fetcher from a plugin-owned page or component." - href="/docs/dev/fetcher" - /> -</Cards> +The `VitNodeApiPlugin` type at the bottom of `config.api.ts` is the link between the two. It keeps the plugin id, module paths and route definitions and drops the Hono app, database and listener code, so a type-only import never pulls server code into a browser build. The generator writes it; keep it when you edit the file. diff --git a/apps/web/content/docs/dev/plugins/api/routes.mdx b/apps/web/content/docs/dev/plugins/api/routes.mdx index 4bc9f2d20b..cc2b171177 100644 --- a/apps/web/content/docs/dev/plugins/api/routes.mdx +++ b/apps/web/content/docs/dev/plugins/api/routes.mdx @@ -1,116 +1,102 @@ --- -title: API Routes -description: Validate plugin-owned Hono route inputs and responses with Zod, then protect staff actions with a clear permission. -icon: Network +title: API routes +description: Add a route with a validated path parameter to the site-notes VitNode plugin, answer 404 for a missing note, and see how invalid input is refused. +icon: Route --- -import { BoxIcon, ShieldCheckIcon, ZapIcon } from 'lucide-react' +Each route declares its inputs and outputs with Zod, and VitNode validates the request before your handler runs. In this lesson you add `GET /api/site-notes/notes/{id}`, which returns one note, refuses an id that is not a positive number, and answers `404` when no note has that id. -Create [the plugin](/docs/dev/plugins/create) and its [API module](/docs/dev/plugins/api/modules) -first. Then put the endpoint in that module so its validation, permission, and -OpenAPI record travel together. +## Before you begin + +- The `notes` module from [API modules](/docs/dev/plugins/api/modules), still returning the sample notes in `sample-notes.ts`. <Steps> - <Step> -### Validate input and response +<Step> + +### Create the route + +Create `src/api/modules/notes/show.route.ts`: -```ts title="plugins/site-notes/src/api/modules/notes/get.route.ts" -import { z } from '@hono/zod-openapi' +```ts title="plugins/site-notes/src/api/modules/notes/show.route.ts" +import { z } from "@hono/zod-openapi"; -import { buildRoute } from '@/api/builders' +import { buildRoute } from "@/api/builders"; -export const getNoteRoute = buildRoute({ +import { zodNote } from "./notes.schema"; +import { sampleNotes } from "./sample-notes"; + +export const showNoteRoute = buildRoute({ route: { - method: 'get', - path: '/{id}', + method: "get", + path: "/{id}", request: { params: z.object({ id: z.coerce.number().int().positive() }), }, responses: { - // [!code ++:7] 200: { - content: { - 'application/json': { - schema: z.object({ id: z.number(), title: z.string() }), - }, - }, - description: 'One site note.', + content: { "application/json": { schema: zodNote } }, + description: "The note.", }, + 404: { description: "No note has this id." }, }, }, - handler: (c) => { - const { id } = c.req.valid('param') - return c.json({ id, title: 'Plugin-owned note' }) + handler: c => { + const { id } = c.req.valid("param"); + const note = sampleNotes.find(item => item.id === id); + if (!note) return c.body(null, 404); + + return c.json(note, 200); }, -}) +}); ``` - </Step> - <Step> +- **`path: "/{id}"`** uses OpenAPI's parameter syntax. It is also the `path` a page passes to the fetcher. +- **`request.params`** validates the URL segment. Path parameters arrive as strings, so `z.coerce.number()` turns `"2"` into `2`, and `.int().positive()` refuses everything else. +- **`c.req.valid("param")`** returns the parsed, typed value. Read inputs through `c.req.valid()`, never from the raw request. +- **List every status the handler returns in `responses`.** That is what lets the fetcher type both outcomes for the caller. -### Gate an AdminCP action +Body and query parameters work the same way, through `request.body` and `request.query`; the [Permissions](/docs/dev/working-with-users/staff-permissions) lesson adds a validated body. -Add `adminStaffPermission` to an action that only staff should call. The API -still authorizes server-side; a hidden button is merely good manners. +</Step> -```ts title="plugins/site-notes/src/api/modules/notes/publish.route.ts" -import { z } from '@hono/zod-openapi' +<Step> -import { buildRoute } from '@/api/builders' +### Add it to the module -export const publishNoteRoute = buildRoute({ - // [!code ++:4] - adminStaffPermission: { - module: 'site_notes', - permission: 'can_publish', - }, - route: { - method: 'post', - path: '/{id}/publish', - responses: { - 200: { - content: { - 'application/json': { - schema: z.object({ published: z.literal(true) }), - }, - }, - description: 'The note was published.', - }, - }, - }, - handler: async (c) => c.json({ published: true }), -}) +Open `src/api/modules/notes/notes.module.ts` and add the route after the list route. Keep `listNotesRoute`: + +```ts title="plugins/site-notes/src/api/modules/notes/notes.module.ts" +import { buildModule } from "@/api/builders"; + +import { listNotesRoute } from "./list.route"; +import { showNoteRoute } from "./show.route"; // [!code ++] + +export const notesModule = buildModule({ + name: "notes", + routes: [listNotesRoute], // [!code --] + routes: [listNotesRoute, showNoteRoute], // [!code ++] +}); ``` - </Step> +</Step> + </Steps> -{/* Image prompt: Dark-theme API documentation screenshot. Show an OpenAPI endpoint for a plugin route with path parameter validation, a successful JSON response, and a staff-permission lock badge. 1440x900. */} - -<Callout type="idea" title="One route, one plugin owner"> - Keep handlers with the feature that owns the data. The host API config only - composes plugins; it should not become a surprise sequel to your business - logic. -</Callout> - -<Cards> - <Card - icon={<BoxIcon />} - title="API modules" - description="Register a module and its plugin API factory with the Hono application." - href="/docs/dev/plugins/api/modules" - /> - <Card - icon={<ShieldCheckIcon />} - title="Staff permissions" - description="Define the permission catalog that gates AdminCP actions." - href="/docs/dev/working-with-users/staff-permissions" - /> - <Card - icon={<ZapIcon />} - title="Typed fetcher" - description="Call validated plugin endpoints from the TanStack Start UI." - href="/docs/dev/fetcher" - /> -</Cards> +## Check the result + +Ask for an existing note, a missing one and an invalid id: + +```bash +curl -i http://localhost:3000/api/site-notes/notes/2 +curl -i http://localhost:3000/api/site-notes/notes/9 +curl -i http://localhost:3000/api/site-notes/notes/abc +``` + +| Request | Status | Body | +| --- | --- | --- | +| `/notes/2` | `200` | `{ "id": 2, "title": "Office closed on Friday", ... }` | +| `/notes/9` | `404` | empty | +| `/notes/abc` | `400` | `{ "success": false, "error": { "name": "ZodError", ... } }` | + +The `400` comes from validation, so your handler never ran. diff --git a/apps/web/content/docs/dev/plugins/build.mdx b/apps/web/content/docs/dev/plugins/build.mdx index 1bc1c65026..e4cd3a6281 100644 --- a/apps/web/content/docs/dev/plugins/build.mdx +++ b/apps/web/content/docs/dev/plugins/build.mdx @@ -1,5 +1,5 @@ --- -title: Build and watch a plugin +title: Build and watch description: How vitnode build compiles a plugin package with tsdown, how vitnode dev watches it, and what ends up in dist. icon: Hammer --- @@ -8,6 +8,8 @@ import { File, Files, Folder } from 'fumadocs-ui/components/files' `vitnode build` turns a plugin's `src` folder into `dist/src`: one JavaScript file, one declaration file (`.d.ts`) and one declaration map per module. `vitnode dev` does the same in watch mode, so an app using the plugin reloads as you save. There is no build config to write. VitNode owns it, and every plugin and adapter package is built the same way. +A generated plugin calls both commands from its `package.json` scripts: `build:plugins` runs `vitnode build` and `dev` runs `vitnode dev`. At the workspace root, `pnpm build:plugins` builds every plugin through Turborepo, and the root `dev` builds them before it starts the apps. + ## What tsdown does [tsdown](https://tsdown.dev) is the compiler behind both commands. It runs in **unbundle mode**: every module is compiled on its own instead of being merged into one bundle, so `src/pages/home-page.tsx` becomes `dist/src/pages/home-page.js`. @@ -72,7 +74,9 @@ Two TypeScript jobs run side by side, and both must pass: | tsdown | Writes the `.d.ts` files and their maps with the TypeScript compiler | | `tsc --noEmit` | Type checks the whole package with `tsconfig.build.json` and fails on an error | -Declarations are written from every file `tsconfig.build.json` includes. That matters for files nothing imports, such as `global.d.ts` and the generated `types/api-registry.gen.d.ts`: they are what lets `fetcher({ plugin: "@acme/site-notes", ... })` infer its response, and that inferred type lands in your `.d.ts` files. +tsdown writes declarations for every file `tsconfig.build.json` includes. That matters for files nothing imports, such as `global.d.ts` and the generated `types/api-registry.gen.d.ts`. They are what lets `fetcher({ plugin: "site-notes", ... })` infer its response, and that inferred type lands in your `.d.ts` files. + +`types/api-registry.gen.d.ts` registers the plugin's own API with the fetcher. `vitnode build` and `vitnode dev` write it before compiling, but only when the plugin has a `src/config.api.ts`. The template `.npmignore` keeps `/types` out of the published package; commit it like `src/manifest.gen.ts` so the plugin type-checks right after a checkout. You don't need `isolatedDeclarations` or explicit return types. Inferred types come out the same as `tsc` would write them. @@ -116,7 +120,7 @@ VitNode uses different builds for different things. This page is about the first | Build | Who runs it | Tool | | -------------- | ---------------------------------- | ----------------------------------------------------------- | -| Plugin package | `vitnode build` in a plugin | tsdown in unbundle mode, plus `tsc --noEmit` | +| Plugin package | `vitnode build` in a plugin (its `build:plugins` script) | tsdown in unbundle mode, plus `tsc --noEmit` | | VitNode CLI | `pnpm build:scripts` in `@vitnode/core` | tsdown with `packages/vitnode/tsdown.config.ts`, into `dist/scripts` | | App | `vitnode build` in an app | Vite and TanStack Start, unchanged | @@ -129,14 +133,9 @@ VitNode uses different builds for different things. This page is about the first - Each JavaScript rebuild renders every module of the package again and writes only the changed ones. That takes under 100 ms for a typical plugin and about 2 seconds for the roughly 1,600 modules of `@vitnode/core`. - A re-export-only `index.ts` may get no `.d.ts.map`, so "Go to definition" stops at its `.d.ts` first. -## Next steps +## Related <Cards> - <Card - title="Migrate a plugin from SWC" - description="Move an existing plugin from SWC and tsc-alias to tsdown" - href="/docs/dev/plugins/migrate-to-tsdown" - /> <Card title="Plugin CLI" description="List and validate plugins" diff --git a/apps/web/content/docs/dev/plugins/catalog.mdx b/apps/web/content/docs/dev/plugins/catalog.mdx index 9848e9fb33..70a99a6dc5 100644 --- a/apps/web/content/docs/dev/plugins/catalog.mdx +++ b/apps/web/content/docs/dev/plugins/catalog.mdx @@ -1,9 +1,11 @@ --- -title: Show a plugin in the AdminCP catalog +title: Catalog listing description: Give your plugin a title and a Lucide icon so AdminCP → System → Plugins lists it with the version that is actually installed. icon: Puzzle --- +import { Tab, Tabs } from 'fumadocs-ui/components/tabs' + import { ImgDocs } from '@/components/fumadocs/img' import catalog from './admin-plugins-catalog.webp' @@ -20,90 +22,108 @@ The catalog reports what is installed. It also shows available updates and secur ## Add your plugin to the catalog +The steps use the `site-notes` plugin from [Create a plugin](/docs/dev/plugins/create). A generated plugin is already listed with the puzzle icon and the placeholder description "A VitNode plugin." These steps make the entry its own. + <Steps> <Step> ### Describe the plugin in package.json -Make sure your `package.json` has a `description` next to its `name` and `version`: +The generator writes `"description": "A VitNode plugin."`. Replace it with a sentence about your plugin, next to its `name` and `version`: ```json title="plugins/site-notes/package.json" { - "name": "@acme/site-notes", - "version": "1.2.0", - "description": "Pin short notes to any page of your community." + "name": "site-notes", + "version": "0.1.0", + "description": "Pin short notes to any page of your community." // [!code highlight] } ``` +The `name` is the plugin id. Keep it as the generator wrote it, because the id also names the API path, the translation namespace and the imports in your app's config files. + </Step> <Step> ### Set the title and icon -Create `src/plugin-info.ts` with the title and a [Lucide icon](https://lucide.dev/icons) name: +Plugins created with the generator already have `src/plugin-info.ts`, with the `puzzle` icon. Change the icon to any [Lucide icon](https://lucide.dev/icons) name, and the title if you like: -```ts title="src/plugin-info.ts" +```ts title="plugins/site-notes/src/plugin-info.ts" import type { PluginInfo } from '@vitnode/core/lib/plugin-manifest' export const pluginInfo: PluginInfo = { title: 'Site Notes', - icon: 'sticky-note', + icon: 'sticky-note', // [!code highlight] } ``` -Plugins created with the generator already have this file, with the `puzzle` icon. - </Step> <Step> -### Register the generated manifest +### Check that the manifest is registered -`vitnode build` writes `src/manifest.gen.ts`. It holds the `name`, `version`, `description`, `author` and `license` from `package.json` and imports the title and icon from `src/plugin-info.ts`. It is also the single source of the plugin id (`manifest.id`), so nothing else spells the id by hand. Pass it to both registrations: +`vitnode build` writes `src/manifest.gen.ts`. It holds the `name`, `version`, `description`, `author` and `license` from `package.json` and imports the title and icon from `src/plugin-info.ts`. It is also the single source of the plugin id (`manifest.id`), so nothing else spells the id by hand. -```tsx title="src/config.tsx" -import { buildPlugin } from '@vitnode/core/lib/plugin' - -import { manifest } from './manifest.gen' -import { routes } from './routes' +A generated plugin already passes `manifest` to both factories, so there is nothing to change. In `src/config.tsx` it sits next to the plugin's routes and translations: +```tsx title="plugins/site-notes/src/config.tsx" export const siteNotesPlugin = () => buildPlugin({ - manifest, + manifest, // [!code highlight] + localeFiles: { + en: 'site-notes/locales/en.json', + }, + messages, routes, }) ``` -```ts title="src/config.api.ts" -import { buildApiPlugin } from '@vitnode/core/api/lib/plugin' - -import { manifest } from '@/manifest.gen' +And in `src/config.api.ts`: +```ts title="plugins/site-notes/src/config.api.ts" export const siteNotesApiPlugin = () => buildApiPlugin({ - manifest, - modules: [], + manifest, // [!code highlight] + modules: [helloModule], }) ``` +If you rewrite either factory, keep `localeFiles` and `messages`. Without them the plugin loses its translations. + </Step> <Step> ### Build the plugin -Run the build in the plugin's folder, or keep `vitnode dev` running: +If `pnpm dev` is running at the workspace root, the plugin rebuilds when you save. Otherwise build it from the plugin's folder with its `build:plugins` script (which runs `vitnode build`): -```bash -vitnode build +<Tabs groupId='package-manager' persist items={['bun', 'pnpm', 'npm']} label='Build the plugin'> + +```bash tab="bun" +cd plugins/site-notes +bun run build:plugins +``` + +```bash tab="pnpm" +cd plugins/site-notes +pnpm build:plugins ``` -Both commands generate the file before TypeScript and tsdown run. Commit it, like `types/api-registry.gen.d.ts`. +```bash tab="npm" +cd plugins/site-notes +npm run build:plugins +``` + +</Tabs> + +The build regenerates `src/manifest.gen.ts` before TypeScript and tsdown run. Commit it, like `types/api-registry.gen.d.ts`. </Step> </Steps> ### Check the result -Restart your app, sign in to the AdminCP and open **System → Plugins**. Your plugin shows its icon, title, `@acme/site-notes`, the description and `1.2.0`. Search matches the title, the package ID and the description. +Restart your app, sign in to the AdminCP and open **System → Plugins**. Your plugin shows the sticky note icon, **Site Notes**, `site-notes`, your description and `0.1.0`. Search matches the title, the package ID and the description. ## Where each value comes from @@ -151,7 +171,7 @@ A side that reports no version is unknown, not a mismatch. ## Older plugins -`buildPlugin()` and `buildApiPlugin()` take the plugin id from `manifest`, so an older plugin that still passes `pluginId` needs `src/manifest.gen.ts` first: run `vitnode build` or `vitnode dev` once, then pass `manifest` instead. A plugin without `src/plugin-info.ts` gets its ID as the title and the default icon. Add it when convenient. +`buildPlugin()` and `buildApiPlugin()` take the plugin id from `manifest`, so an older plugin that still passes `pluginId` needs `src/manifest.gen.ts` first. Run its `build:plugins` or `dev` script once, then pass `manifest` instead. A plugin without `src/plugin-info.ts` gets its ID as the title and the default icon. Add it when convenient. A frontend inventory entry carrying the manifest of another package throws at startup: the API checks that `manifest.id` equals the entry's `id`. diff --git a/apps/web/content/docs/dev/plugins/create-generated-page.webp b/apps/web/content/docs/dev/plugins/create-generated-page.webp new file mode 100644 index 0000000000..21c8d5b7e2 Binary files /dev/null and b/apps/web/content/docs/dev/plugins/create-generated-page.webp differ diff --git a/apps/web/content/docs/dev/plugins/create.mdx b/apps/web/content/docs/dev/plugins/create.mdx index 2157d9e5d8..bb8a6ccb33 100644 --- a/apps/web/content/docs/dev/plugins/create.mdx +++ b/apps/web/content/docs/dev/plugins/create.mdx @@ -1,161 +1,126 @@ --- -title: Create a Plugin -description: Scaffold a VitNode plugin, register its package in your host app, and serve its first TanStack Start page. +title: Create plugin +description: Generate the site-notes VitNode plugin with create-vitnode-app, see where it was registered, and open its first page and API endpoint. icon: PackagePlus --- -import { - BoxIcon, - DatabaseIcon, - LayoutDashboardIcon, - RouteIcon, -} from 'lucide-react' -import { File, Files, Folder } from 'fumadocs-ui/components/files' -import { Tab, Tabs } from 'fumadocs-ui/components/tabs' - -A plugin is the starting point for a VitNode feature. It keeps routes, API -modules, data, translations, and AdminCP extensions together in one installable -package. Nice boundaries; fewer archaeological digs later. - -<Callout type="warn" title="Plugins need a workspace"> - Run the generator from a repository with `turbo.json`. When creating an app, - turn on Turborepo first; a plain single-folder app has nowhere for - `plugins/*`. -</Callout> +import { File, Files, Folder } from "fumadocs-ui/components/files"; +import { Tab, Tabs } from "fumadocs-ui/components/tabs"; -<Steps> - <Step> - -### Generate the package +import { ImgDocs } from "@/components/fumadocs/img"; +import generatedPage from "./create-generated-page.webp"; -Run this at the workspace root and enter a package name such as -`@acme/site-notes` when prompted: +A plugin is where a VitNode feature lives. Its pages, API, database tables, translations and AdminCP screens ship together as one package. In this lesson you generate `site-notes`, a small plugin for short announcements, and open the page and endpoint it starts with. The rest of the beginner path grows this same plugin. -<Tabs groupId="package-manager" persist items={["bun", "pnpm", "npm"]} label="Create a VitNode plugin"> +## Before you begin -```bash tab="bun" -bun create vitnode-app@canary --plugin -``` +- A running app from [Setup](/docs/dev/setup), created **with Turborepo** (or as a Monorepo App). The plugin generator refuses to run without a `turbo.json` at the workspace root. -```bash tab="pnpm" -pnpm create vitnode-app@canary --plugin -``` - -```bash tab="npm" -npm create vitnode-app@canary -- --plugin -``` +<Steps> -</Tabs> +<Step> - </Step> - <Step> +### Generate the plugin -### Run/Restart it and visit the route +From the workspace root, run the generator with `--plugin` and the name `site-notes`: -<Tabs groupId="package-manager" persist items={["bun", "pnpm", "npm"]} label="Start the plugin in development"> +<Tabs groupId="package-manager" persist items={["bun", "pnpm", "npm"]} label="Create a VitNode plugin"> ```bash tab="bun" -bun dev +bun create vitnode-app@canary site-notes --plugin ``` ```bash tab="pnpm" -pnpm dev +pnpm create vitnode-app@canary site-notes --plugin ``` ```bash tab="npm" -npm run dev +npm create vitnode-app@canary site-notes -- --plugin ``` </Tabs> - </Step> - <Step> +Answer **Yes** to "Install dependencies?". The generator then installs and builds the plugin and reloads a running `dev` server. If you answered **No**, run `pnpm install` (or your manager's install), then restart `pnpm dev`. -### Check the generated page +The name becomes the package name, the **plugin id**, the first page's URL and the root key of its translations. Use a plain name. The generator drops a scope such as `@acme/site-notes` and refuses names core already serves, such as `admin`, `api` or `users`. -Visit the route printed in the console, such as `http://localhost:3000/site-notes`. You should see a page with a title and a link to the plugin's README. +</Step> -import { ImgDocs } from '@/components/fumadocs/img' -import generatedPageImage from './generated-page-image.png' +<Step> -<ImgDocs - src={generatedPageImage} - alt="Generated plugin page with title and link to README" -/> +### See what the generator changed - </Step> -</Steps> +The generator printed one line per file it registered the plugin in: + +```txt + + Registered in apps/web/src/vitnode.api.config.ts + + Registered in apps/web/src/vitnode.config.ts +``` -## What gets generated +`siteNotesPlugin()` is now in the `plugins` array of `vitnode.config.ts` (pages, translations, AdminCP navigation) and `siteNotesApiPlugin()` in `vitnode.api.config.ts` (API routes, permissions, background work). The app's `package.json` gained `"site-notes": "workspace:*"`. A Monorepo App gets the same lines for `apps/api`. + +The new package, with the files the next lessons change: <Files> - <Folder name="plugins" defaultOpen> - <Folder name="site-notes" defaultOpen> - <Folder name="src" defaultOpen> - <Folder name="api"> - <Folder name="modules/hello"> - <File name="hello.module.ts" /> - <File name="hello.module.test.ts" /> - <File name="hello.route.ts" /> - </Folder> - <File name="builders.ts" /> - </Folder> - <Folder name="locales"> - <File name="en.json" /> - <File name="index.ts" /> - </Folder> - <Folder name="pages"> - <File name="home-page.tsx" /> + <Folder name="plugins/site-notes" defaultOpen> + <Folder name="src" defaultOpen> + <Folder name="api" defaultOpen> + <File name="builders.ts" /> + <Folder name="modules/hello" defaultOpen> + <File name="hello.module.ts" /> + <File name="hello.route.ts" /> + <File name="hello.module.test.ts" /> </Folder> - <File name="config.api.ts" /> - <File name="config.tsx" /> - <File name="plugin-info.ts" /> - <File name="routes.ts" /> </Folder> - <File name="package.json" /> - <File name="README.md" /> - <File name="vitest.config.ts" /> + <Folder name="locales" defaultOpen> + <File name="en.json" /> + </Folder> + <Folder name="pages" defaultOpen> + <File name="home-page.tsx" /> + </Folder> + <File name="config.api.ts" /> + <File name="config.tsx" /> + <File name="routes.ts" /> + <File name="manifest.gen.ts" /> </Folder> + <Folder name="types"> + <File name="api-registry.gen.d.ts" /> + </Folder> + <File name="package.json" /> </Folder> </Files> -One page at `/site-notes` whose loader calls the plugin's own `hello` endpoint, its strings, and a Vitest test for the endpoint (`pnpm test` in the plugin). `src/plugin-info.ts` sets the plugin's title and Lucide icon for [the AdminCP plugin catalog](/docs/dev/plugins/catalog), and `src/api/builders.ts` binds `buildModule` and `buildRoute` to the plugin's generated `manifest`, so API files never spell the plugin id. The generator also registers the plugin in your apps' `vitnode.config.ts` and `vitnode.api.config.ts`, installs it and builds it. +- `src/config.api.ts` declares the API half; `src/config.tsx` and `src/routes.ts` the front-end half. +- `src/api/builders.ts` binds `buildModule` and `buildRoute` to the plugin, so API files never spell its id. +- Every build writes `src/manifest.gen.ts` and `types/api-registry.gen.d.ts`. Commit them; the second one is what types the plugin's own API calls (see [API modules](/docs/dev/plugins/api/modules#how-the-fetcher-knows-your-routes)). + +</Step> + +</Steps> -The plugin name must be a valid npm name and must not be one of core's routes: `admin`, `api`, `core`, `discover`, `files`, `login`, `notifications`, `register`, `search`, `users` or `vitnode`. +## Check the result -Check the result the way your app will load it: +Open `http://localhost:3000/site-notes`. The page says **Hello from your plugin**, and its card reads **Your plugin's API answered: Hello from site-notes!**. That text came from the plugin's API, which you can also call directly: ```bash -vitnode plugin validate site-notes +curl http://localhost:3000/api/site-notes/hello +``` + +```json +{ "message": "Hello from site-notes!" } +``` + +<ImgDocs + withoutBackground + className="*:max-w-full" + src={generatedPage} + alt="The generated Hello from your plugin page at /site-notes, with a card reading Your plugin's API answered: Hello from site-notes!" +/> + +To check the plugin the way the app loads it, run the validator from the app folder: + +```bash title="apps/web" +pnpm vitnode plugin validate site-notes ``` -See [Plugin CLI](/docs/dev/cli/plugins) for what validation checks. - -## Add the next capability - -<Cards> - <Card - icon={<RouteIcon />} - title="Plugin routes" - description="Add nested layouts, dynamic URLs, loaders, metadata, and guards." - href="/docs/dev/routing" - /> - <Card - icon={<BoxIcon />} - title="API modules" - description="Add another endpoint beside the generated one, with Zod-validated inputs." - href="/docs/dev/plugins/api/modules" - /> - <Card - icon={<DatabaseIcon />} - title="Database models" - description="Put Drizzle models and migrations beside the feature that owns them." - href="/docs/dev/database" - /> - <Card - icon={<LayoutDashboardIcon />} - title="Admin Control Panel" - description="Ship staff screens, navigation, permissions, and dashboard widgets." - href="/docs/dev/plugins/admin" - /> -</Cards> +It ends with `1 plugin valid`. See [Plugin commands](/docs/dev/cli/plugins) for everything it checks. diff --git a/apps/web/content/docs/dev/plugins/generated-page-image.png b/apps/web/content/docs/dev/plugins/generated-page-image.png deleted file mode 100644 index b9c7d213c2..0000000000 Binary files a/apps/web/content/docs/dev/plugins/generated-page-image.png and /dev/null differ diff --git a/apps/web/content/docs/dev/plugins/meta.json b/apps/web/content/docs/dev/plugins/meta.json index 2a3ee7b99f..2ddd434d72 100644 --- a/apps/web/content/docs/dev/plugins/meta.json +++ b/apps/web/content/docs/dev/plugins/meta.json @@ -4,12 +4,10 @@ "icon": "Plug", "defaultOpen": true, "pages": [ - "create", "build", "catalog", - "api", "admin", - "migrate-to-tsdown", + "!api", "..." ] } diff --git a/apps/web/content/docs/dev/plugins/migrate-to-tsdown.mdx b/apps/web/content/docs/dev/plugins/migrate-to-tsdown.mdx deleted file mode 100644 index 9d2154b164..0000000000 --- a/apps/web/content/docs/dev/plugins/migrate-to-tsdown.mdx +++ /dev/null @@ -1,118 +0,0 @@ ---- -title: Migrate a plugin from SWC to tsdown -description: Move an existing VitNode plugin or adapter package from SWC and tsc-alias to the tsdown build vitnode build now uses. -icon: ArrowRightLeft ---- - -import { Tab, Tabs } from 'fumadocs-ui/components/tabs' - -Plugins generated before the switch to tsdown compiled with `tsc`, SWC and `tsc-alias`. `vitnode build` and `vitnode dev` no longer run those tools, so an older plugin needs `tsdown` instead. Your sources, exports and import paths stay the same, and the migration takes about five minutes. - -<Steps> - <Step> - -### Swap the build dependencies - -In the plugin's folder, remove SWC and `tsc-alias`, then add tsdown: - -<Tabs groupId='package-manager' persist items={['bun', 'pnpm', 'npm']} label='Replace SWC with tsdown'> - -```bash tab="bun" -bun remove @swc/cli @swc/core tsc-alias -bun add -D tsdown -``` - -```bash tab="pnpm" -pnpm remove @swc/cli @swc/core tsc-alias -pnpm add -D tsdown -``` - -```bash tab="npm" -npm uninstall @swc/cli @swc/core tsc-alias -npm i -D tsdown -``` - -</Tabs> - -Keep `typescript`: the build still uses it for declarations and type checking. If a standalone API app in your workspace builds with `tsc-alias`, keep it there. Only plugin packages switched. - - </Step> - <Step> - -### Delete `.swcrc` - -Delete `.swcrc` from the plugin, and the `/.swcrc` line from its `.npmignore`. VitNode owns the build configuration, so there is nothing to replace it with. - - </Step> - <Step> - -### Check `package.json` - -The scripts should call the CLI, and `exports` should point into `./dist/src/`, which is how the CLI recognizes a plugin package now that `.swcrc` is gone: - -```json title="package.json" -{ - "type": "module", - "scripts": { - "build:plugins": "vitnode build", - "dev": "vitnode dev" - }, - "exports": { - "./locales/*.json": "./src/locales/*.json", - "./*": { - "import": "./dist/src/*.js", - "types": "./dist/src/*.d.ts", - "default": "./dist/src/*.js" - } - } -} -``` - -If your scripts still run `tsc`, `swc` and `tsc-alias` directly, replace them with these two. - - </Step> - <Step> - -### Build the plugin - -<Tabs groupId='package-manager' persist items={['bun', 'pnpm', 'npm']} label='Build the plugin'> - -```bash tab="bun" -bun run build:plugins -``` - -```bash tab="pnpm" -pnpm build:plugins -``` - -```bash tab="npm" -npm run build:plugins -``` - -</Tabs> - -The first build removes leftovers from the old build that no source produces anymore, such as a stray `dist/vitest.config.d.ts`. Files outside `dist/src` are not touched. - - </Step> -</Steps> - -## Check the result - -Validate the plugin from the app that uses it, through the same loaders the app runs: - -```bash -vitnode plugin validate <plugin-name> -``` - -`dist/src` holds one `.js`, `.d.ts` and `.d.ts.map` per module, plus copies of your JSON and CSS. Then start `vitnode dev` in the plugin and save a file: the app reloads with the change. - -## Troubleshooting - -| Message | Fix | -| ---------------------------------------------------------- | -------------------------------------------------------------------------------------- | -| `... is not a VitNode app, API or plugin package.` | Add `tsconfig.build.json`, or point `exports` into `./dist/src/` | -| `Could not find "tsdown" from ...` | Install `tsdown` in the plugin as shown in the first step | -| `Build failed: tsc exited with code 2` | A type error. The build prints it; the old build failed on it too | -| A file you put in `dist/src` by hand disappears | Move it to `src`. Non-code files there are copied to `dist/src` on every build | - -[Build and watch a plugin](/docs/dev/plugins/build) explains what the new build does with each file. diff --git a/apps/web/content/docs/dev/plugins/page-notes-list.webp b/apps/web/content/docs/dev/plugins/page-notes-list.webp new file mode 100644 index 0000000000..d40d7343ef Binary files /dev/null and b/apps/web/content/docs/dev/plugins/page-notes-list.webp differ diff --git a/apps/web/content/docs/dev/plugins/page.mdx b/apps/web/content/docs/dev/plugins/page.mdx new file mode 100644 index 0000000000..4df97fee9b --- /dev/null +++ b/apps/web/content/docs/dev/plugins/page.mdx @@ -0,0 +1,153 @@ +--- +title: Plugin page +description: Add a page to the site-notes VitNode plugin that loads notes from the plugin API with the typed fetcher, renders them server-side, and handles an empty list. +icon: PanelsTopLeft +--- + +import { ImgDocs } from "@/components/fumadocs/img"; +import notesPage from "./page-notes-list.webp"; + +A plugin declares its pages in `src/routes.ts`, and the app serves them without route files of its own. In this lesson you add `/site-notes/notes`, whose loader fetches the notes from the endpoint you built and renders them on the server. + +## Before you begin + +- The `notes` module from [API routes](/docs/dev/plugins/api/routes), with `pnpm dev` running. + +<Steps> + +<Step> + +### Create the page + +Create `src/pages/notes-page.tsx`: + +```tsx title="plugins/site-notes/src/pages/notes-page.tsx" +import type { PluginRoutePageProps } from "@vitnode/core/routing"; + +import { definePluginRoute } from "@vitnode/core/routing"; +import { fetcher } from "@vitnode/core/tanstack/fetcher"; +import { useTranslations } from "use-intl"; + +import { manifest } from "@/manifest.gen"; + +const loadNotes = async () => { + const response = await fetcher({ + plugin: manifest.id, + method: "get", + module: "notes", + path: "/", + }); + if (!response.ok) throw new Error("Could not load notes."); + + return await response.json(); +}; + +type NotesPageData = Awaited<ReturnType<typeof loadNotes>>; + +export const route = definePluginRoute<NotesPageData>({ + load: loadNotes, +}); + +const NotesPage = ({ loaderData: notes }: PluginRoutePageProps<NotesPageData>) => { + const t = useTranslations("site-notes.notes"); + + return ( + <main className="container mx-auto flex max-w-2xl flex-col gap-6 p-4"> + <h1 className="text-2xl font-semibold tracking-tight text-balance"> + {t("title")} + </h1> + + {notes.length === 0 ? ( + <p className="text-muted-foreground leading-relaxed">{t("empty")}</p> + ) : ( + <ul className="flex flex-col gap-3"> + {notes.map(note => ( + <li className="bg-card rounded-lg border p-4" key={note.id}> + <h2 className="font-medium text-balance">{note.title}</h2> + <p className="text-muted-foreground leading-relaxed text-pretty"> + {note.body} + </p> + </li> + ))} + </ul> + )} + </main> + ); +}; + +export default NotesPage; +``` + +A page file has two exports, and VitNode reads each one by name: + +| Export | What it is | Required | +| --- | --- | --- | +| `export default` | The component that renders the page. | Yes. Without it, the app stops with _does not export a component as its default export_. | +| `export const route` | How the page behaves: `load`, `head`, `breadcrumb`, `notFound`, `parseSearch`. | No. A page with no data or title of its own skips it. | + +They live in the same file so that `lazy()` in the next step loads the loader and the component in one chunk, and `routes.ts` stays a short list of paths. `definePluginRoute` only adds types. `NotesPageData` tells `load` what to return and the component what `loaderData` holds. [Route module](/docs/dev/routing/route-module) lists everything `route` accepts. + +- **`fetcher`** runs on the server during the first render and in the browser on client navigations, with the same call. It infers `module`, `path` and the response type from your routes, so a typo is a compile error. +- **`load`** runs before the page renders, and its result arrives as `loaderData`. The HTML already contains the notes. +- **A failed request throws**, and the app shows its error page instead of an empty list. An empty array is a real answer, so the page says "No notes yet." for it. + +</Step> + +<Step> + +### Declare the route + +Open `src/routes.ts` and add the page after the generated one: + +```ts title="plugins/site-notes/src/routes.ts" +export const routes = definePluginRoutes([ + page("/site-notes", { + component: lazy(() => import("./pages/home-page")), + messages: [`${manifest.id}.home`], + }), + + // [!code ++:4] + page("/site-notes/notes", { + component: lazy(() => import("./pages/notes-page")), + messages: ["site-notes.notes"], + }), +]); +``` + +`lazy()` keeps the page in its own chunk. `messages` names the translation namespace the page renders, so its strings load with the code. + +</Step> + +<Step> + +### Add the strings + +Add a `notes` group under the plugin's root key in `src/locales/en.json`. This excerpt shows only the new keys; keep `home`: + +```json title="plugins/site-notes/src/locales/en.json" +{ + "site-notes": { + "notes": { + "title": "Site notes", + "empty": "No notes yet." + } + } +} +``` + +</Step> + +</Steps> + +## Check the result + +Open `http://localhost:3000/site-notes/notes`. **Site notes** lists **Welcome to site notes** and **Office closed on Friday**. View the page source. Both titles are in the HTML, because the loader ran on the server. + +<ImgDocs + withoutBackground + className="*:max-w-full" + src={notesPage} + alt="The Site notes page at /site-notes/notes listing the notes Welcome to site notes and Office closed on Friday, each in a bordered card" +/> + +Lists that refetch on the client, search parameters and caching build on this page; see [Fetching](/docs/dev/fetcher) and [Routing](/docs/dev/routing). diff --git a/apps/web/content/docs/dev/routing/breadcrumbs.mdx b/apps/web/content/docs/dev/routing/breadcrumbs.mdx index 6368b74da2..1155f20613 100644 --- a/apps/web/content/docs/dev/routing/breadcrumbs.mdx +++ b/apps/web/content/docs/dev/routing/breadcrumbs.mdx @@ -1,173 +1,152 @@ --- title: Breadcrumbs -description: Contribute one localized crumb per plugin route and let VitNode assemble the trail for public pages and AdminCP screens. +description: Add localized crumbs to the VitNode breadcrumb trail from plugin routes, layouts, AdminCP pages and the app's own file routes. icon: Milestone --- -import { DatabaseIcon, LayoutDashboardIcon, MapIcon } from 'lucide-react' -import { Tab, Tabs } from 'fumadocs-ui/components/tabs' - -VitNode renders breadcrumb trails automatically in both the public site header and the AdminCP shell. **A route joins the trail by declaring a crumb**, and the trail reads parent to child: +VitNode renders the breadcrumb trail in the public header and in the AdminCP shell. Each route that declares a crumb adds one entry, and the trail reads from parent to child: ```text -Home / Notes / Getting Started +Site notes / Office closed on Friday ``` -A route declares only what its own crumb should display. VitNode manages the rest: accessible `<nav>` markup, separators, locale-aware links, and `aria-current="page"` semantics. No manual link construction, no duplicated trails, and zero drama. +A route declares only the label of its own crumb. The shell renders the `<nav>` landmark, the separators, the locale-aware links and `aria-current="page"` on the last crumb. -## Static crumb +## Add a crumb to a plugin route -Declare a breadcrumb function directly inside `definePluginRoute`. Because it renders within the route's declared namespaces, `useTranslations` works out of the box: +Set `breadcrumb` in the page module's `route` to a component that returns the label. It renders inside the route's declared `messages`, so `useTranslations` works: -```tsx title="plugins/site-notes/src/pages/notes-layout.tsx" -import { definePluginRoute } from '@vitnode/core/routing' +```tsx title="plugins/site-notes/src/pages/notes-page.tsx" +import { defineRoute } from '@vitnode/core/tanstack/plugin-routes' import { useTranslations } from 'use-intl' -// [!code ++:7] -export const route = definePluginRoute({ - breadcrumb: () => { - const t = useTranslations('@acme/site-notes.home') - - return t('title') - }, -}) +const NotesCrumb = () => { + const t = useTranslations('site-notes.notes') -const NotesLayout = ({ children }: { children: React.ReactNode }) => { - return ( - <div className="container mx-auto flex max-w-4xl flex-col gap-6 p-4"> - <header className="border-b pb-4"> - <h1 className="text-2xl font-bold tracking-tight text-balance"> - Notes - </h1> - </header> - <main>{children}</main> - </div> - ) + return t('title') } -export default NotesLayout +export const route = defineRoute({ + breadcrumb: NotesCrumb, // [!code ++] +}) ``` -{/* Image prompt: Modern web header showing breadcrumb trail Home / Notes with Notes highlighted in dark theme, 1440x900. */} +{/* Image prompt: The public header of a VitNode app at /site-notes/notes showing the breadcrumb trail with the single crumb Site notes as the current page. Dark theme, 1440x900. */} -## Dynamic crumb from loader +## Use loader data in a crumb -When a route is dynamic (such as `/notes/:slug`), its breadcrumb can read directly from the route's `loaderData`. This ensures the trail shows human-readable titles instead of raw IDs, without firing a second network request: +A crumb receives `{ loaderData, params, search }`, the same values the page gets. On a dynamic page, show the record's name instead of its id, without a second request. This extends the note page from [Add a route](/docs/dev/routing/first-route), where `loadNote` and `NotePageData` are defined: -```tsx title="plugins/site-notes/src/pages/note-page-slug.tsx" -import { - definePluginRoute, - type PluginRoutePageProps, -} from '@vitnode/core/routing' -import { useTranslations } from 'use-intl' +```tsx title="plugins/site-notes/src/pages/note-page.tsx" +export const route = defineRoute({ + load: async ({ context, params }) => + await loadNote(context.queryClient, params.id), + head: ({ loaderData }) => ({ + title: loaderData?.title, + description: loaderData?.body, + }), + breadcrumb: ({ loaderData }) => loaderData.title, // [!code ++] +}) +``` -interface Note { - content: string - title: string -} +Declare `breadcrumb` after `load`, so TypeScript types `loaderData` from the loader. A crumb sits under every crumb of the routes above it. When the list and the note page are siblings rather than a [layout](/docs/dev/routing/layouts) and its child, the trail shows only the note. Put both under a `layout()` with its own crumb to get `Site notes / Office closed on Friday`. -export const route = definePluginRoute({ - load: async ({ params }): Promise<Note> => { - return { - content: 'Loaded securely from your plugin loader.', - title: `Note: ${params.slug}`, - } - }, - head: ({ loaderData, params }) => ({ - title: loaderData?.title ?? params.slug, - }), - // [!code ++:5] - breadcrumb: ({ loaderData }) => { - const t = useTranslations('@acme/site-notes.home') +## Leave a route out - return `${t('note')}: ${loaderData.title}` - }, -}) +A route without `breadcrumb` adds nothing, and the crumbs above it stay. Write `breadcrumb: false` (or `null`) to make that explicit, for example on an `index()` page whose layout already names the screen. The trail then ends at the deepest route that declares a crumb, and that crumb gets `aria-current="page"`. -const NotePage = ({ loaderData }: PluginRoutePageProps<Note>) => { - return ( - <article className="container mx-auto flex max-w-3xl flex-col gap-4 p-4"> - <h2 className="text-3xl font-semibold tracking-tight text-balance"> - {loaderData.title} - </h2> - <p className="text-muted-foreground leading-relaxed text-pretty"> - {loaderData.content} - </p> - </article> - ) -} +## Several crumbs from one route -export default NotePage +Some pages know more of the trail than their own label. A forum topic, for example, can list every parent forum from its loader data. Declare a **group** with `routeBreadcrumbGroup` from `@vitnode/core/tanstack/plugin-routes`, and render the items yourself with the breadcrumb components: + +```tsx title="plugins/site-notes/src/pages/note-page.tsx" +import { + BreadcrumbItem, + BreadcrumbLink, + BreadcrumbPage, + BreadcrumbSeparator, +} from '@vitnode/core/components/ui/breadcrumb' +import { HrefLink } from '@vitnode/core/tanstack/link' +import { defineRoute, routeBreadcrumbGroup } from '@vitnode/core/tanstack/plugin-routes' + +export const route = defineRoute({ + load: async ({ context, params }) => + await loadNote(context.queryClient, params.id), + breadcrumb: routeBreadcrumbGroup<NotePageData>(({ loaderData }) => ( + <> + <BreadcrumbItem> + <BreadcrumbLink render={<HrefLink href="/site-notes/notes" />}> + Site notes + </BreadcrumbLink> + </BreadcrumbItem> + <BreadcrumbSeparator /> + <BreadcrumbItem> + <BreadcrumbPage>{loaderData.title}</BreadcrumbPage> + </BreadcrumbItem> + </> + )), +}) ``` -The inline `breadcrumb` receives `{ loaderData, params, search }`—the exact same typed context passed to `head` and the page component. +The shell adds the separator before the group. Inside it, you add the separators between your own items and mark the last one with `BreadcrumbPage`. Translate the labels with `useTranslations` in a small component, as in the first example. -{/* Image prompt: Header breadcrumb trail showing Home / Notes / Note: getting-started with the final crumb active, 1440x900. */} +<Callout type="warn" title="A single crumb returns only its label"> + A plain `breadcrumb` component returns text or an inline element, such as an + icon with text. Never render `<nav>`, links or separators in it, because the + shell already does. Only a group renders `BreadcrumbItem` and friends. +</Callout> -## Omitting a route +## AdminCP crumbs -Silence is the default: a route that declares no `breadcrumb` adds none, and the crumbs above it stay intact. Set `breadcrumb: false` when you want that silence to be deliberate—an `index()` page whose parent layout already names the screen, say: +An AdminCP route declares a crumb the same way. To label the crumbs from the AdminCP navigation, so they match the sidebar, use `adminBreadcrumb` from `@vitnode/core/tanstack/admin`. `segments` is the path below `/admin`: -```tsx title="plugins/site-notes/src/pages/notes-index-page.tsx" +```tsx title="plugins/site-notes/src/pages/admin-notes-page.tsx" import { definePluginRoute } from '@vitnode/core/routing' +import { adminBreadcrumb } from '@vitnode/core/tanstack/admin' -// [!code ++:3] export const route = definePluginRoute({ - breadcrumb: false, + breadcrumb: adminBreadcrumb({ segments: ['site-notes'] }), // [!code ++] }) +``` + +Each segment gets the title of the navigation entry whose link matches it, and a segment with no entry gets a readable version of its name. Pass `overrideLastLabel` when the page knows a better name for the last crumb, such as a record's title. The AdminCP does not look labels up on its own, so a route without `breadcrumb` adds no crumb there either. + +## Crumbs on the app's own routes + +A file route in your app declares its crumb as `staticData.breadcrumb`. App routes do not get plugin `messages`, so a crumb that renders strings wraps itself in `RouteMessages` from `@vitnode/core/tanstack/i18n`: -const NotesIndexPage = () => { - return ( - <div> - <p className="text-muted-foreground leading-relaxed text-pretty"> - Welcome to Site Notes. - </p> - </div> - ) +```tsx title="apps/web/src/site/pricing/breadcrumb.tsx" +import { RouteMessages } from '@vitnode/core/tanstack/i18n' +import { useTranslations } from 'use-intl' + +const PricingLabel = () => { + const t = useTranslations('site.pricing') + + return t('title') } -export default NotesIndexPage +export const PricingBreadcrumb = () => ( + <RouteMessages namespaces={['site.pricing']}> + <PricingLabel /> + </RouteMessages> +) ``` -Both spellings do the same thing. The trail simply ends at the deepest route that does declare a crumb, and that crumb is the one marked `aria-current="page"`. +```tsx title="apps/web/src/routes/_main/pricing.tsx" +import { createFileRoute } from '@tanstack/react-router' -## Trail behavior +import { PricingBreadcrumb } from '@/site/pricing/breadcrumb' +import { PricingPage } from '@/site/pricing/pricing-page' -| Declaration | Behavior in breadcrumb trail | -| :---------------------- | :-------------------------------------------------------------------------- | -| `breadcrumb: Component` | Contributes one crumb with access to `loaderData`, `params`, and `search` | -| `breadcrumb: false` | Omitted from trail; parent crumbs remain visible | -| Omitted / `undefined` | The default: omitted from trail | -| Intermediate crumbs | Automatically rendered as locale-aware links to their respective route URLs | -| Leaf crumb (last item) | Rendered as text without a link and marked with `aria-current="page"` | +export const Route = createFileRoute('/_main/pricing')({ + staticData: { breadcrumb: <PricingBreadcrumb /> }, // [!code ++] + component: PricingPage, +}) +``` -<Callout type="warn" title="Return text or inline elements, not a trail"> - A breadcrumb component should return text or an inline element (like an icon with text). Never render `<Breadcrumb>`, `<nav>`, links, or separators—the shell creates the landmark and manages links and separators for you. -</Callout> +`site.pricing` is a namespace from your app's own locale files; see [Your app's own strings](/docs/dev/i18n#your-apps-own-strings). -<Callout type="info" title="AdminCP labels"> - In the AdminCP, breadcrumbs seamlessly coordinate with the admin navigation - declared in `admin/nav.tsx`. When a route matches a sidebar item, its - translated label is resolved automatically. -</Callout> +## Related -<Cards> - <Card - icon={<MapIcon />} - title="Plugin Routes" - description="Declare the route tree where layouts, pages, and dynamic segments define crumbs." - href="/docs/dev/routing" - /> - <Card - icon={<DatabaseIcon />} - title="Data Fetching" - description="Fetch data in loaders so crumbs and pages receive typed loaderData." - href="/docs/dev/fetcher" - /> - <Card - icon={<LayoutDashboardIcon />} - title="AdminCP Pages" - description="Mount plugin screens, navigation links, and permissions in the AdminCP." - href="/docs/dev/plugins/admin" - /> -</Cards> +- [Layouts](/docs/dev/routing/layouts): one crumb for a layout and all its pages. +- [AdminCP pages](/docs/dev/plugins/admin): a complete AdminCP screen. diff --git a/apps/web/content/docs/dev/routing/first-route.mdx b/apps/web/content/docs/dev/routing/first-route.mdx new file mode 100644 index 0000000000..937f20cad6 --- /dev/null +++ b/apps/web/content/docs/dev/routing/first-route.mdx @@ -0,0 +1,209 @@ +--- +title: Add a route +description: Add a dynamic /site-notes/notes/:id page to a VitNode plugin, with a loader that fetches one note, sets the page title, and answers 404 for a missing note. +icon: FilePlus +--- + +import { Tab, Tabs } from 'fumadocs-ui/components/tabs' + +A plugin page with a URL parameter needs three things: a declaration in `routes.ts`, a loader that reads the parameter, and an answer for a record that does not exist. In this guide you add `/site-notes/notes/:id`, which shows one note, uses its title as the page title, and answers `404` for an unknown id. + +## Before you begin + +- The `/site-notes/notes` page from [Plugin page](/docs/dev/plugins/page). +- The `GET /api/site-notes/notes/{id}` route from [API routes](/docs/dev/plugins/api/routes), with `pnpm dev` running. + +<Steps> + +<Step> + +### Add the TanStack packages + +The page loads the note through TanStack Query (`queryOptions`, `QueryClient`) and throws `notFound()` from TanStack Router. A generated plugin depends on neither, so add both. Run this in the plugin's folder, `plugins/site-notes`: + +<Tabs groupId="package-manager" persist items={['bun', 'pnpm', 'npm']} label="Install the TanStack packages"> + +```bash tab="bun" +bun add @tanstack/react-query @tanstack/react-router +``` + +```bash tab="pnpm" +pnpm add @tanstack/react-query @tanstack/react-router +``` + +```bash tab="npm" +npm install @tanstack/react-query @tanstack/react-router +``` + +</Tabs> + +Both now appear under `dependencies` in `plugins/site-notes/package.json`. + +</Step> + +<Step> + +### Create the page module + +Create `src/pages/note-page.tsx`: + +```tsx title="plugins/site-notes/src/pages/note-page.tsx" +import type { QueryClient } from "@tanstack/react-query"; +import type { PluginRoutePageProps } from "@vitnode/core/routing"; + +import { queryOptions } from "@tanstack/react-query"; +import { notFound } from "@tanstack/react-router"; +import { LOADER_DATA_QUERY_META } from "@vitnode/core/lib/query-client"; +import { fetcher } from "@vitnode/core/tanstack/fetcher"; +import { HrefLink } from "@vitnode/core/tanstack/link"; +import { defineRoute } from "@vitnode/core/tanstack/plugin-routes"; +import { useTranslations } from "use-intl"; + +import { manifest } from "@/manifest.gen"; + +const noteQueryOptions = (id: number) => + queryOptions({ + queryKey: [manifest.id, "notes", id], + queryFn: async () => { + const response = await fetcher({ + plugin: manifest.id, + method: "get", + module: "notes", + path: "/{id}", + args: { params: { id } }, + }); + if (response.status === 404) return null; + if (!response.ok) throw new Error("Could not load the note."); + + return await response.json(); + }, + }); + +const loadNote = async (queryClient: QueryClient, idParam: string) => { + const id = Number(idParam); + if (!Number.isInteger(id) || id < 1) throw notFound(); + + const note = await queryClient.query({ + ...noteQueryOptions(id), + meta: LOADER_DATA_QUERY_META, + staleTime: "static", + }); + if (!note) throw notFound(); + + return note; +}; + +type NotePageData = Awaited<ReturnType<typeof loadNote>>; + +export const route = defineRoute({ + load: async ({ context, params }) => + await loadNote(context.queryClient, params.id), + head: ({ loaderData }) => ({ + title: loaderData?.title, + description: loaderData?.body, + }), +}); + +const NotePage = ({ loaderData: note }: PluginRoutePageProps<NotePageData>) => { + const t = useTranslations("site-notes.notes"); + + return ( + <main className="container mx-auto flex max-w-2xl flex-col gap-4 p-4"> + <HrefLink className="text-muted-foreground text-sm" href="/site-notes/notes"> + {t("back")} + </HrefLink> + <h1 className="text-2xl font-semibold tracking-tight text-balance"> + {note.title} + </h1> + <p className="leading-relaxed text-pretty">{note.body}</p> + </main> + ); +}; + +export default NotePage; +``` + +- **`params.id`** is the `:id` segment of the URL, always a string. The API expects a positive number, so the loader converts it and answers `notFound()` for anything else instead of sending a request that would fail validation. +- **`defineRoute`** gives `load` a `context.queryClient`. Loading through the query cache with `staleTime: "static"` means a hover preload and the click that follows share one request. `LOADER_DATA_QUERY_META` tells the cache not to send the note to the browser a second time, because the loader's result already travels with the HTML. See [Preloading](/docs/dev/routing/preloading). +- **`throw notFound()`** stops the loader and renders the app's 404 page with a real `404` status. A `404` from the API becomes `null`, so it takes the same path. +- **`head`** runs after `load`, so it can use the note for the `<title>` and description. Declare `load` above `head`, or TypeScript cannot type `loaderData`. + +</Step> + +<Step> + +### Declare the route + +Open `src/routes.ts` and add the page after `/site-notes/notes`: + +```ts title="plugins/site-notes/src/routes.ts" + page("/site-notes/notes", { + component: lazy(() => import("./pages/notes-page")), + messages: ["site-notes.notes"], + }), + + // [!code ++:4] + page("/site-notes/notes/:id", { + component: lazy(() => import("./pages/note-page")), + messages: ["site-notes.notes"], + }), +``` + +`:id` marks a dynamic segment. The page reuses the `site-notes.notes` namespace, because its only string sits next to the list's strings. + +</Step> + +<Step> + +### Add the string + +Add `back` to the `notes` group in `src/locales/en.json`. Keep the keys you already have: + +```json title="plugins/site-notes/src/locales/en.json" +{ + "site-notes": { + "notes": { + "title": "Site notes", + "empty": "No notes yet.", + "back": "All notes" // [!code ++] + } + } +} +``` + +</Step> + +<Step> + +### Link the list to the page + +In `src/pages/notes-page.tsx`, import `HrefLink` and wrap each note's title in a link: + +```tsx title="plugins/site-notes/src/pages/notes-page.tsx" +import { HrefLink } from "@vitnode/core/tanstack/link"; // [!code ++] + +// inside notes.map(...) +<h2 className="font-medium text-balance"> + {/* [!code ++:3] */} + <HrefLink className="hover:underline" href={`/site-notes/notes/${note.id}`}> + {note.title} + </HrefLink> +</h2> +``` + +A plugin page cannot use the router's typed `<Link to>`, because the app's route tree does not know plugin URLs at compile time. [Navigation](/docs/dev/routing/navigation#link-from-a-plugin-page) explains `HrefLink`. + +</Step> + +</Steps> + +## Check the result + +1. Open `http://localhost:3000/site-notes/notes` and click a note's title. The note opens at `/site-notes/notes/<id>`, and the browser tab shows the note's title. +2. Click **All notes** to go back to the list. +3. Open `http://localhost:3000/site-notes/notes/999999` and `http://localhost:3000/site-notes/notes/abc`. Both show the app's not-found page inside the normal header. + +## Related + +- [Breadcrumbs](/docs/dev/routing/breadcrumbs): show the note's title in the breadcrumb trail. +- [Route module](/docs/dev/routing/route-module): every option a page module accepts. diff --git a/apps/web/content/docs/dev/routing/index.mdx b/apps/web/content/docs/dev/routing/index.mdx index ed371b4d7d..abf5bbea23 100644 --- a/apps/web/content/docs/dev/routing/index.mdx +++ b/apps/web/content/docs/dev/routing/index.mdx @@ -1,641 +1,79 @@ --- title: Routing -description: Declare plugin-owned URLs as a nested route tree with lazy pages, loaders, metadata, messages, and breadcrumbs. +description: In a VitNode plugin, routes.ts declares the URLs and lazy page modules render them. This page explains how, and maps every routing task to the guide that covers it. icon: Route --- -import { - DatabaseIcon, - LanguagesIcon, - LayoutDashboardIcon, - LoaderCircleIcon, - MilestoneIcon, - TriangleAlertIcon, -} from 'lucide-react' -import { Tab, Tabs } from 'fumadocs-ui/components/tabs' import { ImgDocs } from '@/components/fumadocs/img' import generatedPageImage from './generated-page-image.png' -Start by [creating a plugin](/docs/dev/plugins/create). A plugin's `src/routes.ts` -is its promise to the app: which URLs it owns, and which module renders each one. -The host turns that promise into lazy TanStack Start routes—no copied page files, -no drama. +A plugin declares its pages in `src/routes.ts`, and the app that installs it serves them. The host turns each declaration into a lazy TanStack Start route, so a plugin never copies page files into the app and an app never imports a plugin's pages by hand. -## Simple route +## How a plugin route works -You can declare a single page with a path and a lazy component: +A route has two halves. + +**The declaration** lives in `src/routes.ts`. It names the URL, the page module and the translation namespaces the page renders. This is the routes file the plugin generator writes for `site-notes`: ```ts title="plugins/site-notes/src/routes.ts" import { definePluginRoutes, lazy, page } from '@vitnode/core/routing' +import { manifest } from './manifest.gen' + export const routes = definePluginRoutes([ - page('/notes', { - component: lazy(() => import('./pages/note-page')), - messages: ['@acme/site-notes.home'], + page('/site-notes', { + component: lazy(() => import('./pages/home-page')), + messages: [`${manifest.id}.home`], }), ]) ``` -```tsx title="plugins/site-notes/src/pages/note-page.tsx" -import { useTranslations } from 'use-intl' - -const NotePage = () => { - const t = useTranslations('@acme/site-notes.home') - - return ( - <div className="container mx-auto max-w-2xl p-4"> - <h1 className="text-2xl font-semibold tracking-tight text-balance"> - {t('title')} - </h1> - </div> - ) -} - -export default NotePage -``` - -The `messages` array specifies which translation namespaces this page requires. A namespace must always be a **dotted path** into your plugin's message tree (such as `@acme/site-notes.home` rather than the bare plugin ID), allowing VitNode to only download the strings that this screen renders. For more details on message structure and limits, see the [Namespaces](/docs/dev/i18n/namespaces) and [Translating Pages](/docs/dev/i18n/pages) guides. +Open `http://localhost:3000/site-notes` and the generated page renders inside the app's layout: <ImgDocs src={generatedPageImage} - alt="Generated plugin page with title and link to README" + alt="The generated site-notes page at /site-notes, with the heading Hello from your plugin and a card showing the plugin API's answer, Hello from site-notes!" /> -### Render without the main layout - -Every route picks a shell with `area`. The default is `'main'`, which frames the -page in the host's public layout—header, breadcrumbs, navigation, footer. - -Set `area: 'blank'` for a page that should stand on its own: an embed, a kiosk -screen, a print view, a checkout step with nothing to wander off to. - -```ts title="plugins/site-notes/src/routes.ts" -import { definePluginRoutes, lazy, page } from '@vitnode/core/routing' - -export const routes = definePluginRoutes([ - // [!code ++:4] - page('/notes/embed', { - area: 'blank', - component: lazy(() => import('./pages/notes-embed-page')), - }), -]) -``` - -A blank page is mounted at the root of the host's route tree, so it still gets -the document, the theme, the providers, and translations. It simply has no -chrome around it—which also means no breadcrumb trail, so give the page its own -way back. - -| `area` | Renders inside | -| :-------- | :--------------------------------------------------------- | -| `'main'` | The public layout: header, breadcrumbs, footer (default) | -| `'blank'` | Nothing but the app root—no chrome at all | -| `'admin'` | The [AdminCP shell](#admincp-routes): sidebar, staff guard | - -Only a top-level route chooses an area. Every route nested in a `layout()` -renders in the shell its layout renders in, so it may not declare one of its -own. - -## Dynamic route - -Use `:slug` for dynamic segments. VitNode converts it to TanStack Start's -internal `$slug` spelling while keeping your plugin portable. - -```ts title="plugins/site-notes/src/routes.ts" -import { definePluginRoutes, lazy, page } from '@vitnode/core/routing' - -export const routes = definePluginRoutes([ - page('/notes/:slug', { - component: lazy(() => import('./pages/note-page-slug')), - }), -]) -``` - -```tsx title="plugins/site-notes/src/pages/note-page-slug.tsx" -import type { PluginRoutePageProps } from '@vitnode/core/routing' - -const NotePage = ({ params }: PluginRoutePageProps) => { - return ( - <article className="container mx-auto max-w-3xl p-4"> - <h2 className="text-3xl font-semibold tracking-tight text-balance"> - {params.slug} - </h2> - </article> - ) -} - -export default NotePage -``` - -{/* Image prompt: Browser showing /notes/getting-started with a clean modern page heading 'getting-started' in dark theme, max-w-3xl container, 1440x900. */} - -## Metadata (Head) - -The `head` function returns the page's `<title>` and `<meta>` tags. It runs -after the loader, so it can use the data to make a unique title, description, and other SEO tags. - -```tsx title="plugins/site-notes/src/pages/note-page-slug.tsx" -import { - // [!code ++:1] - definePluginRoute, - type PluginRoutePageProps, -} from '@vitnode/core/routing' - -// [!code ++:6] -export const route = definePluginRoute({ - head: ({ params }) => ({ - description: 'A note delivered by the Site notes plugin.', - title: params.slug, - }), -}) - -const NotePage = ({ params }: PluginRoutePageProps) => { - return ( - <article className="container mx-auto max-w-3xl p-4"> - <h2 className="text-3xl font-semibold tracking-tight text-balance"> - {params.slug} - </h2> - </article> - ) -} - -export default NotePage -``` - -### Localizing metadata - -`head` runs outside the React component tree—during SSR and on every -navigation—so hooks like `useTranslations` cannot reach it. Instead, `head` -receives `t`, a translator over the namespaces the route declared in `messages`: - -```ts title="plugins/site-notes/src/routes.ts" -page('/notes/:slug', { - component: lazy(() => import('./pages/note-page-slug')), - // [!code ++:1] - messages: ['@acme/site-notes.home'], -}) -``` - -```tsx title="plugins/site-notes/src/pages/note-page-slug.tsx" -import { definePluginRoute } from '@vitnode/core/routing' - -export const route = definePluginRoute({ - // [!code ++:4] - head: ({ params, t }) => ({ - description: t('@acme/site-notes.home.desc'), - title: `${t('@acme/site-notes.home.note')}: ${params.slug}`, - }), -}) -``` - -Keys are **full dotted paths**, namespace included. A component scopes itself -once with `useTranslations('@acme/site-notes.home')` and then asks for -`t('title')`; in `head` there is no component to scope, so the whole key is -spelled out. +**The page module** is the file `lazy()` points at. Its default export is the page component. An optional `route` export adds a loader, metadata, a breadcrumb and a not-found screen. The smallest useful one looks like this: -Values interpolate the same way they do in a component: +```tsx title="plugins/site-notes/src/pages/about-page.tsx" +import { defineRoute } from '@vitnode/core/tanstack/plugin-routes' -```tsx -head: ({ t }) => ({ - title: t('@acme/site-notes.home.greeting', { name: 'Ada' }), +export const route = defineRoute({ + head: () => ({ title: 'About site notes' }), }) -``` - -<Callout type="info" title="No extra request"> - The route's namespaces are already fetched before `head` runs—the loader warms - them, and `head` runs after the loader—so translating metadata costs a cache - read, not a round trip. -</Callout> - -`load` receives the same `t`, for a title that depends on data you are already -fetching: -```tsx title="plugins/site-notes/src/pages/note-page-slug.tsx" -export const route = definePluginRoute({ - load: async ({ params, t }) => { - const note = await findNote(params.slug) +const AboutPage = () => <h1>About site notes</h1> - return { heading: `${t('@acme/site-notes.home.note')}: ${note.title}` } - }, - head: ({ loaderData }) => ({ title: loaderData?.heading }), -}) +export default AboutPage ``` -<Callout type="warn" title="Declare the namespace first"> - Calling `t` on a route that declares no `messages` throws, naming the - namespace to add. Echoing the key back instead would ship - `@acme/site-notes.home.title` into a `<title>`, where nothing would surface it - until it showed up in a search result. -</Callout> - -## Loaders - -Fetch data in `load`. It runs on the server during SSR and on the client for subsequent navigations. Data returned from `load` is automatically passed to both `head` (for dynamic metadata) and the page component as `loaderData`. - -<Callout type="info" title="Declare load above head"> - Always declare `load` **above** `head` in `definePluginRoute`. TypeScript - infers the type of `loaderData` in `head` directly from the return type of - `load`. -</Callout> - -```tsx title="plugins/site-notes/src/pages/note-page-slug.tsx" -import { - definePluginRoute, - type PluginRoutePageProps, -} from '@vitnode/core/routing' - -interface Note { - content: string - title: string -} - -export const route = definePluginRoute({ - // [!code ++:6] - load: async ({ params }): Promise<Note> => { - return { - content: 'Loaded securely from your plugin loader.', - title: `Note: ${params.slug}`, - } - }, - head: ({ loaderData, params }) => ({ - description: loaderData?.content, - title: loaderData?.title ?? params.slug, - }), -}) - -const NotePage = ({ loaderData }: PluginRoutePageProps<Note>) => { - return ( - <article className="container mx-auto flex max-w-3xl flex-col gap-4 p-4"> - <h1 className="text-3xl font-semibold tracking-tight text-balance"> - {loaderData.title} - </h1> - <p className="text-muted-foreground leading-relaxed text-pretty"> - {loaderData.content} - </p> - </article> - ) -} - -export default NotePage -``` +The split matters for bundle size. `routes.ts` is part of the app's initial bundle, because the router needs every path before any page loads. Everything in the page module downloads only when a visitor opens or preloads that page. So the declaration holds what the router must know up front, and the page module holds the rest: -{/* Image prompt: Browser preview showing the rendered note page with title and content in container, 1440x900. */} +| In `routes.ts` (read before the page loads) | In the page module's `route` (loaded with the page) | +| :--------------------------------------------------------------------------- | :-------------------------------------------------- | +| `component`, `messages`, `area`, `requires`, `search`, `cache`, `preload`, `pendingComponent` | `load`, `head`, `breadcrumb`, `notFound`, `parseSearch` | -## Breadcrumbs +`messages` lists dotted paths into your plugin's message tree, such as `site-notes.notes`. Use the narrowest branch the page renders, so the page downloads only the strings it shows. [Namespaces](/docs/dev/i18n/namespaces) explains the limits. -Declare a crumb directly in `definePluginRoute` to contribute to the public header or AdminCP breadcrumb trail. +Paths use `:param` for a dynamic segment and a trailing `*` for a catch-all. VitNode converts them to TanStack Start's `$param` spelling for you, which keeps a plugin portable between hosts. -Because `breadcrumb` renders as a React component within the route's declared namespaces, `useTranslations` from `use-intl` works out of the box: - -```tsx title="plugins/site-notes/src/pages/notes-layout.tsx" -import { definePluginRoute } from '@vitnode/core/routing' -import { useTranslations } from 'use-intl' - -// [!code ++:7] -export const route = definePluginRoute({ - breadcrumb: () => { - const t = useTranslations('@acme/site-notes.home') - - return t('title') - }, -}) -``` - -For dynamic routes, the crumb receives `{ loaderData }` and can combine dynamic records with localized copy: - -```tsx title="plugins/site-notes/src/pages/note-page-slug.tsx" -import { definePluginRoute } from '@vitnode/core/routing' -import { useTranslations } from 'use-intl' - -export const route = definePluginRoute({ - load: async ({ params }): Promise<Note> => { - return { - content: 'Loaded securely from your plugin loader.', - title: params.slug, - } - }, - head: ({ loaderData, params }) => ({ - title: loaderData?.title ?? params.slug, - }), - // [!code ++:5] - breadcrumb: ({ loaderData }) => { - const t = useTranslations('@acme/site-notes.home') - - return `${t('note')}: ${loaderData.title}` - }, -}) -``` - -For nested trails, deferred crumbs, and AdminCP trails, see the [Breadcrumbs guide](/docs/dev/routing/breadcrumbs). - -## Loading states - -A route's `pendingComponent` is the skeleton shown while its page suspends, for example on a client-only route or a query the loader does not await. Client navigations keep the current page on screen with a top progress bar instead. - -```ts title="plugins/site-notes/src/routes.ts" -import { definePluginRoutes, lazy, page } from '@vitnode/core/routing' -// [!code ++:1] -import { TablePendingSkeleton } from '@vitnode/core/tanstack/pending' - -export const routes = definePluginRoutes([ - page('/notes', { - component: lazy(() => import('./pages/notes-page')), - // [!code ++:1] - pendingComponent: TablePendingSkeleton, - }), -]) -``` +## Find your task -<Callout type="warn" title="A pending component is not code-split"> - A router draws it *before* the page's own chunk has arrived, so there is - nothing to wait for it—TanStack Router never splits a `pendingComponent`, and - neither does VitNode. It is imported outright into the initial bundle. Keep it - to a skeleton. -</Callout> - -VitNode ships pre-built skeleton shapes from `@vitnode/core/tanstack/pending`: `TablePendingSkeleton`, `FeedPendingSkeleton`, `FormPendingSkeleton`, `CardsPendingSkeleton`, `ProfilePendingSkeleton`, `AuthPendingSkeleton`, and `RoutePendingSpinner`. - -To customize skeleton props (such as `rows` or `className`), rename the file to `routes.tsx` and return a JSX element: - -```tsx title="plugins/site-notes/src/routes.tsx" -page('/notes', { - component: lazy(() => import('./pages/notes-page')), - // [!code ++:1] - pendingComponent: () => <FeedPendingSkeleton rows={4} />, -}) -``` - -If `pendingComponent` is omitted, the route falls back to the application's global `defaultPendingComponent`. Learn more in the [Loading States guide](/docs/dev/routing/loading). - -## Missing pages - -When a loader cannot find what the URL requested, throw `notFound()` from `@tanstack/react-router` to activate the route's `notFound` component: - -```tsx title="plugins/site-notes/src/pages/note-page-slug.tsx" -import { notFound } from '@tanstack/react-router' -import { definePluginRoute } from '@vitnode/core/routing' - -export const route = definePluginRoute({ - load: async ({ params }) => { - const note = await findNote(params.slug) - - // [!code ++:3] - if (!note) { - throw notFound() - } - - return note - }, - - // [!code ++:5] - notFound: () => ( - <div className="container mx-auto p-4"> - <p className="text-muted-foreground leading-relaxed">No such note.</p> - </div> - ), -}) -``` - -Unlike `pendingComponent`, `notFound` is part of the lazy page chunk and costs unvisited routes nothing. If omitted, the route falls through to the application's global `defaultNotFoundComponent`, preserving the application layout and navigation shell. Learn more in the [Errors & Not Found guide](/docs/dev/routing/not-found). - -## Catch-all routes - -A `*` segment matches every remaining segment of the URL, allowing a single route to own an entire subtree. It must be the final segment of a path and is only permitted on `page()` declarations (a layout ending in `*` would match before its child routes): - -```ts title="plugins/site-notes/src/routes.ts" -import { definePluginRoutes, lazy, page } from '@vitnode/core/routing' - -export const routes = definePluginRoutes([ - // [!code ++:3] - page('/notes/*', { - component: lazy(() => import('./pages/notes-catch-all')), - }), -]) -``` - -The page reads the matched trailing path from `params._splat`: - -```tsx title="plugins/site-notes/src/pages/notes-catch-all.tsx" -import type { PluginRoutePageProps } from '@vitnode/core/routing' - -const NotesCatchAll = ({ params }: PluginRoutePageProps) => { - const segments = (params._splat ?? '').split('/').filter(Boolean) - - return ( - <div className="container mx-auto max-w-3xl p-4"> - <p className="text-muted-foreground leading-relaxed"> - {segments.join(' / ')} - </p> - </div> - ) -} - -export default NotesCatchAll -``` - -## Route protection (requires) - -Protect pages or entire layouts by declaring `requires`. VitNode checks the visitor's authentication state before the route chunk downloads: - -```ts title="plugins/site-notes/src/routes.ts" -import { definePluginRoutes, lazy, page } from '@vitnode/core/routing' - -export const routes = definePluginRoutes([ - page('/notes/new', { - component: lazy(() => import('./pages/new-note-page')), - // [!code ++:1] - requires: 'authenticated', - }), -]) -``` - -| `requires` | Behavior when not satisfied | Typical use cases | -| :---------------- | :------------------------------------------------------------------------- | :---------------------------------------- | -| `'authenticated'` | Redirects guests to `/login` with `returnTo` set to the target path | Dashboards, note editors, user settings | -| `'guest'` | Redirects authenticated users away (to `/` or their post-auth destination) | Sign-in, account creation, password reset | -| `'admin-guest'` | Redirects staff with active admin sessions into the AdminCP | AdminCP sign-in screen (`/admin`) | - -<Callout type="info" title="AdminCP routes are pre-guarded"> - Routes with `area: 'admin'` are automatically placed behind the AdminCP staff - session guard and cannot declare `requires`. To restrict access to specific - staff roles or permissions, gate content inside the route component or loader. -</Callout> - -## Cache public pages - -A page that looks the same for every visitor can declare `cache`, and VitNode reuses its server-rendered HTML for the given number of seconds: - -```ts title="plugins/blog/src/routes.ts" -page('/blog/article/:slug', { - cache: { maxAge: 300, staleWhileRevalidate: 60 }, // [!code ++] - component: lazy(() => import('./pages/post-page')), -}) -``` - -Routes are uncached by default, and a route with `requires` cannot be cached. [Cache server-rendered pages](/docs/dev/cache/pages) covers the setup and how the host app overrides a policy. - -## Control preloading - -Hovering a link runs the destination's loader early. Set `preload: false` on a page or layout whose loader is too costly to run on a hover: - -```ts title="plugins/site-notes/src/routes.ts" -page('/notes/:slug/history', { - component: lazy(() => import('./pages/note-history-page')), - preload: false, // [!code ++] -}) -``` - -Children of a `layout()` inherit its setting. [Preloading](/docs/dev/routing/preloading) explains what a preload runs and how to keep it to one request. - -## Nested routes and layouts - -A `layout()` wraps child routes in a shared UI frame without claiming a URL segment of its own. Use `index()` to render a page at the layout's root path. - -Every path nested inside a layout is **relative** to its parent: - -```ts title="plugins/site-notes/src/routes.ts" -import { - definePluginRoutes, - index, - layout, - lazy, - page, -} from '@vitnode/core/routing' - -export const routes = definePluginRoutes([ - layout('/notes', { - component: lazy(() => import('./pages/notes-layout')), - messages: ['@acme/site-notes.home'], - children: [ - index({ - component: lazy(() => import('./pages/notes-index-page')), - }), - page(':slug', { - component: lazy(() => import('./pages/note-page-slug')), - }), - ], - }), -]) -``` - -A layout component renders `{children}`: - -```tsx title="plugins/site-notes/src/pages/notes-layout.tsx" -import { useTranslations } from 'use-intl' - -const NotesLayout = ({ children }: { children: React.ReactNode }) => { - const t = useTranslations('@acme/site-notes.home') - - return ( - <div className="container mx-auto flex max-w-4xl flex-col gap-6 p-4"> - <header className="border-b pb-4"> - <h1 className="text-2xl font-bold tracking-tight text-balance"> - {t('title')} - </h1> - </header> - <main>{children}</main> - </div> - ) -} - -export default NotesLayout -``` - -| Route helper | URL resolved | Purpose | -| :---------------------- | :------------- | :---------------------------------------------------- | -| `layout('/notes', ...)` | — | Wraps nested child routes with a persistent UI shell | -| `index(...)` | `/notes` | Renders at the exact URL of the parent layout | -| `page(':slug', ...)` | `/notes/:slug` | Relative leaf route rendered inside the parent layout | - -## AdminCP routes - -Mount a screen in the staff-only Admin Control Panel by adding `area: 'admin'`. It automatically inherits the AdminCP shell, sidebar, and staff authentication guard: - -```ts title="plugins/site-notes/src/routes.ts" -page('/admin/notes', { - // [!code ++:1] - area: 'admin', - component: lazy(() => import('./pages/admin-notes-page')), - messages: ['@acme/site-notes.admin'], -}) -``` - -## Search parameters - -Type and validate query strings (like pagination or filters). Declare `search` on the route for eager URL-level validation before code chunks load: - -```ts title="plugins/site-notes/src/routes.ts" -page('/notes', { - component: lazy(() => import('./pages/notes-page')), - // [!code ++:3] - search: (input: Record<string, unknown>) => ({ - page: Number(input.page) || 1, - }), -}) -``` - -The component receives typed `search` values and a `navigate` helper: - -```tsx title="plugins/site-notes/src/pages/notes-page.tsx" -import type { PluginRoutePageProps } from '@vitnode/core/routing' - -interface NotesSearch { - page: number -} - -const NotesPage = ({ - navigate, - search, -}: PluginRoutePageProps<undefined, NotesSearch>) => ( - <div className="container mx-auto p-4"> - <button - className="inline-flex items-center rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90" - onClick={() => void navigate({ search: { page: search.page + 1 } })} - type="button" - > - Next Page ({search.page}) - </button> - </div> -) - -export default NotesPage -``` +| You want to | Guide | +| :---------------------------------------------------------------- | :------------------------------------------------------------ | +| Add a page with a URL parameter, a loader and a title | [Add a route](/docs/dev/routing/first-route) | +| Choose `defineRoute`, `definePluginRoute` and friends, or look up `head` | [Route module](/docs/dev/routing/route-module) | +| Share a frame between pages, render without chrome, or add an AdminCP page | [Layouts](/docs/dev/routing/layouts) | +| Read `:params`, catch-all paths and the query string | [URL parameters](/docs/dev/routing/url-parameters) | +| Limit a page to signed-in visitors or guests | [Protected routes](/docs/dev/routing/protected-routes) | +| Link between pages, from an app or a plugin | [Navigation](/docs/dev/routing/navigation) | +| Stop a hover from running an expensive loader | [Preloading](/docs/dev/routing/preloading) | +| Add the page to the breadcrumb trail | [Breadcrumbs](/docs/dev/routing/breadcrumbs) | +| Show a skeleton or a spinner while something loads | [Loading states](/docs/dev/routing/loading) | +| Answer 404 for a missing record, or customize the error pages | [Errors and 404](/docs/dev/routing/not-found) | +| Reuse a public page's HTML for every visitor | [Cache pages](/docs/dev/cache/pages) | +| Translate the strings on a page | [Translate pages](/docs/dev/i18n/pages) | -<Cards> - <Card - icon={<DatabaseIcon />} - title="Data Fetching" - description="Learn universal fetchers, cache invalidation, and background mutations." - href="/docs/dev/fetcher" - /> - <Card - icon={<MilestoneIcon />} - title="Breadcrumbs" - description="Customize static and dynamic breadcrumbs across public and admin pages." - href="/docs/dev/routing/breadcrumbs" - /> - <Card - icon={<LoaderCircleIcon />} - title="Loading States" - description="Display debounced spinners and tailored skeleton fallbacks while pages stream." - href="/docs/dev/routing/loading" - /> - <Card - icon={<TriangleAlertIcon />} - title="Errors & Not Found" - description="Render localized 404 and 500 boundaries while keeping application layouts intact." - href="/docs/dev/routing/not-found" - /> - <Card - icon={<LanguagesIcon />} - title="Translations" - description="Stream localized message namespaces in parallel with route chunks using use-intl." - href="/docs/dev/i18n/pages" - /> - <Card - icon={<LayoutDashboardIcon />} - title="AdminCP Pages" - description="Mount plugin screens, navigation links, and permissions in the AdminCP." - href="/docs/dev/plugins/admin" - /> -</Cards> +New to plugins? Start with [Create a plugin](/docs/dev/plugins/create) and [Plugin page](/docs/dev/plugins/page), then come back for [Add a route](/docs/dev/routing/first-route). diff --git a/apps/web/content/docs/dev/routing/layouts.mdx b/apps/web/content/docs/dev/routing/layouts.mdx new file mode 100644 index 0000000000..dac743cc72 --- /dev/null +++ b/apps/web/content/docs/dev/routing/layouts.mdx @@ -0,0 +1,116 @@ +--- +title: Layouts +description: Wrap several VitNode plugin pages in a shared layout with layout() and index(), choose the main, blank or admin area, and mount a page in the AdminCP. +icon: LayoutTemplate +--- + +A layout wraps a group of pages in a shared frame, such as a heading or a side menu that stays on screen while the visitor moves between them. An area picks the app shell a top-level route renders in: the public layout, nothing at all, or the AdminCP. + +## Share a frame with `layout()` + +`layout()` takes a path, a component and `children`. Every child path is relative to the layout, and `index()` is the page at the layout's own URL. This tree puts the notes list and the note page from [Add a route](/docs/dev/routing/first-route) under one frame: + +```ts title="plugins/site-notes/src/routes.ts" +import { + definePluginRoutes, + index, + layout, + lazy, + page, +} from '@vitnode/core/routing' + +export const routes = definePluginRoutes([ + layout('/site-notes/notes', { + component: lazy(() => import('./pages/notes-layout')), + messages: ['site-notes.notes'], + children: [ + index({ + component: lazy(() => import('./pages/notes-page')), + }), + page(':id', { + component: lazy(() => import('./pages/note-page')), + }), + ], + }), +]) +``` + +| Declaration | URL | Renders | +| :---------------------------------- | :------------------------ | :---------------------------------------- | +| `layout('/site-notes/notes', ...)` | none of its own | The frame around its children | +| `index(...)` | `/site-notes/notes` | The page at the layout's URL | +| `page(':id', ...)` | `/site-notes/notes/:id` | A child page inside the frame | + +The layout component renders its page through `children`: + +```tsx title="plugins/site-notes/src/pages/notes-layout.tsx" +import { useTranslations } from 'use-intl' + +const NotesLayout = ({ children }: { children: React.ReactNode }) => { + const t = useTranslations('site-notes.notes') + + return ( + <div className="container mx-auto flex max-w-2xl flex-col gap-6 p-4"> + <p className="text-muted-foreground text-sm">{t('title')}</p> + {children} + </div> + ) +} + +export default NotesLayout +``` + +A child inherits the layout's `messages`, `requires` and `preload`, so declare them once on the layout. A layout module can export a `route` too, for example a breadcrumb that every child page shares. + +### Rules the build checks + +The build stops with a message that names the route when a tree breaks one of these: + +- A child path is relative: `':id'`, not `'/site-notes/notes/:id'`. A top-level path starts with `/`. +- `index()` only appears inside a layout's `children`. +- A layout needs at least one child, because it claims no URL of its own. +- A layout cannot declare `cache` or `search`. Put them on the page that has the URL. +- A layout path cannot end in `*`, because nothing inside a catch-all could be reached. Use a `page()` for that. +- Only a top-level route declares `area`. Children render in their layout's area. + +## Pick an area + +`area` chooses the shell a top-level page or layout renders in: + +| `area` | Renders inside | +| :----------------- | :---------------------------------------------------------------- | +| `'main'` (default) | The public layout: header, breadcrumbs, navigation, footer | +| `'blank'` | Only the app root: document, theme, providers and translations | +| `'admin'` | The AdminCP shell: sidebar, AdminCP breadcrumbs, staff session | + +Use `'blank'` for a page that stands on its own, such as an embed, a print view or a kiosk screen: + +```ts title="plugins/site-notes/src/routes.ts" +page('/site-notes/embed', { + area: 'blank', // [!code ++] + component: lazy(() => import('./pages/notes-embed-page')), +}), +``` + +A blank page has no header and no breadcrumb trail, so give it its own way back if a visitor can reach it from the site. + +## Add an AdminCP page + +`area: 'admin'` mounts the page in the AdminCP, behind the staff session guard. Use a path under `/admin`: + +```ts title="plugins/site-notes/src/routes.ts" +page('/admin/site-notes', { + area: 'admin', // [!code ++] + component: lazy(() => import('./pages/admin-notes-page')), + messages: ['site-notes.admin.notes'], +}), +``` + +An AdminCP route cannot declare `requires`. The AdminCP has its own session, and the build rejects the field. To limit the page to staff with a permission, check the permission inside the page. Write the page module with `defineAdminRoute` when its loader needs `context.adminAccess`, the resolved AdminCP session. + +[AdminCP pages](/docs/dev/plugins/admin) builds a complete AdminCP screen with a sidebar link, a permission check and a form. + +## Related + +- [Route module](/docs/dev/routing/route-module): what a layout or page module can export. +- [Breadcrumbs](/docs/dev/routing/breadcrumbs): a crumb for a layout and its pages. diff --git a/apps/web/content/docs/dev/routing/loading.mdx b/apps/web/content/docs/dev/routing/loading.mdx index da295e6f17..a435f063d2 100644 --- a/apps/web/content/docs/dev/routing/loading.mdx +++ b/apps/web/content/docs/dev/routing/loading.mdx @@ -1,5 +1,5 @@ --- -title: Loading States +title: Loading states description: Show a top progress bar for route navigation, section skeletons for loading content, and button spinners for actions in VitNode. icon: LoaderCircle --- @@ -85,16 +85,22 @@ Import pre-built skeletons directly from `@vitnode/core/tanstack/pending`: | `CardsPendingSkeleton` | Responsive 1/2/3 column card grid | Dashboard widgets, product catalogs | | `FormPendingSkeleton` | Stacked input placeholders and action buttons | Settings forms, modal dialogs | | `AuthPendingSkeleton` | Centered authentication card with form inputs | Sign-in, sign-up, password reset | +| `ProfilePendingSkeleton` | Cover, avatar, name and side cards | Member profiles | +| `DiscoverPendingSkeleton` | Heading and a timeline of results | Discover-style activity pages | +| `SearchPendingSkeleton` | Search box, filter column and results | Search pages | +| `BreadcrumbPendingSkeleton` | Two short crumb placeholders | A breadcrumb that is still loading | +| `RoutePendingSkeleton` | Heading and text blocks | A page with no closer shape | | `RoutePendingSpinner` | Centered accessible spinner | Suspense fallback with no known shape | ## Customizing skeleton props -All skeleton shapes accept props from `RoutePendingSkeletonProps` to adapt to your layout: +The table, feed, cards, form, auth and route skeletons accept every prop of `RoutePendingSkeletonProps`. The profile, discover, search and breadcrumb skeletons accept only `className` and `label`. ```tsx title="plugins/site-notes/src/pages/notes-admin-page.tsx" import { TablePendingSkeleton } from '@vitnode/core/tanstack/pending' import { Suspense } from 'react' +// `AdminNotesTable` is your table, which suspends while its query loads. const AdminNotesPage = () => { return ( <Suspense @@ -117,10 +123,10 @@ export default AdminNotesPage | Prop | Type | Default | Description | | :---------------- | :-------- | :---------- | :------------------------------------------------------------ | -| `rows` | `number` | `3` to `6` | Number of skeleton items, rows, or cards to display | +| `rows` | `number` | per shape (`2` to `6`) | Number of skeleton items, rows, or cards to display | | `withDescription` | `boolean` | `true` | Whether to display a skeleton subtitle under the heading | | `className` | `string` | `undefined` | Additional Tailwind utility classes merged onto the container | -| `label` | `string` | `'Loading'` | Accessible screen-reader label (`aria-label`) | +| `label` | `string` | `'Loading'` | Screen-reader status text, rendered as a visually hidden `role="status"` element. Pass a translated string | ## Keep data on screen during refetches @@ -131,6 +137,7 @@ import { keepPreviousData, useQuery } from '@tanstack/react-query' import { FeedPendingSkeleton } from '@vitnode/core/tanstack/pending' import { cn } from 'cn' +// `notesQueryOptions` is your own query helper, keyed on the search term. const NotesSearch = ({ search }: { search: string }) => { const query = useQuery({ ...notesQueryOptions({ search }), @@ -158,6 +165,8 @@ Submissions and mutations show their progress on the control that started them. import { Button } from '@vitnode/core/components/ui/button' import { useMutation } from '@tanstack/react-query' +// `deleteNote` is your own API call. In a real handler, also invalidate the +// notes query and show a toast in `onSuccess`. const DeleteNoteButton = ({ id }: { id: number }) => { const mutation = useMutation({ mutationFn: deleteNote }) @@ -178,19 +187,19 @@ While the action runs, also disable any other control that would conflict with i <Cards> <Card icon={<DatabaseIcon />} - title="Data Fetching" + title="Fetcher" description="Learn universal fetchers, cache invalidation, and background mutations." href="/docs/dev/fetcher" /> <Card icon={<MapIcon />} - title="Plugin Routes" + title="Routing" description="Declare routes, lazy page components, metadata, and breadcrumbs." href="/docs/dev/routing" /> <Card icon={<TriangleAlertIcon />} - title="Errors & Not Found" + title="Errors and 404" description="Handle 404, 500, and error boundaries in routes." href="/docs/dev/routing/not-found" /> diff --git a/apps/web/content/docs/dev/routing/meta.json b/apps/web/content/docs/dev/routing/meta.json index f17f02937e..97e89ea686 100644 --- a/apps/web/content/docs/dev/routing/meta.json +++ b/apps/web/content/docs/dev/routing/meta.json @@ -4,6 +4,11 @@ "icon": "Route", "pages": [ "index", + "first-route", + "route-module", + "layouts", + "url-parameters", + "protected-routes", "navigation", "preloading", "breadcrumbs", diff --git a/apps/web/content/docs/dev/routing/navigation.mdx b/apps/web/content/docs/dev/routing/navigation.mdx index 78c1b96c4f..dfe85fa8df 100644 --- a/apps/web/content/docs/dev/routing/navigation.mdx +++ b/apps/web/content/docs/dev/routing/navigation.mdx @@ -128,14 +128,25 @@ when the href matches the current URL exactly, query and hash included. To navigate from code after a form is saved, use `useHrefNavigate`: ```tsx title="plugins/catalog/src/pages/catalog-new-page.tsx" +import { Button } from '@vitnode/core/components/ui/button' import { useHrefNavigate } from '@vitnode/core/tanstack/link' -const navigate = useHrefNavigate() - -// after a successful save: -await navigate(`/catalog/${encodeURIComponent(item.slug)}`) +export const OpenItemButton = ({ slug }: { slug: string }) => { + const navigate = useHrefNavigate() + + return ( + <Button + onClick={() => void navigate(`/catalog/${encodeURIComponent(slug)}`)} + type="button" + > + Open item + </Button> + ) +} ``` +Call the returned `navigate` from an event handler, such as a form's `onSubmit` after a successful save. Pass `{ replace: true }` to replace the current history entry instead of adding one. + Invalidate the queries the save changed before you navigate, so the next page's loader reads fresh data. For filters, sorting and pagination on the page already being viewed, use the page's own `navigate` prop shown above. @@ -145,12 +156,12 @@ being viewed, use the page's own `navigate` prop shown above. work with any host, which saves future hosts from router spaghetti. </Callout> -## Next +## Related <Cards> <Card title="Routing" - description="How a URL gets claimed, by your app or by a plugin" + description="How a plugin claims a URL, and which guide covers each routing task" href="/docs/dev/routing" /> <Card diff --git a/apps/web/content/docs/dev/routing/not-found.mdx b/apps/web/content/docs/dev/routing/not-found.mdx index fd19950066..c728427b0f 100644 --- a/apps/web/content/docs/dev/routing/not-found.mdx +++ b/apps/web/content/docs/dev/routing/not-found.mdx @@ -1,146 +1,115 @@ --- -title: Errors & Not Found -description: Handle 404 Not Found, 500 Server Errors, and route exceptions with localized layouts, action buttons, and loader boundaries. +title: Errors and 404 +description: Answer 404 from a VitNode route loader with notFound(), and configure the app's localized 404 and 500 pages, their action buttons and the development error overlay. icon: TriangleAlert --- -import { DatabaseIcon, LoaderCircleIcon, MapIcon } from 'lucide-react' -import { Tab, Tabs } from 'fumadocs-ui/components/tabs' +VitNode ships localized error screens that render inside your app's layout, so a visitor who hits a missing page or a crash keeps the header, the navigation and the theme. Plugins signal the problem from their loaders; the app decides how it looks. -In TanStack Start, error handling and missing routes are configured through route boundaries rather than static files. VitNode provides built-in, localized error layouts (`NotFound`, `Error500Page`, and `ErrorActions`) mounted directly inside your application shell—keeping navigation, headers, and themes intact when errors occur. +## Answer 404 from a loader -## 404 Not Found +When the record a URL asks for does not exist, throw `notFound()` from `@tanstack/react-router` in `load`: -### Throwing 404 in loaders - -When a requested resource (such as a note slug or user ID) is not found in the database, throw `notFound()` from `@tanstack/react-router` inside `load`: - -```tsx title="plugins/site-notes/src/pages/note-page-slug.tsx" +```tsx title="plugins/site-notes/src/pages/note-page.tsx" import { notFound } from '@tanstack/react-router' -import { definePluginRoute } from '@vitnode/core/routing' -export const route = definePluginRoute({ - load: async ({ params }) => { - const note = await fetchNote(params.slug) - - // [!code ++:3] - if (!note) { - throw notFound() - } +const loadNote = async (queryClient: QueryClient, idParam: string) => { + // ... read the note through the query cache, as in Add a route + if (!note) throw notFound() // [!code ++] - return note - }, -}) + return note +} ``` -When thrown, TanStack Router catches the signal and halts page execution, rendering the nearest `notFoundComponent`. - -### Unmatched URLs keep the main shell +The loader stops, the page renders the not-found screen, and the server answers with a `404` status, so search engines drop the URL. [Add a route](/docs/dev/routing/first-route) builds this page in full. -When a visitor navigates to a non-existent URL, the main shell (`_main.tsx`) is -still matched, so the route is caught **inside** it. +To show something other than the app's 404 page for one route, give its page module a `notFound` component: -This ensures visitors never hit a blank, unstyled screen: they retain the main site header, navigation bar, and theme switcher, while search crawlers receive a real HTTP `404` status code: - -```tsx title="apps/web/src/router.tsx" -const routeTree = withVitNodeRoutes( - fileRouteTree, - pluginRouteSpecs(pluginRouteSources), - { - mountUnder: { - admin: adminShellRoute, - blank: fileRouteTree, - main: mainShellRoute, - }, - }, +```tsx title="plugins/site-notes/src/pages/note-page.tsx" +const NoteNotFound = () => ( + <p className="container mx-auto p-4 leading-relaxed">This note does not exist.</p> ) + +export const route = defineRoute({ + load: async ({ context, params }) => + await loadNote(context.queryClient, params.id), + notFound: NoteNotFound, // [!code ++] +}) ``` -{/* Image prompt: VitNode 404 error page displayed inside the main site layout with header, 404 heading, localized description, and "Go back" / "Back to home" action buttons in dark theme, 1440x900. */} +`notFound` loads with the page module, so it costs unvisited routes nothing. Without one, the route falls back to the router's `defaultNotFoundComponent`. + +A URL that matches no route at all gets the same default 404 page. It still renders inside the main layout, because the main shell route matches every public URL. -## 500 Server & runtime errors +{/* Image prompt: The VitNode 404 page inside the main site layout, with the header, a 404 heading, a localized description and the Go back and Back to home buttons. Dark theme, 1440x900. */} -When an unhandled exception or database failure occurs during SSR or client navigation, the router activates its `errorComponent`. +## Configure the app's error pages -VitNode configures the global error boundary in `apps/web/src/router.tsx` using `Error500Page`: +The app sets both screens once, in `getRouter` in `src/router.tsx`. A new app already contains this: ```tsx title="apps/web/src/router.tsx" import { createRouter as createTanStackRouter } from '@tanstack/react-router' import { - Error500Page, ErrorActions, NotFound, + RouteErrorPage, } from '@vitnode/core/tanstack/layout' export function getRouter() { - return createTanStackRouter({ - routeTree, - // [!code ++:2] + const router = createTanStackRouter({ + // ... defaultNotFoundComponent: () => <NotFound actions={<ErrorActions />} />, - defaultErrorComponent: () => <Error500Page actions={<ErrorActions />} />, + defaultErrorComponent: ({ error }) => ( + <RouteErrorPage actions={<ErrorActions />} error={error} /> + ), }) + + return router } ``` -`Error500Page` automatically renders localized strings from `core.global.errors.500`, keeping the error message friendly and clear while preserving layout structure. +| Component | Import | Shows | +| :--------------- | :------------------------------ | :------------------------------------------------------------------------------------------ | +| `NotFound` | `@vitnode/core/tanstack/layout` | The localized 404 page | +| `RouteErrorPage` | `@vitnode/core/tanstack/layout` | `Error500Page`, plus an overlay with the error's details in development only | +| `Error500Page` | `@vitnode/core/tanstack/layout` | The localized 500 page alone, with the strings from `core.global.errors.500` | +| `ErrorActions` | `@vitnode/core/tanstack/layout` | A **Go back** button and a **Back to home** link | -{/* Image prompt: VitNode 500 internal server error page with large 500 status code, friendly error title, description, and recovery buttons in dark theme, 1440x900. */} +Keep `RouteErrorPage` as the error component. Passing `Error500Page` directly works too, but you lose the development overlay that shows what went wrong. -## Error action buttons +{/* Image prompt: The VitNode 500 page with the status code, a friendly title and description, and the recovery buttons. Dark theme, 1440x900. */} -The `<ErrorActions />` component renders two primary recovery buttons: +## Change the action buttons -1. **Go Back** (`router.history.back()`) — Takes the user to their previously viewed screen. -2. **Back to Home** (`/`) — Returns to the application homepage using locale-aware navigation. +`actions` takes any elements. To add a link next to the default buttons, render both: -You can also pass custom action elements into the `actions` prop of `NotFound` or `Error500Page`: - -```tsx title="apps/web/src/routes/__root.tsx" +```tsx title="apps/web/src/router.tsx" +import { buttonVariants } from '@vitnode/core/components/ui/button' import { ErrorActions, NotFound } from '@vitnode/core/tanstack/layout' - -export const Route = createRootRouteWithContext<RootRouterContext>()({ - // [!code ++:1] - notFoundComponent: () => <NotFound actions={<ErrorActions />} />, - component: RootComponent, -}) +import { HrefLink } from '@vitnode/core/tanstack/link' + +// in createTanStackRouter({ ... }) +defaultNotFoundComponent: () => ( + <NotFound + actions={ + <> + <ErrorActions /> + {/* [!code ++:3] */} + <HrefLink className={buttonVariants({ size: 'lg', variant: 'outline' })} href="/site-notes/notes"> + Browse notes + </HrefLink> + </> + } + /> +), ``` -## Standard error codes - -VitNode provides `ErrorContent` to render standardized error presentations across public and administrative pages: - -| Status code | Name | Common cause | Handled by | -| :---------- | :-------------------- | :------------------------------------------------------- | :---------------------- | -| `400` | Bad Request | Malformed request parameters or invalid form payloads | `ErrorContent` | -| `403` | Forbidden | Insufficient permissions or unauthorized staff action | Staff auth guards | -| `404` | Not Found | Route does not exist or loader threw `notFound()` | `NotFound` | -| `409` | Conflict | Resource already exists or version mismatch | API mutation handlers | -| `429` | Too Many Requests | Rate limits exceeded on public or API endpoints | Rate limiter middleware | -| `500` | Internal Server Error | Uncaught server exception or database connectivity issue | `Error500Page` | - -<Callout type="info" title="Centralized error boundaries"> - Keep root error fallbacks in your host application (`apps/web`). Individual - plugins should throw `notFound()` or errors from their loaders and let the - centralized, localized layouts handle presentation. +<Callout type="info" title="Keep error pages in the app"> + Plugins throw `notFound()` or let errors surface from their loaders. The app + owns the screens, so every plugin's errors look the same. </Callout> -<Cards> - <Card - icon={<LoaderCircleIcon />} - title="Loading States" - description="Render instant skeletons and spinners during route transitions." - href="/docs/dev/routing/loading" - /> - <Card - icon={<MapIcon />} - title="Plugin Routes" - description="Declare URLs, lazy page components, metadata, and breadcrumbs." - href="/docs/dev/routing" - /> - <Card - icon={<DatabaseIcon />} - title="Data Fetching" - description="Server and client data loading with universal fetchers." - href="/docs/dev/fetcher" - /> -</Cards> +## Related + +- [Loading states](/docs/dev/routing/loading): what the visitor sees while a page loads. +- [Route module](/docs/dev/routing/route-module): `load`, `notFound` and the other module options. diff --git a/apps/web/content/docs/dev/routing/preloading.mdx b/apps/web/content/docs/dev/routing/preloading.mdx index 723bee6af7..780ebca80c 100644 --- a/apps/web/content/docs/dev/routing/preloading.mdx +++ b/apps/web/content/docs/dev/routing/preloading.mdx @@ -51,7 +51,7 @@ import { fetchNote } from '../features/notes/fetch-note' const noteQuery = (slug: string) => queryOptions({ - queryKey: ['@acme/site-notes', 'note', { slug }], + queryKey: ['site-notes', 'note', { slug }], queryFn: async () => await fetchNote(slug), staleTime: 60_000, // [!code ++] meta: LOADER_DATA_QUERY_META, // [!code ++] @@ -123,7 +123,7 @@ const ExportLink = () => ( Keep it on for ordinary reading pages. One cached preload makes the click feel instant and costs nothing on the second hover. -## Next +## Related <Cards> <Card diff --git a/apps/web/content/docs/dev/routing/protected-routes.mdx b/apps/web/content/docs/dev/routing/protected-routes.mdx new file mode 100644 index 0000000000..97cf270b47 --- /dev/null +++ b/apps/web/content/docs/dev/routing/protected-routes.mdx @@ -0,0 +1,63 @@ +--- +title: Protected routes +description: Limit a VitNode plugin page to signed-in visitors or to guests with requires, and read the signed-in user in the loader with defineAuthenticatedRoute. +icon: Lock +--- + +Some pages only make sense for one kind of visitor: a note editor needs a signed-in member, a sign-in screen needs a guest. Declare `requires` on the route, and VitNode checks the session before the page chunk downloads and redirects everyone else. + +## Declare who may open the page + +Add `requires` to the route in `routes.ts`: + +```ts title="plugins/site-notes/src/routes.ts" +page('/site-notes/my-notes', { + component: lazy(() => import('./pages/my-notes-page')), + requires: 'authenticated', // [!code ++] +}), +``` + +| `requires` | Who may open the page | Everyone else | +| :---------------- | :----------------------------- | :------------------------------------------------------------------------------ | +| `'authenticated'` | Signed-in visitors | Guests go to `/login`, with `returnTo` set so they come back after signing in | +| `'guest'` | Visitors who are not signed in | Signed-in visitors go to their `returnTo` destination, or the home page | +| `'admin-guest'` | Visitors without an AdminCP session | Staff with an AdminCP session go into the AdminCP. Meant for an AdminCP sign-in screen | + +Declared on a `layout()`, `requires` covers every page inside it. A child that declares a different requirement than its layout fails the build, because no visitor could ever reach it. + +## Read the user in the loader + +Write the page module with `defineAuthenticatedRoute` from `@vitnode/core/tanstack/plugin-routes`. Its loader context has `auth`, the session the guard already checked, so `auth.user` is never `null`: + +```tsx title="plugins/site-notes/src/pages/my-notes-page.tsx" +import type { PluginRoutePageProps } from '@vitnode/core/routing' + +import { defineAuthenticatedRoute } from '@vitnode/core/tanstack/plugin-routes' + +export const route = defineAuthenticatedRoute({ + load: ({ context }) => ({ userId: context.auth.user.id }), +}) + +const MyNotesPage = ({ loaderData }: PluginRoutePageProps<{ userId: number }>) => ( + <p className="leading-relaxed">Notes for user {loaderData.userId}</p> +) + +export default MyNotesPage +``` + +Use `defineAuthenticatedRoute` only on a route that declares `requires: 'authenticated'`, directly or through its layout. The `'guest'` and `'admin-guest'` guards hand the loader no session. + +<Callout type="warn" title="The guard is not your API's permission check"> + `requires` decides which page a visitor sees. Every API route the page calls + still checks the session and permissions itself, because anyone can call an + API route without opening the page. +</Callout> + +## Limits + +- A route with `requires` cannot declare `cache`. Its HTML depends on who asks, so VitNode never reuses it for another visitor. See [Cache pages](/docs/dev/cache/pages). +- An AdminCP route (`area: 'admin'`) cannot declare `requires`. The AdminCP has its own staff session guard; see [Layouts](/docs/dev/routing/layouts#add-an-admincp-page). + +## Check the result + +Sign out and open the protected page. You land on `/login` with `returnTo` in the URL. Sign in, and you come back to the page. diff --git a/apps/web/content/docs/dev/routing/route-module.mdx b/apps/web/content/docs/dev/routing/route-module.mdx new file mode 100644 index 0000000000..9785b0e988 --- /dev/null +++ b/apps/web/content/docs/dev/routing/route-module.mdx @@ -0,0 +1,112 @@ +--- +title: Route module +description: "Reference for a VitNode plugin page module: defineRoute, defineAuthenticatedRoute, defineAdminRoute and definePluginRoute, the load and head arguments, translated metadata, and the page props." +icon: FileCode +--- + +A page module is the file a route's `lazy(() => import(...))` points at. It default-exports the page component and may export a `route` object with the page's loader, metadata, breadcrumb and not-found screen. This page lists what that object accepts. For a walkthrough, see [Add a route](/docs/dev/routing/first-route). + +```tsx title="plugins/site-notes/src/pages/note-page.tsx" +import type { PluginRoutePageProps } from '@vitnode/core/routing' + +import { defineRoute } from '@vitnode/core/tanstack/plugin-routes' + +export const route = defineRoute({ + load: async ({ params }) => ({ id: params.id }), + head: ({ loaderData }) => ({ title: `Note ${loaderData?.id ?? ''}` }), +}) + +const NotePage = ({ loaderData }: PluginRoutePageProps<{ id: string }>) => ( + <h1>Note {loaderData.id}</h1> +) + +export default NotePage +``` + +## Pick a helper + +All four helpers return the object unchanged. They exist to type `load`'s `context` by what the route earned in `routes.ts`. + +| Helper | Import | `load` context | Use it for | +| :------------------------- | :-------------------------------------- | :----------------------------------------- | :------------------------------------------- | +| `defineRoute` | `@vitnode/core/tanstack/plugin-routes` | `locale`, `queryClient` | Most pages | +| `defineAuthenticatedRoute` | `@vitnode/core/tanstack/plugin-routes` | `locale`, `queryClient`, `auth` | A route declared with `requires` | +| `defineAdminRoute` | `@vitnode/core/tanstack/plugin-routes` | `locale`, `queryClient`, `adminAccess` | A route with `area: 'admin'` | +| `definePluginRoute` | `@vitnode/core/routing` | `locale` | Code that must not depend on TanStack | + +- **`queryClient`** is the app's TanStack Query client. Warm the page's data with `context.queryClient.query({ ...options, staleTime: 'static' })`, so a preload and the click that follows share one request. See [Cache app data](/docs/dev/cache/app). +- **`auth`** is the signed-in visitor's session, already checked by the `requires` guard, so `context.auth.user` is never `null`. See [Protected routes](/docs/dev/routing/protected-routes). +- **`adminAccess`** is the AdminCP session state resolved by the AdminCP shell. + +A route that did not declare `requires` or `area: 'admin'` cannot ask for `auth` or `adminAccess`. The field is a type error rather than `undefined` at runtime. + +`definePluginRoute` works too, but a loader without `queryClient` fetches directly, and the router runs it again on every hover preload. Prefer `defineRoute` in a VitNode app. + +## Options + +| Name | Type | Description | +| :------------ | :------------------------------------------------------ | :----------------------------------------------------------------------------------------------------------- | +| `load` | `(args) => TData \| Promise<TData>` | Runs before the page renders, on the server for the first visit and in the browser after. Its result is `loaderData`. | +| `head` | `(args) => PluginRouteHead` | Builds the page's `<title>` and meta tags. Runs after `load`. | +| `breadcrumb` | component, `{ group }`, `false` or `null` | The page's crumb in the breadcrumb trail. See [Breadcrumbs](/docs/dev/routing/breadcrumbs). | +| `notFound` | `React.ComponentType` | Rendered when `load` throws `notFound()`. Omit it to use the app's 404 page. | +| `parseSearch` | `(input: unknown) => TSearch` | Parses the query string inside the loader, when `routes.ts` declares no `search`. See [URL parameters](/docs/dev/routing/url-parameters#search-parameters). | + +<Callout type="warn" title="Declare load above head"> + TypeScript infers `loaderData` in `head` and `breadcrumb` from `load`'s return + type, and it reads the object's members in order. With `head` written first, + `loaderData` is not typed. +</Callout> + +### `load` arguments + +| Name | Type | Description | +| :-------- | :--------------------------------- | :----------------------------------------------------------------------- | +| `params` | `Readonly<Record<string, string>>` | The route's dynamic segments, e.g. `{ id: '2' }`. Always strings. | +| `search` | `TSearch` | The parsed query string, or `{}` when the route parses none. | +| `context` | depends on the helper | See [Pick a helper](#pick-a-helper). | +| `t` | `(key, values?) => string` | Translates a key from the route's `messages`. See [Translated metadata](#translated-metadata). | + +Throw `notFound()` or `redirect()` from `@tanstack/react-router` to answer with a 404 or a redirect instead of the page. + +### `head` arguments and result + +`head` receives `loaderData` (possibly `undefined`), `params`, `search` and `t`, and returns: + +| Name | Type | Description | +| :------------ | :---------------------------------------------------------- | :-------------------------------------------------------------------------------------------- | +| `title` | `string` | The page's own title, already translated. VitNode suffixes it with the site name from `buildConfig({ metadata })`. | +| `description` | `string` | The `<meta name="description">`. | +| `robots` | `'index, follow' \| 'noindex, follow' \| 'noindex, nofollow'` | What a crawler may do. `noindex, follow` keeps a filtered list out of the index while its links still count. | +| `alternates` | `Record<string, string>` | Internal paths of the page in each language, for canonical and `hreflang` links. See [Advanced localized URLs](/docs/dev/i18n/localized-urls-advanced#hreflang-and-canonical-links). | + +Return only these fields. VitNode turns them into the full set of tags with the app's site metadata. + +## Translated metadata + +`head` and `load` run outside the React tree, so `useTranslations` cannot reach them. Both receive `t` instead, a translator over the namespaces the route declared in `messages`. Keys are full dotted paths, namespace included: + +```tsx title="plugins/site-notes/src/pages/notes-page.tsx" +export const route = defineRoute({ + head: ({ t }) => ({ + title: t('site-notes.notes.title'), + }), +}) +``` + +A component scopes itself once with `useTranslations('site-notes.notes')` and then calls `t('title')`. In `head` there is no component to scope, so you spell out the whole key. Placeholders work as they do in a component. Pass the values as the second argument, `t(key, { name: 'Ada' })`. + +The namespaces have already loaded by the time `head` runs, so translating metadata costs no extra request. Calling `t` on a route that declares no `messages` throws and names the namespace to add, so a raw key never ends up in a `<title>`. + +## Page props + +The default export receives `PluginRoutePageProps<TData, TSearch>`: + +| Prop | Type | Description | +| :----------- | :--------------------------------------------------------- | :---------------------------------------------------------------- | +| `loaderData` | `TData` | What `load` returned; `undefined` without a loader. | +| `params` | `Readonly<Record<string, string>>` | The route's dynamic segments. | +| `search` | `TSearch` | The parsed query string. | +| `navigate` | `(options: { search: TSearch; resetScroll?: boolean }) => Promise<void>` | Navigates to the same page with new search parameters. | + +A layout's default export receives the same props plus `children`. See [Layouts](/docs/dev/routing/layouts). diff --git a/apps/web/content/docs/dev/routing/url-parameters.mdx b/apps/web/content/docs/dev/routing/url-parameters.mdx new file mode 100644 index 0000000000..984518b604 --- /dev/null +++ b/apps/web/content/docs/dev/routing/url-parameters.mdx @@ -0,0 +1,101 @@ +--- +title: URL parameters +description: Read dynamic segments, catch-all paths and typed search parameters in VitNode plugin routes, and update the query string with the page's navigate prop. +icon: Brackets +--- + +A plugin route reads three parts of the URL: dynamic segments such as `:id`, a catch-all tail with `*`, and the query string. All three reach the loader and the page as props, typed where you declare a type. + +## Dynamic segments + +Write `:name` for a segment that varies. The value arrives as a string in `params`, in `load`, `head`, `breadcrumb` and the page: + +```ts title="plugins/site-notes/src/routes.ts" +page('/site-notes/notes/:id', { + component: lazy(() => import('./pages/note-page')), +}), +``` + +```tsx title="plugins/site-notes/src/pages/note-page.tsx" +import type { PluginRoutePageProps } from '@vitnode/core/routing' + +const NotePage = ({ params }: PluginRoutePageProps) => <h1>Note {params.id}</h1> + +export default NotePage +``` + +`params.id` is always a string. Convert and validate it before you use it, and throw `notFound()` from `@tanstack/react-router` for a value that cannot match a record. [Add a route](/docs/dev/routing/first-route) shows the full loader. + +## Catch-all paths + +A trailing `*` matches every remaining segment, so one page can own a whole subtree. It must be the last segment, and only a `page()` may end in one: + +```ts title="plugins/site-notes/src/routes.ts" +page('/site-notes/files/*', { + component: lazy(() => import('./pages/files-page')), +}), +``` + +The matched tail arrives as `params._splat`. For `/site-notes/files/2026/october`, it is `2026/october`: + +```tsx title="plugins/site-notes/src/pages/files-page.tsx" +import type { PluginRoutePageProps } from '@vitnode/core/routing' + +const FilesPage = ({ params }: PluginRoutePageProps) => { + const segments = (params._splat ?? '').split('/').filter(Boolean) + + return <p className="leading-relaxed">{segments.join(' / ')}</p> +} + +export default FilesPage +``` + +## Search parameters + +Declare `search` on the route in `routes.ts` to parse the query string. The router runs it while it matches the URL, before the page chunk loads, and its return type becomes the page's `search` prop: + +```ts title="plugins/site-notes/src/routes.ts" +page('/site-notes/notes', { + component: lazy(() => import('./pages/notes-page')), + // [!code ++:3] + search: (input: Record<string, unknown>) => ({ + page: Number(input.page) || 1, + }), +}), +``` + +The page reads `search` and changes it with `navigate`, which stays on the same path: + +```tsx title="plugins/site-notes/src/pages/notes-page.tsx" +import type { PluginRoutePageProps } from '@vitnode/core/routing' + +import { Button } from '@vitnode/core/components/ui/button' + +interface NotesSearch { + page: number +} + +const NotesPage = ({ navigate, search }: PluginRoutePageProps<undefined, NotesSearch>) => ( + <Button + onClick={() => void navigate({ search: { page: search.page + 1 } })} + type="button" + > + Page {search.page + 1} + </Button> +) + +export default NotesPage +``` + +Pass `resetScroll: false` to `navigate` to keep the scroll position, for example when a filter changes. The loader also receives `search`, so it can fetch the right page. Key the query on the search values and use `placeholderData: keepPreviousData` so the current page stays on screen while the next loads; see [Loading states](/docs/dev/routing/loading#keep-data-on-screen-during-refetches). + +A layout cannot declare `search`, because it has no URL of its own. + +### `parseSearch` in the page module + +A page module's `route` can declare `parseSearch` instead. It runs inside the loader, after the page chunk has loaded, and only when `routes.ts` declares no `search`. Prefer `search` in `routes.ts`, which parses the URL before the page chunk downloads. + +## Related + +- [Navigation](/docs/dev/routing/navigation): link to a page with parameters and a query string. +- [Route module](/docs/dev/routing/route-module): every argument `load` and `head` receive. diff --git a/apps/web/content/docs/dev/search-elasticsearch.mdx b/apps/web/content/docs/dev/search-elasticsearch.mdx index c2f500ca6e..55ecabdbb7 100644 --- a/apps/web/content/docs/dev/search-elasticsearch.mdx +++ b/apps/web/content/docs/dev/search-elasticsearch.mdx @@ -10,6 +10,21 @@ Use Elasticsearch when your site-wide search needs fuzzy matching, custom ranking, or a search cluster separate from Postgres. VitNode keeps Postgres as the canonical index; this adapter mirrors it, so switching is pleasantly boring. +## Before you begin + +- **An Elasticsearch or OpenSearch cluster.** For a local try, start a single-node Elasticsearch in Docker. It has security switched off, so keep it on your machine: + + ```bash + docker run -d --name vitnode-elasticsearch -p 9200:9200 \ + -e discovery.type=single-node -e xpack.security.enabled=false \ + docker.elastic.co/elasticsearch/elasticsearch:9.1.0 + ``` + + `curl http://localhost:9200` answers with the cluster name and version once it is up. +- **An AdminCP account with `system:can_manage_search`** (root roles have it) to run the first rebuild. + +## Switch to Elasticsearch + <Steps> <Step> @@ -79,21 +94,6 @@ collection) needs the **Rebuild and clear the search index** permission </Step> </Steps> -<Callout type="info" title="Upgrading an existing index"> - Documents now carry `authorIds` (a list) instead of `authorId`, plus drafts as - private documents. If your index was created before that, delete it in - Elasticsearch and run **Rebuild index**. VitNode recreates it with the new - mapping. -</Callout> - -<Callout type="info" title="Indexes created before uuid and bigint ids"> - Documents now carry an `itemKey` keyword with the record id as text, so [uuid - and bigint](/docs/content-engine/id-strategy) records can be found. VitNode - adds the field to an existing index the first time it uses it. Older documents - get a value on the next **Rebuild index**; until then, results fall back to - the numeric `itemId`, which works for serial ids only. -</Callout> - <Callout type="info" title="Totals stop at 10,000"> Elasticsearch counts matches up to 10,000, the same depth you can page to. Past that, `totalCount` reads 10,000 and means "at least this many" - nobody @@ -105,17 +105,39 @@ collection) needs the **Rebuild and clear the search index** permission lets you switch providers without losing your map back home. </Callout> -## Next +## Check the result + +- **Debug Panel → Search** names `elasticsearch` on the **Engine** card, and the collection counts match what you indexed. +- A search from the public API returns hits from the cluster: + + ```bash + curl "http://localhost:3000/api/@vitnode/core/search?search=hello" + ``` + +## Upgrading an index from an earlier version + +Documents now carry `authorIds` (a list) instead of `authorId`, plus drafts as +private documents. If your index was created before that, delete it in +Elasticsearch and run **Rebuild index**. VitNode recreates it with the new +mapping. + +Documents now carry an `itemKey` keyword with the record id as text, so search +can find [uuid and bigint](/docs/content-engine/id-strategy) records. VitNode +adds the field to an existing index the first time it uses it. Older documents +get a value on the next **Rebuild index**; until then, results fall back to +the numeric `itemId`, which works for serial ids only. + +## Related <Cards> <Card - title="Search & Discovery" + title="Search" description="Index plugin records and register rebuild indexers." href="/docs/dev/search" /> <Card title="Content Engine" description="Make a content type searchable without hand-writing an indexer." - href="/docs/content-engine/public-pages" + href="/docs/content-engine/search" /> </Cards> diff --git a/apps/web/content/docs/dev/search-external.mdx b/apps/web/content/docs/dev/search-external.mdx index 540836dc1f..5d80f5a395 100644 --- a/apps/web/content/docs/dev/search-external.mdx +++ b/apps/web/content/docs/dev/search-external.mdx @@ -136,7 +136,7 @@ import { RootProvider } from "fumadocs-ui/provider/tanstack"; `variant="bar"` turns the floating header card into a flat, full-width bar that sits flush on the docs layout, and `--fd-banner-height` tells Fumadocs how tall it is, so its sidebar and page fit underneath. Need a button before the logo, like a sidebar toggle? Pass it as `leading`. Load the header's data in the docs route too, with `loadMainShell(context)` in its `loader`. -Got a layout with its own search button? `SearchDialog` from `@vitnode/core/tanstack/search` is the header dialog without its trigger. Give it `open`, `onOpenChange` and the same `firstSourceId`. +Got a layout without the VitNode header? Render `HeaderSearch` from `@vitnode/core/tanstack/search` on its own. It is the header's search button together with its dialog and <kbd>⌘</kbd> <kbd>K</kbd> shortcut. Pass `firstSourceId={docsSearchSource.id}` to put your source's group first. Core exports no dialog without its trigger. ## Check the result diff --git a/apps/web/content/docs/dev/search-suggestions-dialog.webp b/apps/web/content/docs/dev/search-suggestions-dialog.webp index e384432bd7..bb2f44b47b 100644 Binary files a/apps/web/content/docs/dev/search-suggestions-dialog.webp and b/apps/web/content/docs/dev/search-suggestions-dialog.webp differ diff --git a/apps/web/content/docs/dev/search-suggestions.mdx b/apps/web/content/docs/dev/search-suggestions.mdx index eb3bffe752..f9876e5870 100644 --- a/apps/web/content/docs/dev/search-suggestions.mdx +++ b/apps/web/content/docs/dev/search-suggestions.mdx @@ -1,5 +1,5 @@ --- -title: Suggestions +title: Search suggestions description: Show as-you-type search suggestions and popular content in the header search dialog, and count views, clicks and comments towards popularity. icon: TextSearch --- @@ -13,7 +13,7 @@ Before you type, it shows what's popular. After two characters, it shows matchin <ImgDocs src={searchDialog} - alt="Search bar open over the page with an empty Search field and a Popular right now list: a forum topic with its forum name and excerpt, followed by articles and comments, each with a thumbnail or type icon and its date on the right, and keyboard hints at the bottom" + alt="Search bar open over the page with an empty Search field and a Popular right now list: a forum topic with its forum name and excerpt, followed by four newsroom stories and explainers, each with a type icon and its date on the right, and keyboard hints at the bottom" /> ## How the dialog works @@ -91,13 +91,13 @@ curl "http://localhost:3000/api/@vitnode/core/search/suggest?search=hel&lang=en" "items": [ { "id": 105, - "itemType": "example.article", + "itemType": "newsroom.story", "itemId": "3", "title": "Hello Content Engine", - "url": "/articles/hello-content-engine", + "url": "/stories/hello-content-engine", "context": null, "createdAt": "2026-10-10T10:55:34.335Z", - "excerpt": "Hello Content Engine A first article from the example plugin, served by the public API and rendered on its own page.", + "excerpt": "Hello Content Engine A first story from the newsroom plugin, served by the public API and rendered on its own page.", "image": null } ] diff --git a/apps/web/content/docs/dev/search.mdx b/apps/web/content/docs/dev/search.mdx index 194702e585..531888439a 100644 --- a/apps/web/content/docs/dev/search.mdx +++ b/apps/web/content/docs/dev/search.mdx @@ -1,5 +1,5 @@ --- -title: Search & Discovery +title: Search description: Enable site-wide full-text search across plugin content with Postgres or Elasticsearch ranking engines. icon: Search --- @@ -20,7 +20,7 @@ VitNode includes a unified site-wide search and discovery engine. Searchable rec ### Index when a record changes -Index or update an item from any Hono route handler via `c.get("search")`: +Call `c.get("search").index()` in the route handler that writes the record, right after the database write succeeds, so the index never points at a row that was rolled back. Calling it again for the same `itemType` and `itemId` updates the document: ```ts // [!code ++:10] @@ -36,6 +36,8 @@ await c.get('search').index({ }) ``` +`article` is the row your handler just saved. Inside a plugin route the document belongs to that plugin automatically. From a cron job or queue task, which run in Core's request, pass `pluginId: manifest.id` so your plugin owns the document. + </Step> <Step> @@ -107,7 +109,7 @@ export const recipesApiPlugin = () => </Step> </Steps> -## Pluggable Search Engines +## Search engines VitNode supports two search engines: @@ -217,7 +219,7 @@ Once `domains` is configured, every result URL is absolute, so a click always op The rules: - It starts with `/` and holds exactly one `{slug}`. No other placeholder, empty segment, `..` or whitespace. -- A page route has to serve it (`{slug}` read as `:slug`). If a plugin exports its content types from `src/content.ts` and none does, `dev` and `build` stop with `content-url-without-page`. See [Content URLs need a page](/docs/dev/i18n/localized-urls#content-urls-need-a-page). +- A page route has to serve it (`{slug}` read as `:slug`). If a plugin exports its content types from `src/content.ts` and none does, `dev` and `build` stop with `content-url-without-page`. See [Content URLs need a page](/docs/dev/i18n/localized-urls-advanced#content-urls-need-a-page). - `{locale}` is no longer needed. A legacy leading `/{locale}/` (like `/{locale}/blog/article/{slug}`) is still accepted and stripped. - `{locale}` anywhere else fails with: `search.pathTemplate "..." places "{locale}" somewhere other than the start of the path. The language is no longer part of the template: i18n.localePrefix, i18n.domains and i18n.routePaths now place it. Remove "{locale}" (recommended), or keep it only as the first segment ("/{locale}/...").` - A content type that isn't localized can't use `{locale}` at all. @@ -249,7 +251,7 @@ A rebuild is a queued job, so it only runs once [cron](/docs/dev/cron) is runnin --- -## Search Document Reference +## Search document reference <TypeTable type={{ @@ -259,6 +261,11 @@ A rebuild is a queued job, so it only runs once [cron](/docs/dev/cron) is runnin required: true, type: 'string', }, + createdAt: { + description: 'When the record was created. Sorts timelines and powers the date filter.', + required: true, + type: 'Date', + }, itemId: { description: 'Primary key of the indexed record. A number for serial ids, a string for Content Engine types with a uuid or bigint idStrategy. Stored as text in core_search_index.', @@ -302,10 +309,38 @@ A rebuild is a queued job, so it only runs once [cron](/docs/dev/cron) is runnin type: 'boolean', default: 'false', }, + languageCode: { + description: + 'The language of this document. Multi-language content indexes one document per language; with `itemType` and `itemId` it identifies the document.', + type: 'string', + default: "''", + }, + containerType: { + description: + 'The kind of container holding the item, such as a forum. Used with `containerId` to group items.', + type: 'string', + }, + containerId: { + description: 'The id of the container holding the item.', + type: 'number', + }, + metadata: { + description: 'Extra data returned with the hit.', + type: 'Record<string, unknown>', + }, + pluginId: { + description: + "The plugin that owns the document. Defaults to the current request's plugin; set it from cron jobs and queue tasks. A rebuild indexer fills it in for you.", + type: 'string', + }, + updatedAt: { + description: 'When the record last changed.', + type: 'Date', + }, }} /> -## Learn More +## Learn more <Cards> <Card @@ -314,7 +349,7 @@ A rebuild is a queued job, so it only runs once [cron](/docs/dev/cron) is runnin href="/docs/dev/database/search" /> <Card - title="Suggestions" + title="Search suggestions" description="Header search dialog, popular content and popularity signals" href="/docs/dev/search-suggestions" /> diff --git a/apps/web/content/docs/dev/server-functions.mdx b/apps/web/content/docs/dev/server-functions.mdx index 05ce4dccbb..55f8ceeb51 100644 --- a/apps/web/content/docs/dev/server-functions.mdx +++ b/apps/web/content/docs/dev/server-functions.mdx @@ -1,5 +1,5 @@ --- -title: Server Functions & Isomorphic Fetching +title: Server functions description: When to reach for the universal fetcher, the server fetcher, or a TanStack Start server function in VitNode. icon: ServerCog --- @@ -8,7 +8,7 @@ VitNode keeps backend business logic in Hono API routes. The frontend's only job is to call them - and for that, one universal fetcher covers SSR and browser navigation at once. -## Decision Matrix +## Pick the tool | Goal | Recommended tool | Rationale | | :----------------------------- | :---------------------------------------------- | :-------------------------------------------------------------------------------------------- | @@ -56,13 +56,23 @@ export const fetchDevices = async () => { ``` ```tsx title="plugins/devices/src/pages/devices-page.tsx" -import { definePluginRoute } from '@vitnode/core/routing' - -export const route = definePluginRoute({ - load: async () => await fetchDevices(), // [!code ++] +import { defineRoute } from '@vitnode/core/tanstack/plugin-routes' + +import { fetchDevices } from '../lib/fetch-devices' + +export const route = defineRoute({ + // [!code ++:5] + load: async ({ context }) => + await context.queryClient.query({ + queryKey: ['devices', 'list'], + queryFn: fetchDevices, + staleTime: 'static', + }), }) ``` +Loading through `context.queryClient` lets a hover preload and the click share one request; see [Preloading](/docs/dev/routing/preloading). + The initial render forwards the visitor's cookie, user-agent and IP through the request-aware transport; later navigations are a direct same-origin `fetch` to Hono. See [Fetcher](/docs/dev/fetcher) for the full contract. @@ -120,7 +130,7 @@ Keep code that imports `@vitnode/core/tanstack/fetcher/server` in a rejects these three options at compile time, so a call that needs one cannot end up in a shared module by accident. -## Learn More +## Related <Cards> <Card @@ -129,8 +139,8 @@ end up in a shared module by accident. href="/docs/dev/fetcher" /> <Card - title="Plugin Routes" - description="Declare routes, lazy components, and loaders" + title="Routing" + description="Declare plugin pages and their loaders" href="/docs/dev/routing" /> </Cards> diff --git a/apps/web/content/docs/dev/setup.mdx b/apps/web/content/docs/dev/setup.mdx index 213328cf81..e284961d9a 100644 --- a/apps/web/content/docs/dev/setup.mdx +++ b/apps/web/content/docs/dev/setup.mdx @@ -1,28 +1,28 @@ --- -title: Getting Started -description: Create a VitNode app, prepare Postgres, start TanStack Start, and sign in to AdminCP in five small steps. +title: Setup +description: Create a VitNode app with create-vitnode-app, start PostgreSQL, run the development server and sign in to the AdminCP as the first administrator. icon: Rocket --- -import { - BookOpenIcon, - PackagePlusIcon, - ServerIcon, - SettingsIcon, -} from 'lucide-react' -import { Tab, Tabs } from 'fumadocs-ui/components/tabs' +import { Tab, Tabs } from "fumadocs-ui/components/tabs"; -You need Node.js 22.18+ (or 24.11+, or 26+), Postgres (or Docker), and your -favorite package manager. -Choose a workspace with Turborepo if you will create plugins; plugins are the -home for product work, not an optional side quest. +This page takes you from an empty folder to a running VitNode app, with you signed in to the AdminCP as its administrator. It is the first lesson of the beginner path; the next one creates a plugin inside this app. + +## Before you begin + +- **Node.js** `22.18` or newer in the 22 line, `24.11` or newer in the 24 line, or `26` and later. The generator and every generated package refuse older versions. +- **A package manager:** pnpm, bun or npm. +- **PostgreSQL.** The easiest local option is Docker, which the generator can configure for you. Your own PostgreSQL server works too. <Steps> - <Step> + +<Step> ### Scaffold the app -<Tabs groupId="package-manager" persist items={["bun", "pnpm", "npm"]} label="Create VitNode app"> +Run the generator from the folder that should contain your project: + +<Tabs groupId="package-manager" persist items={["bun", "pnpm", "npm"]} label="Create a VitNode app"> ```bash tab="bun" bun create vitnode-app@canary @@ -38,16 +38,74 @@ npm create vitnode-app@canary </Tabs> -Pick **Single App** for TanStack Start plus Hono at `/api`. Turn on -**Turborepo** if you intend to use `--plugin`; it gives the generator a -workspace root and a place for `plugins/*`. +It asks for a project name (default `my-vitnode`), then these questions. The recommended answers set you up for the rest of the beginner path: + +| Question | Recommended | Why | +| --------------------- | ------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- | +| Package manager | the one you use | The generator offers only the managers you have installed. | +| App type | **Single App** | One TanStack Start app with the Hono API mounted at `/api`. **Monorepo App** splits web and API into two apps; **Only API** has no front end. | +| Use Turborepo? | **Yes** | Required to create plugins. A Monorepo App always gets it, so the generator skips the question. | +| Oxlint & Oxfmt? | your choice | Adds lint and format scripts. | +| Docker container? | **Yes**, unless you run PostgreSQL yourself | Adds `docker-compose.yml` with PostgreSQL 17 and Redis on `127.0.0.1`. | +| Git repository? | your choice | | +| Install dependencies? | **Yes** | Installs packages and generates the first migrations. | + +With Turborepo, the app lives in `apps/web` (plus `apps/api` for a Monorepo App) and your plugins will go in `plugins/`. Without it, the app sits at the project root and you cannot create plugins later. + +</Step> + +<Step> + +### Open the project + +<Tabs groupId="package-manager" persist items={["bun", "pnpm", "npm"]} label="Open the project"> + +```bash tab="bun" +cd my-vitnode +``` + +```bash tab="pnpm" +cd my-vitnode +``` + +```bash tab="npm" +cd my-vitnode +``` + +</Tabs> + +Run every command in this lesson, and in the lessons after it, from this folder. If you answered **No** to "Install dependencies?", install them now with `bun install`, `pnpm install` or `npm install`. + +</Step> - </Step> - <Step> +<Step> -### Start the database +### Configure the environment -If you selected Docker, start Postgres and Redis locally: +The app reads its settings from `.env`. A Monorepo App already has `apps/web/.env`; a Single App ships only `.env.example`. Copy it next to itself: + +```bash title="Single App with Turborepo" +cp apps/web/.env.example apps/web/.env +``` + +For a Monorepo App / Single API: + +```bash title="Monorepo App / Single API" +cp apps/api/.env.example apps/api/.env +``` + +The defaults match the Docker services: + +- PostgreSQL at `postgresql://root:root@localhost:5432/vitnode`. Using your own PostgreSQL? Change `POSTGRES_URL` to its connection string. +- Redis at `redis://localhost:6379` with the password `root`. Redis is optional. Delete `REDIS_URL` to run without it, and VitNode falls back to an in-process cache. + +</Step> + +<Step> + +### Start PostgreSQL + +With Docker, start the database and Redis in the background: <Tabs groupId="package-manager" persist items={["bun", "pnpm", "npm"]} label="Start local services"> @@ -65,16 +123,18 @@ npm run docker:dev </Tabs> - </Step> - - <Step> +Without Docker, make sure the server in `POSTGRES_URL` is running and the database exists. + +</Step> -### Start VitNode +<Step> + +### Start the development server <Tabs groupId="package-manager" persist items={["bun", "pnpm", "npm"]} label="Start the development server"> ```bash tab="bun" -bun dev +bun run dev ``` ```bash tab="pnpm" @@ -87,36 +147,32 @@ npm run dev </Tabs> -Open `http://localhost:3000`. +Before the server starts, `dev` builds your plugins, generates any pending migrations, applies them and seeds the default roles. You never run migrations by hand on this path. When it is ready, it prints the addresses: + +```txt + Web http://localhost:3000 + AdminCP http://localhost:3000/admin + API http://localhost:3000/api +``` + +</Step> + +<Step> + +### Create the administrator account + +VitNode has no installer. The **first account that registers becomes the administrator**. It gets the root Administrator role and a verified email. Every later account is a regular member. + +Open `http://localhost:3000/register` and create your account. Then open `http://localhost:3000/admin` and sign in with the same email and password. The AdminCP has its own sign-in, separate from the site's. + +</Step> - </Step> </Steps> -## Keep going - -<Cards> - <Card - icon={<SettingsIcon />} - title="Configuration" - description="Locales, plugins, request middleware, and what a browser may hold." - href="/docs/dev/configuration" - /> - <Card - icon={<PackagePlusIcon />} - title="Create a plugin" - description="Scaffold, register, and visit an installable feature package." - href="/docs/dev/plugins/create" - /> - <Card - icon={<ServerIcon />} - title="Deployments" - description="Move your TanStack Start and Hono app to production." - href="/docs/dev/deployments/self-hosted" - /> - <Card - icon={<BookOpenIcon />} - title="First plugin tutorial" - description="Follow a compact example that adds a real plugin page." - href="/docs/guides/first-plugin" - /> -</Cards> +## Check the result + +- `http://localhost:3000` shows the public site, and the header has your avatar. +- `http://localhost:3000/admin` shows the AdminCP dashboard after you sign in. +- **Staff → Administrators** in the AdminCP lists the **Administrator** role as unrestricted. + +If `dev` stops with a database error, PostgreSQL is not reachable at `POSTGRES_URL`. Check that the containers are up (`docker ps`) or that your own server accepts the connection. diff --git a/apps/web/content/docs/dev/sso/connected-accounts.mdx b/apps/web/content/docs/dev/sso/connected-accounts.mdx index 81806f3658..ace0d2e7c4 100644 --- a/apps/web/content/docs/dev/sso/connected-accounts.mdx +++ b/apps/web/content/docs/dev/sso/connected-accounts.mdx @@ -1,5 +1,5 @@ --- -title: Connected Accounts +title: Connected accounts description: Let members connect Google, Discord, Facebook or custom SSO accounts from Settings, import their avatar and name, and keep them in sync on sign-in. icon: Link --- @@ -8,7 +8,7 @@ import { TypeTable } from "fumadocs-ui/components/type-table" **Settings → Connected accounts** (`/settings/sso`) is where a signed-in member manages their SSO accounts. It shows up in the settings menu as soon as at least one [SSO adapter](/docs/dev/sso) is configured - no extra setup, no extra redirect URIs. -// Image prompt: A settings page titled "Connected accounts" with three grouped lists - "Sign-in accounts" (Google with an email and "Connected Mar 14", Discord, Facebook with a Connect button), "Profile" (Avatar, First name, Last name rows, each with its current value and a "From Google" / "Manual" dropdown) and "Sync" (Google · First name, an "On sign-in" switch and a "Sync now" button), light theme, blue accents. +{/* Image prompt: A settings page titled "Connected accounts" with three grouped lists - "Sign-in accounts" (Google with an email and "Connected Mar 14", Discord, Facebook with a Connect button), "Profile" (Avatar, First name, Last name rows, each with its current value and a "From Google" / "Manual" dropdown) and "Sync" (Google · First name, an "On sign-in" switch and a "Sync now" button), light theme, blue accents. */} The page uses the same grouped rows as the rest of Settings, in three groups: @@ -118,6 +118,7 @@ Mutations take JSON bodies, which the browser can't send cross-site without a CO - [`user.sso.linked`](/docs/dev/events/built-in-events) - a provider was connected. - [`user.sso.unlinked`](/docs/dev/events/built-in-events) - a provider was disconnected. - [`user.sso.profile_synced`](/docs/dev/events/built-in-events) - profile fields changed because of a provider. +- [`user.sso.preferences_updated`](/docs/dev/events/built-in-events) - profile-field sources or sync-on-sign-in settings were saved, by the member or by staff. ## Adapter support diff --git a/apps/web/content/docs/dev/sso/custom-adapter.mdx b/apps/web/content/docs/dev/sso/custom-adapter.mdx index 629c3508e0..a22b418bc0 100644 --- a/apps/web/content/docs/dev/sso/custom-adapter.mdx +++ b/apps/web/content/docs/dev/sso/custom-adapter.mdx @@ -1,22 +1,27 @@ --- -title: Custom SSO Adapter -description: Build a custom OAuth2 Single Sign-On adapter for any provider with the SSOApiPlugin interface. +title: Custom adapter +description: Build a custom OAuth2 single sign-on adapter for VitNode with the SSOApiPlugin interface, using GitHub as the example. icon: Puzzle --- import { TypeTable } from "fumadocs-ui/components/type-table" -VitNode includes built-in SSO adapters for Google, Discord, and Facebook. You can connect any other OAuth2 provider (e.g. GitHub, Apple, Slack) by implementing the `SSOApiPlugin` interface. State cookie generation, user session linking, and login buttons are handled automatically. +VitNode ships SSO adapters for Google, Discord and Facebook. To connect any other OAuth2 provider, such as GitHub, write an object that implements `SSOApiPlugin`. VitNode handles the state cookie, account linking, sessions and the login button; your adapter only talks to the provider. -## Quick start: GitHub SSO Adapter +## Before you begin -Implement the 5 interface methods and register the adapter in `vitnode.api.config.ts`: +- An OAuth app at the provider. For GitHub, create one under **Settings → Developer settings → OAuth Apps** with the callback URL `http://localhost:3000/login/sso/github` (your `VITNODE_WEB_URL` plus `login/sso/github`), and put its client ID and secret in the API's `.env` as `GITHUB_CLIENT_ID` and `GITHUB_CLIENT_SECRET`. -### 1. Build the Adapter +## Build a GitHub adapter + +An adapter has three functions: `getUrl`, `fetchToken` and `fetchUser`. + +### 1. Write the adapter ```ts title="apps/api/src/utils/sso/github.ts" import type { SSOApiPlugin } from "@vitnode/core/api/models/sso" import { getRedirectUri } from "@vitnode/core/api/models/sso" +import { HTTPException } from "hono/http-exception" export const GitHubSSOApiPlugin = ({ clientId, @@ -32,11 +37,11 @@ export const GitHubSSOApiPlugin = ({ id, name: "GitHub", profileFields: ["avatar"], - // 1. Build Authorization URL + // 1. Send the browser to the provider getUrl: ({ state }) => `https://github.com/login/oauth/authorize?client_id=${clientId}&redirect_uri=${encodeURIComponent(redirectUri)}&scope=read:user,user:email&state=${state}`, - // 2. Exchange Authorization Code for Token + // 2. Exchange the authorization code for a token fetchToken: async (code) => { const response = await fetch("https://github.com/login/oauth/access_token", { method: "POST", @@ -52,10 +57,15 @@ export const GitHubSSOApiPlugin = ({ }), }) - return await response.json() + const data = await response.json() + if (!data.access_token) { + throw new HTTPException(400, { message: "GitHub refused the code" }) + } + + return { access_token: data.access_token, token_type: data.token_type } }, - // 3. Retrieve User Profile & Email + // 3. Read the provider account fetchUser: async ({ access_token, token_type }) => { const headers = { Authorization: `${token_type} ${access_token}` } @@ -68,11 +78,14 @@ export const GitHubSSOApiPlugin = ({ const emails: Array<{ email: string; primary: boolean; verified: boolean }> = await emailRes.json() const primaryEmail = emails.find((e) => e.primary && e.verified)?.email - if (!primaryEmail) throw new Error("GitHub account has no verified email") + if (!primaryEmail) { + throw new HTTPException(400, { message: "Email not verified" }) + } return { id: String(user.id), email: primaryEmail, + emailVerified: true, // only a verified primary address gets this far username: user.login, avatarUrl: user.avatar_url, } @@ -81,9 +94,9 @@ export const GitHubSSOApiPlugin = ({ } ``` ---- +`getRedirectUri(id)` returns `{VITNODE_WEB_URL}login/sso/{id}`; send the same value from `getUrl` and `fetchToken`. Throw an `HTTPException` to fail a sign-in with a clear status; a plain `Error` becomes a `500`. -### 2. Register in API Configuration +### 2. Register it in the API config ```ts title="apps/api/src/vitnode.api.config.ts" import { buildApiConfig } from "@vitnode/core/vitnode.config" @@ -102,11 +115,11 @@ export const vitNodeApiConfig = buildApiConfig({ }) ``` -A **GitHub** login button automatically renders on `/login` and `/register`, and `/login/sso/github` routes incoming authentication requests. It is a neutral, text-only button until you give the adapter an [icon and a brand color](/docs/dev/sso/icons). +## Check the result ---- +Restart the API and open `/login`. A **Continue with GitHub** button appears on `/login` and `/register`; pressing it sends you to GitHub and back to `/login/sso/github`, signed in. It is a neutral, text-only button until you give the adapter an [icon and a brand color](/docs/dev/sso/icons). -## `SSOApiPlugin` Interface Reference +## `SSOApiPlugin` reference <TypeTable type={{ @@ -121,12 +134,12 @@ A **GitHub** login button automatically renders on `/login` and `/register`, and type: "string", }, icon: { - description: "Brand mark for the login button: inline SVG markup or an image URL. See Provider Icons and Colors.", + description: "Brand mark for the login button: inline SVG markup or an image URL. See Provider icons.", required: false, type: "string", }, brandColor: { - description: "Hex color (#RGB or #RRGGBB) that fills the login button. The label turns white or near-black, whichever reads better. See Provider Icons and Colors.", + description: "Hex color (#RGB or #RRGGBB) that fills the login button. The label turns white or near-black, whichever reads better. See Provider icons.", required: false, type: "string", }, @@ -153,7 +166,7 @@ A **GitHub** login button automatically renders on `/login` and `/register`, and }} /> -See [Provider Icons and Colors](/docs/dev/sso/icons) for the accepted `icon` and `brandColor` formats, and for registering a React component instead. +See [Provider icons](/docs/dev/sso/icons) for the accepted `icon` and `brandColor` formats, and for registering a React component instead. ## Profile data @@ -182,21 +195,21 @@ All three are optional, so adapters written before this still work - they just h - `avatarUrl` must be `https`. VitNode downloads and re-stores it itself, with SSRF protection and size and type checks, so you don't have to. - Don't add scopes just for profile data. If a field needs extra permission, leave it out of `profileFields`. -## Built-in SSO Adapters +## Built-in adapters <Cards> <Card - title="Google SSO" + title="Google" description="Configure Google OAuth credentials" href="/docs/dev/sso/google" /> <Card - title="Discord SSO" + title="Discord" description="Configure Discord OAuth credentials" href="/docs/dev/sso/discord" /> <Card - title="Facebook SSO" + title="Facebook" description="Configure Facebook OAuth credentials" href="/docs/dev/sso/facebook" /> diff --git a/apps/web/content/docs/dev/sso/discord.mdx b/apps/web/content/docs/dev/sso/discord.mdx index 54e8816859..53f3416c91 100644 --- a/apps/web/content/docs/dev/sso/discord.mdx +++ b/apps/web/content/docs/dev/sso/discord.mdx @@ -170,10 +170,15 @@ Ask the API what it thinks it supports: ```bash curl http://localhost:3000/api/@vitnode/core/middleware -# {"isEmail":false,"sso":[{"id":"discord","name":"Discord"}]} ``` -Then open `/login` and press **Discord**. You should see Discord's "connect to" +The `sso` array in the response now lists the provider. Here it is abridged, with the SVG icon cut short: + +```json +{ "sso": [{ "id": "discord", "name": "Discord", "icon": "<svg ...>", "brandColor": "#5865F2" }] } +``` + +Then open `/login` and press the **Discord** button under **Continue with**. You should see Discord's "connect to" authorization prompt listing your username and email, get bounced back to `/login/sso/discord?code=...&state=...`, and land on the front page signed in. @@ -195,7 +200,7 @@ permissions. | Profile URL | `https://discord.com/api/users/@me` | | Scopes | `identify email` | | Provider id | `discord` | -| Fields read | `id`, `email`, `username`, `avatar` | +| Fields read | `id`, `email`, `verified`, `username`, `avatar` | Both scopes are required: `identify` for the id and username, `email` for the address the account is created with. @@ -216,11 +221,11 @@ avatar is offered. generic error screen. </Callout> -<Callout type="warn" title="Discord's email verification flag is not checked"> - Unlike the Google adapter, which rejects an unverified address, this one takes - Discord's `email` at face value and never reads Discord's `verified` field. If - you need that guarantee, copy the adapter into your own project and add the - check - see [Custom adapter](/docs/dev/sso/custom-adapter). +<Callout type="warn" title="Unverified Discord emails are rejected"> + The adapter reads Discord's `verified` field and answers `400 Email not + verified` when it is `false`, so a Discord account with an unconfirmed address + cannot sign in until its owner confirms it in Discord. The visitor sees the + generic error screen. </Callout> <Callout type="warn" title="A missing key fails on click, not on boot"> @@ -230,7 +235,7 @@ avatar is offered. toast is an environment problem, not a portal problem. </Callout> -## Next +## Related <Cards> <Card diff --git a/apps/web/content/docs/dev/sso/facebook.mdx b/apps/web/content/docs/dev/sso/facebook.mdx index de4baa9269..0988423b4c 100644 --- a/apps/web/content/docs/dev/sso/facebook.mdx +++ b/apps/web/content/docs/dev/sso/facebook.mdx @@ -204,10 +204,15 @@ Ask the API what it thinks it supports: ```bash curl http://localhost:3000/api/@vitnode/core/middleware -# {"isEmail":false,"sso":[{"id":"facebook","name":"Facebook"}]} ``` -Then open `/login` and press **Facebook**. You should get Meta's "Continue as +The `sso` array in the response now lists the provider. Here it is abridged, with the SVG icon cut short: + +```json +{ "sso": [{ "id": "facebook", "name": "Facebook", "icon": "<svg ...>", "brandColor": "#0866FF" }] } +``` + +Then open `/login` and press the **Facebook** button under **Continue with**. You should get Meta's "Continue as ..." dialog, be bounced back to `/login/sso/facebook?code=...&state=...`, and land on the front page signed in. Your account now has a row in `core_users_sso` with `providerId` `facebook`. @@ -282,17 +287,13 @@ counts as no avatar. toast is an environment problem, not a dashboard problem. </Callout> -<Callout - type="warn" - title="An email that already has a password account is a dead end" -> - If someone registered with the same address using the password form, the - callback answers `409` and offers them the login page instead. There is no - link-an-existing-account screen yet - see [how the callback handles - accounts](/docs/dev/sso#what-the-callback-does-with-the-account). +<Callout type="info" title="An email that already has an account"> + If someone already registered with the same address, Facebook sign-in asks for + that account's password once and then links the two. See [Linking an existing + account](/docs/dev/sso#how-accounts-link). </Callout> -## Next +## Related <Cards> <Card diff --git a/apps/web/content/docs/dev/sso/google.mdx b/apps/web/content/docs/dev/sso/google.mdx index d3ca1a0a74..e3addc82e0 100644 --- a/apps/web/content/docs/dev/sso/google.mdx +++ b/apps/web/content/docs/dev/sso/google.mdx @@ -206,15 +206,20 @@ Ask the API what it thinks it supports: ```bash curl http://localhost:3000/api/@vitnode/core/middleware -# {"isEmail":false,"sso":[{"id":"google","name":"Google"}]} ``` -Then open `/login` and press **Google**. You should be sent to Google's consent +The `sso` array in the response now lists the provider. Here it is abridged, with the SVG icon cut short: + +```json +{ "sso": [{ "id": "google", "name": "Google", "icon": "<svg ...>" }] } +``` + +Then open `/login` and press the **Google** button under **Continue with**. You should be sent to Google's consent screen, bounced back to `/login/sso/google?code=...&state=...`, and land on the front page signed in. Your account now has a row in `core_users_sso` with `providerId` `google`. -{/* Image prompt: The VitNode /login page with the email and password fields above a divider reading "Or continue With" and an outline button labelled Google beneath it, light theme, 900x850. */} +{/* Image prompt: The VitNode /login page split in two columns - left a "Continue with" heading over a neutral "Google" button with the four-colour G, right the email and password form, a vertical "or" divider between them, light theme, 900x600. */} </Step> @@ -271,17 +276,13 @@ account without one simply has nothing to import for that field. console. </Callout> -<Callout - type="warn" - title="An email that already has a password account is a dead end" -> - If someone registered with the same address using the password form, the - callback answers `409` and offers them the login page instead. There is no - link-an-existing-account screen yet - see [how the callback handles - accounts](/docs/dev/sso#what-the-callback-does-with-the-account). +<Callout type="info" title="An email that already has an account"> + If someone already registered with the same address, Google sign-in asks for + that account's password once and then links the two. See [Linking an existing + account](/docs/dev/sso#how-accounts-link). </Callout> -## Next +## Related <Cards> <Card diff --git a/apps/web/content/docs/dev/sso/icons.mdx b/apps/web/content/docs/dev/sso/icons.mdx index 454ec62ce9..8769f1462b 100644 --- a/apps/web/content/docs/dev/sso/icons.mdx +++ b/apps/web/content/docs/dev/sso/icons.mdx @@ -1,5 +1,5 @@ --- -title: Provider Icons and Colors +title: Provider icons description: Put a brand mark and a brand color on your SSO login buttons - an inline SVG or image URL and a hex color from the adapter, or a React component registered in the browser. icon: Image --- @@ -24,18 +24,20 @@ give it a mark and a color. ## From the adapter (SVG or image) -Add an `icon` to whatever your adapter returns. It travels to the browser with +Add an `icon` to the object your adapter returns. It travels to the browser with the rest of the provider list, so it has to be a string: inline SVG markup, or a URL the browser can load as an image. +This excerpt adds it to the object the GitHub adapter from [Custom adapter](/docs/dev/sso/custom-adapter) returns: + ```ts title="apps/api/src/utils/sso/github.ts" -export const GitHubSSOApiPlugin = (): SSOApiPlugin => ({ - id: 'github', - name: 'GitHub', - // [!code ++] - icon: '<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path fill="currentColor" d="M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12" /></svg>', - // ... getUrl, fetchToken, fetchUser -}) + return { + id, + name: "GitHub", + // [!code ++] + icon: '<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path fill="currentColor" d="M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12" /></svg>', + // ... profileFields, getUrl, fetchToken, fetchUser + } ``` Use `fill="currentColor"` and leave the width and height off: the mark then @@ -44,12 +46,12 @@ takes the button's text color in both light and dark mode - white on a multicolor mark, such as Google's four-color G, keeps its own fills; give its adapter no brand color so it sits on the neutral button it was designed for. -An image works the same way: +An image works the same way. Use any one of these: ```ts -icon: 'https://cdn.example.com/brands/slack.svg' // remote file -icon: '/icons/slack.png' // something in your app's public folder -icon: 'data:image/png;base64,iVBORw0KGgo...' // inlined bytes +icon: "https://cdn.example.com/brands/github.svg", // remote file +icon: "/icons/github.png", // something in your app's public folder +icon: "data:image/png;base64,iVBORw0KGgo...", // inlined bytes ``` <Callout type="warn" title="What the login page will accept"> @@ -74,27 +76,57 @@ mystery. ### Importing an .svg file -You do not have to paste the markup. Where the adapter is bundled by Vite - -`apps/web`, or any generated VitNode app - import the file itself: +You do not have to paste the markup. Give the adapter factory an `icon` option +and pass the file in from the config. In the factory from [Custom +adapter](/docs/dev/sso/custom-adapter): + +```ts title="apps/web/src/utils/sso/github.ts" +export const GitHubSSOApiPlugin = ({ + clientId, + clientSecret, + icon, // [!code ++] +}: { + clientId: string + clientSecret: string + icon?: string // [!code ++] +}): SSOApiPlugin => { + // ... + return { + id, + name: "GitHub", + icon, // [!code ++] + // ... profileFields, getUrl, fetchToken, fetchUser + } +} +``` + +Where the config is bundled by Vite (`apps/web`, or any generated single-app +VitNode project), import the file itself: ```ts title="apps/web/src/vitnode.api.config.ts" -import githubIcon from './icons/github.svg?raw' // the file's markup -import slackIcon from './icons/slack.svg' // the file's URL +import { buildApiConfig } from "@vitnode/core/vitnode.config" + +import githubIcon from "./icons/github.svg?raw" // the file's markup +import { GitHubSSOApiPlugin } from "./utils/sso/github" export const vitNodeApiConfig = buildApiConfig({ authorization: { ssoAdapters: [ - GitHubSSOApiPlugin({ icon: githubIcon }), - SlackSSOApiPlugin({ icon: slackIcon }), + GitHubSSOApiPlugin({ + clientId: process.env.GITHUB_CLIENT_ID!, + clientSecret: process.env.GITHUB_CLIENT_SECRET!, + icon: githubIcon, + }), ], }, // ... }) ``` -Both work: `?raw` hands you the file's contents - prolog, `DOCTYPE`, generator -comment and all - and a plain import hands you a URL (`/assets/slack-a1b2c3.svg` -after a build, or an inlined `data:image/svg+xml,...` for a small file). Prefer +`?raw` hands you the file's whole contents, including the prolog, `DOCTYPE` and +generator comment. A plain `import githubLogo from "./icons/github.svg"` hands you a URL instead +(`/assets/github-a1b2c3.svg` after a build, or an inlined +`data:image/svg+xml,...` for a small file), and that works as an `icon` too. Prefer `?raw` when you want the mark to follow the button's text colour, since a file loaded through `<img>` cannot see `currentColor`. @@ -123,14 +155,14 @@ Add a `brandColor` to fill the button with the provider's color, the way the built-in Discord (`#5865F2`) and Facebook (`#0866FF`) adapters do: ```ts title="apps/api/src/utils/sso/github.ts" -export const GitHubSSOApiPlugin = (): SSOApiPlugin => ({ - id: 'github', - name: 'GitHub', - icon: githubIcon, - // [!code ++] - brandColor: '#24292F', - // ... getUrl, fetchToken, fetchUser -}) + return { + id, + name: "GitHub", + icon: githubIcon, + // [!code ++] + brandColor: "#24292F", + // ... profileFields, getUrl, fetchToken, fetchUser + } ``` The value has to be a hex color - `#RGB` or `#RRGGBB`. The button picks its @@ -152,11 +184,11 @@ id. A registered icon wins over whatever the adapter sent. ```tsx title="apps/web/src/router.tsx" import { configureSSOIcons } from '@vitnode/core/tanstack/auth' -import { GitHubIcon } from './components/icons/github' +import { GitLabIcon } from './components/icons/gitlab' // your own component configureSSOIcons({ - github: GitHubIcon, // a component: rendered with `className="size-4"` - slack: <img alt="" className="size-4" src="/icons/slack.png" />, // or JSX + gitlab: GitLabIcon, // a component: rendered with `className="size-4"` + github: <img alt="" className="size-4" src="/icons/github.png" />, // or JSX }) ``` @@ -195,12 +227,12 @@ needs a wrapper element. <Cards> <Card - title="Custom SSO Adapter" + title="Custom adapter" description="Build an adapter for any OAuth2 provider" href="/docs/dev/sso/custom-adapter" /> <Card - title="Single Sign-On" + title="SSO" description="Register adapters and link accounts" href="/docs/dev/sso" /> diff --git a/apps/web/content/docs/dev/sso/index.mdx b/apps/web/content/docs/dev/sso/index.mdx index a6af4571ef..7a65e35257 100644 --- a/apps/web/content/docs/dev/sso/index.mdx +++ b/apps/web/content/docs/dev/sso/index.mdx @@ -1,10 +1,10 @@ --- -title: Single Sign-On (SSO) -description: Enable social authentication with Google, Discord, Facebook, or custom OAuth2 providers in VitNode. +title: SSO +description: Add single sign-on to VitNode with Google, Discord, Facebook or a custom OAuth2 adapter, and learn how SSO sign-ins find, link or create accounts. icon: KeyRound --- -VitNode provides built-in OAuth2 single sign-on. Register an adapter in your API configuration, and authentication buttons automatically appear on `/login` and `/register`. +VitNode has built-in OAuth2 single sign-on (SSO). Register an adapter in your API configuration, and a sign-in button for that provider appears on `/login` and `/register`. This page covers registering an adapter, the callback URL to give the provider, and how an SSO sign-in finds, links or creates an account. ## Quick start @@ -19,8 +19,8 @@ export const vitNodeApiConfig = buildApiConfig({ authorization: { ssoAdapters: [ GoogleSSOApiPlugin({ - clientId: process.env.GOOGLE_CLIENT_ID!, - clientSecret: process.env.GOOGLE_CLIENT_SECRET!, + clientId: process.env.GOOGLE_CLIENT_ID, + clientSecret: process.env.GOOGLE_CLIENT_SECRET, }), ], }, @@ -28,8 +28,9 @@ export const vitNodeApiConfig = buildApiConfig({ ``` A **Google** button automatically renders on your login and registration forms, -four-color G included. The built-in adapters ship their own marks and colors - -Discord and Facebook buttons are filled with their brand color - and a custom +four-color G included. [Google](/docs/dev/sso/google) shows where the client ID +and secret come from. The built-in adapters ship their own marks and colors, and +the Discord and Facebook buttons are filled with their brand color. A custom adapter can [add its own](/docs/dev/sso/icons). ## On the login page @@ -51,7 +52,7 @@ providers first, then the form. --- -## Supported Providers +## Supported providers <Cards> <Card @@ -70,12 +71,12 @@ providers first, then the form. href="/docs/dev/sso/facebook" /> <Card - title="Custom OAuth2 Adapter" + title="Custom adapter" description="Connect GitHub, Apple, or any OAuth2 provider" href="/docs/dev/sso/custom-adapter" /> <Card - title="Provider Icons and Colors" + title="Provider icons" description="Put a brand mark and color on the login buttons" href="/docs/dev/sso/icons" /> @@ -91,18 +92,35 @@ When configuring OAuth2 applications in provider developer consoles, use the fol https://your-domain.com/login/sso/{provider_id} ``` -For example: `https://your-domain.com/login/sso/google`. +For example: `https://your-domain.com/login/sso/google`. The domain is your +`VITNODE_WEB_URL`. + +## The round trip + +1. The sign-in button asks the API for the provider's authorization URL. The API + creates a random `state` value, stores a salted hash of it in the + `vitnode_auth--state-sso` cookie (named after `authorization.cookieName`), + and sends the browser to the provider with the plain value. +2. The provider redirects back to `/login/sso/{provider_id}` with a `code` and + the `state`. +3. The API checks `state` against the cookie and deletes the cookie. A missing + or different value answers `400 Invalid state`, so a callback link someone + else started cannot sign you in. +4. The adapter exchanges the `code` for a token, reads the provider account, and + VitNode signs in, links or creates the account as described below. + +The state cookie has no expiry date, so it never outlives the browser session. --- -## How Accounts Link +## How accounts link When a user signs in via SSO: -1. **Known Identity**: The provider account was linked before, so the user is signed straight into it. -2. **Existing Email Match**: An account already uses that address. The visitor is asked for that account's password; once it checks out, the provider identity is linked to the account and they are signed in. From then on that provider signs them straight in (case 1). A social login can never take over an account on its own - the password is what proves ownership. -3. **New Visitor**: A new user is created with their social display name and email. Avatar and real name stay empty until the member imports them from [Connected accounts](/docs/dev/sso/connected-accounts). +1. **Known identity.** The provider account was linked before, so the user is signed straight into it. +2. **Existing email match.** An account already uses that address. The visitor is asked for that account's password; once it checks out, the provider identity is linked to the account and they are signed in. From then on that provider signs them straight in (case 1). A social login can never take over an account on its own, because the password is what proves ownership. +3. **New visitor.** A new user is created with their social display name and email. Avatar and real name stay empty until the member imports them from [Connected accounts](/docs/dev/sso/connected-accounts). -Signed-in members can also connect more providers, import their avatar and name, and disconnect accounts in **Settings → Connected accounts** - see [Connected Accounts](/docs/dev/sso/connected-accounts). +Signed-in members can also connect more providers, import their avatar and name, and disconnect accounts in **Settings → Connected accounts**. See [Connected accounts](/docs/dev/sso/connected-accounts). ### SSO confirms the email @@ -134,18 +152,18 @@ points back to the login page. Every successful link emits [`user.sso.linked`](/docs/dev/events/built-in-events). -The address the provider returns is matched in its canonical form, so a Google account that reports `jan.kowalski@gmail.com` finds the member who registered as `jankowalski@gmail.com` instead of quietly becoming a second account. See [One Mailbox, One Account](/docs/dev/advanced/auth#one-mailbox-one-account). +The address the provider returns is matched in its canonical form, so a Google account that reports `jan.kowalski@gmail.com` finds the member who registered as `jankowalski@gmail.com` instead of quietly becoming a second account. See [One mailbox, one account](/docs/dev/advanced/auth#one-mailbox-one-account). -## Learn More +## Related <Cards> <Card - title="Users" - description="Session cookies and user model structure" + title="Current user" + description="Read the signed-in user and the user record" href="/docs/dev/working-with-users/users" /> <Card - title="Custom SSO Adapter" + title="Custom adapter" description="Implement SSOApiPlugin for other providers" href="/docs/dev/sso/custom-adapter" /> diff --git a/apps/web/content/docs/dev/sso/meta.json b/apps/web/content/docs/dev/sso/meta.json index fbdfb43945..57771ebbf1 100644 --- a/apps/web/content/docs/dev/sso/meta.json +++ b/apps/web/content/docs/dev/sso/meta.json @@ -1,5 +1,5 @@ { - "title": "Single Sign-On (SSO)", + "title": "SSO", "description": "OAuth2 sign-in with Google, Discord, Facebook or your own adapter", "icon": "KeyRound", "pages": [ diff --git a/apps/web/content/docs/dev/storage/custom-adapter.mdx b/apps/web/content/docs/dev/storage/custom-adapter.mdx index 1388a159f9..2501a08c40 100644 --- a/apps/web/content/docs/dev/storage/custom-adapter.mdx +++ b/apps/web/content/docs/dev/storage/custom-adapter.mdx @@ -25,7 +25,7 @@ export interface StorageApiPlugin { Return an object of that shape from a factory, set it as `storage.adapter` in your API config, and VitNode stores files through it. The -[working skeleton](#write-one) below is about 60 lines. +[working skeleton](#write-one) below is short enough to read in one go. ## The three methods @@ -247,12 +247,12 @@ starting template: - [`@vitnode/s3`](https://github.com/aXenDeveloper/vitnode/tree/canary/packages/s3) - a lazily created SDK client, a URL built from three rules - [`@vitnode/supabase-storage`](https://github.com/aXenDeveloper/vitnode/tree/canary/packages/supabase-storage) - - the shortest possible adapter, at under 60 lines + the shortest real adapter Keep `@vitnode/core` a dev dependency (you only import types from it) and ship your provider's SDK as a real dependency, the way both of those do. -## Next +## Related <Cards> <Card diff --git a/apps/web/content/docs/dev/storage/index.mdx b/apps/web/content/docs/dev/storage/index.mdx index 0fca1a9831..110639c0d0 100644 --- a/apps/web/content/docs/dev/storage/index.mdx +++ b/apps/web/content/docs/dev/storage/index.mdx @@ -1,160 +1,292 @@ --- title: Storage -description: Upload, serve, and delete files in VitNode with pluggable storage adapters - local disk, AWS S3, Cloudflare R2, or Supabase Storage. +description: Upload images in a VitNode plugin route with c.get("storage"). Validate the file, read its public URL and dimensions, and delete it again, on local disk, S3, R2 or Supabase. icon: HardDrive --- -import { Tab, Tabs } from "fumadocs-ui/components/tabs" +`c.get("storage")` stores uploaded files through the adapter you configure, records each one in the `core_files` table and gives you back its public URL. In this guide the `site-notes` plugin gets an admin endpoint that uploads an image for a note, and another that deletes it. -VitNode provides an integrated storage layer accessible via `c.get("storage")`. It handles file validation, automatic WebP image optimization, database indexing in `core_files`, and persistent storage across multiple backends. +## Before you begin -## Quick start +- **A storage adapter.** Set `storage.adapter` in your `vitnode.api.config.ts`: [Local disk](/docs/dev/storage/local) for development, or [S3 / R2](/docs/dev/storage/s3-r2) or [Supabase](/docs/dev/storage/supabase) in production. Without one, uploads fail with `500`. +- The `site-notes` plugin with the **Create notes** permission, from the beginner path up to [Permissions](/docs/dev/working-with-users/staff-permissions). Any plugin with an admin route works the same way. -### 1. Register a Storage Adapter +<Steps> -In `apps/api/src/vitnode.api.config.ts`, configure the local disk adapter: +<Step> -```ts title="apps/api/src/vitnode.api.config.ts" -import { LocalStorageAdapter } from "@vitnode/core/api/adapters/storage/local" -import { buildApiConfig } from "@vitnode/core/vitnode.config" +### Create the upload route -export const vitNodeApiConfig = buildApiConfig({ - storage: { - adapter: LocalStorageAdapter(), // [!code ++] - }, -}) -``` - -### 2. Upload Files in an API Route +Create `src/api/modules/admin/notes/upload-image.route.ts`: -Handle uploads in a Hono route using `c.get("storage").upload()`: +```ts title="plugins/site-notes/src/api/modules/admin/notes/upload-image.route.ts" +import { z } from "@hono/zod-openapi"; -```ts title="plugins/blog/src/api/modules/posts/routes/upload-cover.route.ts" -import { z } from "@hono/zod-openapi" +import { buildRoute } from "@/api/builders"; -import { buildRoute } from "@/api/builders" - -export const uploadCoverRoute = buildRoute({ +export const uploadNoteImageRoute = buildRoute({ + adminStaffPermission: { module: "notes", permission: "can_create" }, route: { method: "post", - path: "/cover", + path: "/images", request: { body: { + required: true, content: { "multipart/form-data": { schema: z.object({ - file: z.instanceof(File), + file: z.instanceof(File).openapi({ type: "string", format: "binary" }), + }), + }, + }, + }, + }, + responses: { + 201: { + content: { + "application/json": { + schema: z.object({ + id: z.number(), + url: z.string(), + width: z.number().nullable(), + height: z.number().nullable(), }), }, }, + description: "The stored image.", }, }, }, - handler: async (c) => { - const { file } = await c.req.parseBody() - - // [!code ++:7] - const uploaded = await c.get("storage").upload({ - file: file as File, - folder: "covers", - maxBytes: 5 * 1024 * 1024, // 5 MB + handler: async c => { + const { file } = c.req.valid("form"); + const image = await c.get("storage").upload({ + file, + folder: "site-notes", + maxBytes: 2 * 1024 * 1024, allowedMimeTypes: ["image/jpeg", "image/png", "image/webp"], - }) - - return c.json(uploaded) + }); + + return c.json( + { + id: image.id, + url: image.url, + width: image.dimensions?.width ?? null, + height: image.dimensions?.height ?? null, + }, + 201, + ); }, -}) +}); ``` -`upload()` saves the file, creates a record in `core_files`, and returns `{ id, url, width, height, size }`. +- **`adminStaffPermission`** keeps uploads to administrators with **Create notes**. An upload route without a guard lets any visitor fill your bucket. +- **`c.req.valid("form")`** reads the file the route's schema validated. A request without a file never reaches the handler. +- **`maxBytes` and `allowedMimeTypes`** refuse a file that is too large or of another type with `400`. VitNode also reads the first bytes of every image, so an HTML page renamed `cat.png` is refused too. +- **`folder`** groups the plugin's uploads in the bucket. See [Storage keys](#storage-keys). ---- +</Step> -## Supported Storage Adapters +<Step> -<Cards> - <Card - title="Local Disk" - description="Stores files in public/uploads for local development" - href="/docs/dev/storage/local" - /> - <Card - title="AWS S3 / Cloudflare R2" - description="Scalable object storage via @vitnode/s3" - href="/docs/dev/storage/s3-r2" - /> - <Card - title="Supabase Storage" - description="Native Supabase bucket storage" - href="/docs/dev/storage/supabase" - /> - <Card - title="Custom Storage Adapter" - description="Implement your own storage backend" - href="/docs/dev/storage/custom-adapter" - /> -</Cards> +### Create the delete route ---- +Create `src/api/modules/admin/notes/delete-image.route.ts`. It deletes by the file's **id**, the number `upload()` returned: -## Image Optimization +```ts title="plugins/site-notes/src/api/modules/admin/notes/delete-image.route.ts" +import { z } from "@hono/zod-openapi"; -Turn on `storage.image` and VitNode re-encodes JPEG, PNG, AVIF, TIFF and WebP uploads with [sharp](https://sharp.pixelplumbing.com/), converts them to WebP, and records their pixel dimensions: +import { buildRoute } from "@/api/builders"; -```ts title="apps/api/src/vitnode.api.config.ts" -export const vitNodeApiConfig = buildApiConfig({ - storage: { - adapter: LocalStorageAdapter(), - // [!code ++:5] - image: { - quality: 85, // 1-100, defaults to 85 - webp: true, // set to false to keep the original format - maxPixels: 40_000_000, // width x height, defaults to 40 million +export const deleteNoteImageRoute = buildRoute({ + adminStaffPermission: { module: "notes", permission: "can_create" }, + route: { + method: "delete", + path: "/images/{id}", + request: { + params: z.object({ id: z.coerce.number().int().positive() }), + }, + responses: { + 204: { description: "The image and its file record are gone." }, + 404: { description: "No file has this id." }, + 409: { description: "Content still uses the file." }, }, }, -}) + handler: async c => { + const { id } = c.req.valid("param"); + await c.get("storage").deleteFile(id); + + return c.body(null, 204); + }, +}); ``` -`maxPixels` is your guard against image bombs - a tiny PNG that claims to be 16383x16383 pixels and unpacks into gigabytes of memory. Anything over the limit is refused with a `400` before a single pixel is decoded. The default fits an 8K photo with room to spare, so most installs never need to touch it. +Add both routes to the `routes` of `adminNotesModule` in `src/api/modules/admin/notes/notes.module.ts`. + +</Step> + +</Steps> + +## Check the result + +Add a temporary component that uploads a picked image with `fetcher`, shows it, and deletes it again. Create `src/pages/check-note-image.tsx`: + +```tsx title="plugins/site-notes/src/pages/check-note-image.tsx" +import { Button } from "@vitnode/core/components/ui/button"; +import { fetcher } from "@vitnode/core/tanstack/fetcher"; +import { useState } from "react"; + +import { manifest } from "@/manifest.gen"; + +export const CheckNoteImage = () => { + const [image, setImage] = useState<{ id: number; url: string }>(); + const [status, setStatus] = useState<number>(); + const [isPending, setIsPending] = useState(false); + + const upload = async (file: File) => { + const formData = new FormData(); + formData.append("file", file); + + setIsPending(true); + try { + const response = await fetcher({ + plugin: manifest.id, + method: "post", + module: "admin/notes", + path: "/images", + formData, + }); + setStatus(response.status); + if (response.status === 201) setImage(await response.json()); + } finally { + setIsPending(false); + } + }; + + const remove = async (id: number) => { + setIsPending(true); + try { + const response = await fetcher({ + plugin: manifest.id, + method: "delete", + module: "admin/notes", + path: "/images/{id}", + args: { params: { id } }, + }); + setStatus(response.status); + } finally { + setIsPending(false); + } + }; + + return ( + <div className="flex flex-col gap-3"> + <input + accept="image/*" + aria-label="Image to upload" + disabled={isPending} + onChange={event => { + const file = event.target.files?.[0]; + if (file) void upload(file); + }} + type="file" + /> + {status ? <output>{status}</output> : null} + {image ? ( + <> + <img alt="Uploaded test" className="max-w-xs rounded-md" src={image.url} /> + <Button isLoading={isPending} onClick={() => remove(image.id)} variant="destructive"> + Delete it + </Button> + </> + ) : null} + </div> + ); +}; +``` -### What gets checked on every upload +`formData` replaces `args` for a multipart route. `fetcher` sends it as the body and lets the browser set the boundary. -Whether or not `storage.image` is set, an upload declared as JPEG, PNG, WebP, GIF, AVIF or TIFF must actually _be_ that format. The type a client sends is just a claim (a browser guesses it from the file name), so VitNode reads the file's first bytes and refuses a mismatch - say, an HTML page named `cat.gif` - with a `400`. SVG and non-image files have no fixed signature and are not affected; limit them with `allowedMimeTypes`. +Render `<CheckNoteImage />` in `src/pages/notes-page.tsx`, sign in to the AdminCP in the same browser and open `/site-notes/notes`: ---- +1. Pick a PNG. The status reads `201` and the image appears, served from a URL such as `http://localhost:3000/api/uploads/2026/10/site-notes/cf8e….webp`. +2. Press **Delete it**. The status reads `204`, and reloading the image URL now answers `404`. +3. Sign out of the AdminCP and pick the image again. The status reads `403`. A PDF answers `400`. + +The upload's `width` and `height` are only filled in when [image processing](#image-processing) is on; otherwise they are `null`. The `.webp` extension comes from the same setting. Remove `CheckNoteImage` when you are done. + +## What `upload()` returns + +| Field | What it is | +| --- | --- | +| `id` | The `core_files` row. Store this in your own tables, and pass it to `deleteFile()`. | +| `key` | Where the adapter stored the bytes, such as `2026/10/site-notes/cf8e….webp`. | +| `url` | The public URL of those bytes, for `<img src>`. | +| `name` | The file name, changed to `.webp` when the image was converted. | +| `mimeType` | The stored type, after any conversion. | +| `size` | Bytes stored, after any processing. | +| `dimensions` | `{ width, height }`, or `null` when image processing did not read the file. | + +Save the `id`, not the URL. The adapter builds the URL from the key, so it changes if you move to another bucket or domain. + +## Deleting files + +| Call | What it removes | +| --- | --- | +| `deleteFile(id)` | The `core_files` row and the stored bytes. Use this for anything `upload()` created. | +| `delete(key)` | Only the bytes in the bucket. The row stays, pointing at nothing. Meant for adapter-level cleanup. | -## Deleting Files +`deleteFile()` protects files that are still in use: -Remove files and clean up storage using `delete()`: +- **Unused** files, such as the one above, are deleted. +- Files **referenced by content**, such as a Content Engine file field, are refused with `409` and `{ code: "FILE_IN_USE", content: true }`. Remove the file from that content first. +- Files **pinned only by old content revisions** are refused with `409`, `content: false` and the number of `revisions`. `deleteFile(id, { force: true })` releases those pins and deletes the file. +- A member's avatar or cover that uses the file is cleared. +- An id that does not exist returns `404`. Pass `ownerId` to delete only if that user uploaded the file. + +VitNode never deletes unused uploads by itself. When your plugin stops needing a file, for example because a note was deleted, call `deleteFile()`. + +## Storage keys + +Every upload is stored under a key built from the date, your `folder` and a random name: -```ts -await c.get("storage").delete({ fileId: 42 }) +``` +{year}/{month}/{folder}/{uuid}{extension} +2026/10/site-notes/cf8e90c3-49a4-4e21-abb5-27990e6459a3.webp ``` -This removes the file from the configured storage bucket and deletes its row from `core_files`. +- `folder` may be nested, such as `site-notes/covers`. Each segment starts with a letter or digit and holds only letters, digits, `-` and `_`. Anything else, including `..`, throws. +- The extension follows the stored type, not the uploaded name, so a converted image ends in `.webp`. +- The adapter turns the key into the public URL. With the local adapter that is `{baseUrl}/api/uploads/{key}`. ---- +## Image processing + +With `storage.image` set, VitNode re-encodes JPEG, PNG, AVIF, TIFF and WebP uploads with [sharp](https://sharp.pixelplumbing.com/), converts them to WebP and records their dimensions: + +```ts title="vitnode.api.config.ts" +storage: { + adapter: LocalStorageAdapter(), + // [!code ++:5] + image: { + quality: 85, // 1-100, defaults to 85 + webp: true, // false keeps the original format + maxPixels: 40_000_000, // width x height, defaults to 40 million + }, +}, +``` + +Without `image`, files are stored exactly as uploaded and `dimensions` is `null`. GIF and SVG are never converted, to keep animation and vectors, and an image wider or taller than 16,383 px keeps its format because WebP cannot hold it. -## Verifying in AdminCP +`maxPixels` guards against image bombs, tiny files that claim huge dimensions and would unpack into gigabytes of memory. VitNode refuses such a file with `400` before decoding it. The type check from the upload route runs whether or not `image` is set. -{/* Image prompt: VitNode AdminCP System -> Integrations screen at /admin/core/system/integrations. Storage integration card shows "Active" with a "Test file storage" button and modal demonstrating test file upload. Dark theme, 1440x900. */} +## Test your adapter -Test your storage adapter anytime in the AdminCP: -1. Navigate to **System → Integrations** (`/admin/core/system/integrations`). -2. On the **Storage** card, click **Test Storage** to verify bucket credentials and upload capabilities. -3. Inspect uploaded files anytime under **System → Files** (`/admin/core/system/files`). +In the AdminCP, open **System → Integrations** (`/admin/core/system/integrations`), choose **Test storage** on the **Storage** card and upload an image. It is stored under the `admin-storage-test` folder and shown back to you, which proves both the upload and the public URL work. Every uploaded file is listed under **System → Files** (`/admin/core/system/files`). -## Learn More +## Adapters <Cards> - <Card - title="Content Engine Fields" - description="Use field.file() in Content Engine models" - href="/docs/content-engine/fields" - /> - <Card - title="Self-Hosted Deployment" - description="Configure production storage mounts and S3 buckets" - href="/docs/dev/deployments/self-hosted" - /> + <Card title="Local disk" description="Files in public/uploads, for development and single servers" href="/docs/dev/storage/local" /> + <Card title="S3 and R2" description="AWS S3, Cloudflare R2 and other S3-compatible buckets" href="/docs/dev/storage/s3-r2" /> + <Card title="Supabase" description="Supabase Storage buckets" href="/docs/dev/storage/supabase" /> + <Card title="Custom adapter" description="Store files anywhere else" href="/docs/dev/storage/custom-adapter" /> </Cards> diff --git a/apps/web/content/docs/dev/storage/local.mdx b/apps/web/content/docs/dev/storage/local.mdx index 26d0d93072..5de237e774 100644 --- a/apps/web/content/docs/dev/storage/local.mdx +++ b/apps/web/content/docs/dev/storage/local.mdx @@ -1,5 +1,5 @@ --- -title: Local (disk) +title: Local disk description: The zero-config VitNode storage adapter - writes uploads to public/uploads on the API server and serves them back as static files. icon: FolderOpen --- @@ -268,7 +268,7 @@ outside the API's own base path strips nothing and every file answers `404`. volume, or move to a cloud adapter. </Callout> -## Next +## Related <Cards> <Card diff --git a/apps/web/content/docs/dev/storage/s3-r2.mdx b/apps/web/content/docs/dev/storage/s3-r2.mdx index f03fade557..4f48392954 100644 --- a/apps/web/content/docs/dev/storage/s3-r2.mdx +++ b/apps/web/content/docs/dev/storage/s3-r2.mdx @@ -1,5 +1,5 @@ --- -title: AWS S3 / Cloudflare R2 +title: S3 and R2 description: Store VitNode uploads in an S3-compatible bucket with @vitnode/s3 - one adapter for AWS S3, Cloudflare R2 and anything that speaks the S3 API. icon: Cloud --- @@ -273,7 +273,7 @@ hostname in `publicUrl`. rules are how old versions actually go away. </Callout> -## Next +## Related <Cards> <Card diff --git a/apps/web/content/docs/dev/storage/supabase.mdx b/apps/web/content/docs/dev/storage/supabase.mdx index 410931b955..42d3c94bae 100644 --- a/apps/web/content/docs/dev/storage/supabase.mdx +++ b/apps/web/content/docs/dev/storage/supabase.mdx @@ -1,5 +1,5 @@ --- -title: Supabase Storage +title: Supabase description: Store VitNode uploads in a Supabase Storage bucket with @vitnode/supabase-storage - a secret key, a bucket name and one adapter call. icon: CloudUpload --- @@ -226,7 +226,7 @@ public matters more here than it looks. risk - it is what makes a retried upload idempotent rather than a duplicate. </Callout> -## Next +## Related <Cards> <Card diff --git a/apps/web/content/docs/dev/swagger.mdx b/apps/web/content/docs/dev/swagger.mdx index 327205aded..c6b3974a41 100644 --- a/apps/web/content/docs/dev/swagger.mdx +++ b/apps/web/content/docs/dev/swagger.mdx @@ -9,6 +9,9 @@ so the Zod schemas a route declares are its documentation. Nothing is written twice and nothing can drift: if the schema changes, the spec changes, and the request that no longer matches it is rejected. +Swagger is on in development and **off in production** unless you turn it on +(see [Swagger in production](#swagger-in-production)). + ## Quick start Start the dev server and open Swagger UI in a browser: @@ -24,124 +27,109 @@ same Hono app. The raw document is one path deeper, at `/api/swagger/doc` - OpenAPI 3.0.0, titled `VitNode API`, versioned with the `@vitnode/core` release you have installed. Point any OpenAPI tool at that URL. -{/* Image prompt: A screenshot of Swagger UI at http://localhost:3000/api/swagger for the VitNode API - the "VitNode API" title bar at the top, and below it collapsed tag groups reading "(Core) - Users", "(Core) - Admin / Users", "(Core) - Admin / Debug" and "(Example) - Content / Articles", with the first group expanded to show a GET and a POST operation. Light theme, 1440x900. */} +{/* Image prompt: A screenshot of Swagger UI at http://localhost:3000/api/swagger for the VitNode API - the "VitNode API" title bar at the top, and below it collapsed tag groups reading "(Core) - Users", "(Core) - Admin / Users", "(Core) - Admin / Debug" and "(Newsroom) - Content / Stories", with the first group expanded to show a GET and a POST operation. Light theme, 1440x900. */} Every path reads `/api/{pluginId}/{module}/{route}`, which is why core's session endpoint is `/api/@vitnode/core/users/session`. That is not a Swagger convention - it is how the app is mounted, and [Architecture](/docs/dev/architecture) walks the whole request path. -## Where the spec comes from +## Put a plugin route in the spec -You never register anything with Swagger. Four steps in the API's own build -produce the document as a side effect: +There is no extra work. The route, module and plugin files that make a route +reachable also make it documented. What you control is how good the +documentation is. The steps below improve the `GET /api/site-notes/notes/{id}` +route of the `site-notes` plugin. -| Step | What it contributes | -| ---------------- | ---------------------------------------------------------------------------------- | -| `buildRoute` | The operation: method, path, `request` and `responses` schemas, `description`. | -| `buildModule` | Registers it on the module's Hono app, and names the group it lands in. | -| `buildApiPlugin` | Mounts each module under its name and collects the group list for the document. | -| `VitNodeAPI` | Mounts each plugin under its `pluginId` and serves `/swagger` plus `/swagger/doc`. | - -## Put a plugin route in the spec +### Before you begin -There is no extra work - the same three files that make a route reachable make -it documented. What you control is how good the documentation is. +- The `site-notes` plugin with its `notes` module, from [API modules](/docs/dev/plugins/api/modules) and [Database](/docs/dev/database). <Steps> <Step> -### Describe the route as you build it +### Describe the route -`description` becomes the operation's summary line, and every schema field can -carry an `example`. This is the whole difference between a spec people use and a -spec people ignore: +In `src/api/modules/notes/show.route.ts`, add a `description` and an `example` +for the parameter. `description` becomes the operation's summary line, and every +schema field can carry an `example`. This is the whole difference between a +spec people use and a spec people ignore: -```ts title="src/api/modules/orders/routes/show.route.ts" -import { z } from '@hono/zod-openapi' - -import { buildRoute } from '@/api/builders' - -export const showOrderRoute = buildRoute({ +```ts title="plugins/site-notes/src/api/modules/notes/show.route.ts" +export const showNoteRoute = buildRoute({ route: { method: 'get', path: '/{id}', - description: 'Get one order by id', // [!code ++] + description: 'Get one note by id', // [!code ++] request: { params: z.object({ - id: z.string().openapi({ example: '1' }), // [!code ++] + id: z.coerce.number().int().positive().openapi({ example: 1 }), // [!code ++] }), }, - responses: { - 200: { - content: { - 'application/json': { - schema: z.object({ id: z.string(), total: z.number() }), - }, - }, - description: 'The order', - }, - }, - }, - handler: async (c) => c.json({ id: '1', total: 42 }), -}) + // ...responses and handler stay as they are ``` +`.openapi()` comes from the `z` that `@hono/zod-openapi` exports, which the +route file already imports. + </Step> <Step> -### Register it in a module - -```ts title="src/api/modules/orders/orders.module.ts" -import { buildModule } from '@/api/builders' +### Check the module and the plugin -import { showOrderRoute } from './routes/show.route' +Nothing to add here, but this is where the group in Swagger UI gets its name. +The route sits in the `notes` module, and the module sits in the plugin: -export const ordersModule = buildModule({ - name: 'orders', // [!code ++] - routes: [showOrderRoute], // [!code ++] +```ts title="plugins/site-notes/src/api/modules/notes/notes.module.ts" +export const notesModule = buildModule({ + name: 'notes', + routes: [listNotesRoute, showNoteRoute], + // ... }) ``` -The `name` is the second half of the group heading, so pick the word you want to -read in the sidebar. - -</Step> -<Step> - -### Mount the module in the plugin - -```ts title="src/config.api.ts" -import { buildApiPlugin } from '@vitnode/core/api/lib/plugin' - -import { ordersModule } from '@/api/modules/orders/orders.module' -import { manifest } from '@/manifest.gen' - -export const shopApiPlugin = () => +```ts title="plugins/site-notes/src/config.api.ts" +export const siteNotesApiPlugin = () => buildApiPlugin({ manifest, - modules: [ordersModule], // [!code ++] + modules: [helloModule, notesModule], + // ... }) ``` +The module `name` is the second half of the group heading, so pick the word you +want to read in the sidebar. + </Step> -<Step> +</Steps> -### Reload the UI +### Check the result -Restart the dev server and refresh `/api/swagger`. A new group is at the bottom -of the list - **(Shop) - Orders** - with `GET /api/@acme/shop/orders/{id}` in -it, and "Try it out" sends a real request with your cookies attached. +Let the plugin rebuild, restart the dev server and refresh `/api/swagger`. The +restart matters, because the API builds the document on its first request and +serves that copy from then on. The **(Site Notes) - Notes** group lists +`GET /api/site-notes/notes/{id}` with the summary "Get one note by id" and `1` +filled in as the example id. "Try it out" sends a real request with your cookies +attached. -</Step> -</Steps> +## Where the spec comes from + +You never register anything with Swagger. Four steps in the API's own build +produce the document as a side effect: + +| Step | What it contributes | +| ---------------- | ---------------------------------------------------------------------------------- | +| `buildRoute` | The operation: method, path, `request` and `responses` schemas, `description`. | +| `buildModule` | Registers it on the module's Hono app, and names the group it lands in. | +| `buildApiPlugin` | Mounts each module under its name and collects the group list for the document. | +| `VitNodeAPI` | Mounts each plugin under its `pluginId` and serves `/swagger` plus `/swagger/doc`. | ## Grouping Swagger has exactly one grouping mechanism - the tag - so the tag carries both halves of "where does this endpoint live". The plugin comes from `pluginId` with -its scope dropped and title-cased, the module from the `name` you gave -`buildModule`: +its scope dropped and title-cased (`@vitnode/core` reads `Core`, `site-notes` +reads `Site Notes`), the module from the `name` you gave `buildModule`: ```ts buildModule({ @@ -175,18 +163,26 @@ Groups appear in the order the plugins declared their modules, with core first. the same operation can also show up under a tag you name yourself. </Callout> -## Gotchas +## Swagger in production -<Callout - type="warn" - title="Swagger has no login, and it is mounted in production" -> - `/api/swagger` and `/api/swagger/doc` are registered unconditionally, before - the session middleware, and neither checks anything. Anyone who can reach your - API can read every route it serves, including the admin ones. Deny both paths - at your reverse proxy if that is not what you want - see - [Self-hosted](/docs/dev/deployments/self-hosted). -</Callout> +VitNode serves `/api/swagger` and `/api/swagger/doc` when `NODE_ENV` is +`development` and switches them off otherwise. The document names every route, +parameter and response shape of the install, the admin tree included, and +neither path checks a session. To publish it in production anyway, turn it on +in the API config: + +```ts title="src/vitnode.api.config.ts" +export const vitNodeApiConfig = buildApiConfig({ + // ... + docs: { enabled: true }, // [!code ++] +}) +``` + +`docs: { enabled: false }` turns it off in development too. If you enable it on +a public install, put something in front of both paths, such as an allowlist at +your reverse proxy. See [Self-hosted](/docs/dev/deployments/self-hosted). + +## Gotchas <Callout type="warn" title="A response with no content widens res.json() to unknown"> The [fetcher](/docs/dev/fetcher) infers its return type from the same @@ -204,7 +200,7 @@ Groups appear in the order the plugins declared their modules, with core first. rather than a catalogue of what VitNode can do. </Callout> -## Next +## Related <Cards> <Card diff --git a/apps/web/content/docs/dev/updates/admin.mdx b/apps/web/content/docs/dev/updates/admin.mdx index a5bc3095b1..b295787455 100644 --- a/apps/web/content/docs/dev/updates/admin.mdx +++ b/apps/web/content/docs/dev/updates/admin.mdx @@ -1,5 +1,5 @@ --- -title: Check for updates in the AdminCP +title: AdminCP checks description: Where VitNode shows available updates and security updates, how the daily check and the Check for updates button work, and what each status means. icon: ShieldCheck --- @@ -36,7 +36,7 @@ Staff with **Plugins → Check for plugin and core updates now** (`plugins.can_c - A long check keeps its lease by renewing it every minute. If an API instance crashes mid-check, its lease runs out within five minutes and the next check takes over. - If the server running a check can no longer confirm it holds the lease, the check stops and says _The check stopped before it finished_. Results stored before that are kept; press the button again a moment later. - A manual check can run once every two minutes. A click inside that window says how many seconds to wait. -- The endpoint is `POST /api/@vitnode/core/admin/plugins/updates/check`. It answers `409` while a check runs (`"status": "running"`) or when the check was interrupted (`"status": "interrupted"`), and `429` during the cooldown. +- The endpoint is `POST /api/@vitnode/core/admin/plugins/updates/check`. It answers `409` while a check runs (`"status": "running"`), when the check was interrupted (`"status": "interrupted"`) or when update checks are turned off (`"status": "disabled"`), and `429` during the cooldown. Viewing the results only needs **Plugins → View installed plugins and their versions** (`plugins.can_view`). The same permission shows the [update count, security alert and System updates widget](/docs/dev/updates/alerts) everywhere else in the AdminCP. How the lease keeps checks exclusive is explained in [How update checks share one lease](/docs/dev/updates/lease). diff --git a/apps/web/content/docs/dev/updates/alerts.mdx b/apps/web/content/docs/dev/updates/alerts.mdx index e5f9a18af0..2c2965659c 100644 --- a/apps/web/content/docs/dev/updates/alerts.mdx +++ b/apps/web/content/docs/dev/updates/alerts.mdx @@ -1,5 +1,5 @@ --- -title: Update alerts in the AdminCP +title: Update alerts description: The update count next to System → Plugins, the security alert in the AdminCP header and the System updates dashboard widget - what each number means, how fresh it is and who sees it. icon: BellRing --- @@ -49,41 +49,43 @@ A publisher that cannot be reached never clears a warning. VitNode keeps the las ## The System updates widget -The **System updates** widget is on the dashboard by default for staff who can view plugins. You can remove it or add it back from **Edit layout**, like any other widget. It reads like a small ledger, one row per fact: +The **System updates** widget is on the dashboard by default for staff who can view plugins. You can remove it or add it back from **Edit layout**, like any other widget. It opens with a one-line status and a short explanation, then lists one row per fact, and ends with a **View plugins** link and the time of the last check. | Row | Meaning | | -------------- | --------------------------------------------------------------------------------------------------------- | -| Status | The one-line verdict - see below. | | VitNode | The core version you run and, after the arrow, the newer core release on your channel when one is known. | -| Plugin updates | Plugins with a newer release on their channel. Core is not included here. | -| Security | How many packages, core included, a security update affects - or _None_. | -| Last checked | How fresh the numbers are. | +| Plugin updates | Plugins with a newer release on their channel, as _N available_ or _None_. Core is not included here. | +| Security fixes | How many packages, core included, a security update affects (_Needed for N packages_), or _None found_. | +| No update info | How many packages, out of all, publish no update info. The row appears only when some do. | -Before anything was checked, the numbers show a dash and _Unknown_ instead of `0`. +Before anything was checked, the rows show _Unknown_ instead of a number. ### Status -| Status | What it means | -| ------------------------ | ------------------------------------------------------------------------------------------------- | -| Security fix needed | At least one package you run is affected by a security update. | -| Updates available | A newer release exists for core or a plugin, and nothing is affected by a security update. | -| Up to date | Every installed package was checked and none has an update or a security warning. | -| 2 packages not confirmed | Nothing is outdated, but some packages could not be confirmed, for example they declare no update source. The Plugins page says why. | -| Checking... | A check is running. The widget, the count and the pill refresh every 15 seconds until it finishes. | -| Not checked yet | No check has run. The daily check runs overnight, or press **Check for updates** on the Plugins page. | -| No update sources | No installed package declares where its updates are published. | -| Checks turned off | The API config sets `updates.enabled` to `false`. | - -### Last checked - -| Value | What it means | -| -------------- | ----------------------------------------------------------------------------------------------------------------------- | -| 3 hours ago | The oldest stored result is that recent, so every package was checked at least that recently. | -| Failed 5 minutes ago | The latest check could not read a source. The numbers include the last known results for it. | -| Out of date | You redeployed, upgraded core or changed channel. Results are recomputed from the stored manifests until the next check. | -| Never | Nothing has been checked yet. | - -The time is the **oldest** successful result, not the newest. One package checked a minute ago does not prove the others were. +| Status | What it means | +| ----------------------- | ----------------------------------------------------------------------------------------------------- | +| Security fix needed | At least one package you run is affected by a security update. | +| Updates available | A newer release exists for core or a plugin, and nothing is affected by a security update. | +| Everything is up to date | Every installed package was checked and none has an update or a security warning. | +| No updates found | Nothing is outdated, but some packages publish no update info, so VitNode cannot check them. The explanation says how many were confirmed. | +| Checking for updates... | A check is running. The widget, the count and the pill refresh every 15 seconds until it finishes. | +| Not checked yet | No check has run. The daily check runs overnight, or press **Check for updates** on the Plugins page. | +| Can't check for updates | No installed package publishes update info. | +| Update checks are off | The API config sets `updates.enabled` to `false`. | + +### Last check + +The line at the bottom right says how fresh the numbers are: + +| Text | What it means | +| ----------------------------------- | ----------------------------------------------------------------------------------------------------------------------- | +| Checked _date_ | The oldest stored result is from that date, so every package was checked at least that recently. | +| Last check failed _date_ | The latest check could not read a source. The numbers include the last known results for it. | +| Checked _date_, needs a recheck | You redeployed, upgraded core or changed channel. Results are recomputed from the stored manifests until the next check. | +| Checking now... | A check is running. | +| Never checked | Nothing has been checked yet. | + +The date is the **oldest** successful result, not the newest. One package checked a minute ago does not prove the others were. ## When update checks are turned off diff --git a/apps/web/content/docs/dev/updates/lease.mdx b/apps/web/content/docs/dev/updates/lease.mdx index 13a41e7ac0..8e92997e99 100644 --- a/apps/web/content/docs/dev/updates/lease.mdx +++ b/apps/web/content/docs/dev/updates/lease.mdx @@ -1,5 +1,5 @@ --- -title: How update checks share one lease +title: Check lease description: How VitNode keeps the daily and manual update checks exclusive across API instances with a renewable database lease, cancels a check that loses it, and fences every write so a stale owner cannot overwrite newer results. icon: Lock --- diff --git a/apps/web/content/docs/dev/updates/manifest.mdx b/apps/web/content/docs/dev/updates/manifest.mdx index 23e0d9b7ef..c63538ba21 100644 --- a/apps/web/content/docs/dev/updates/manifest.mdx +++ b/apps/web/content/docs/dev/updates/manifest.mdx @@ -1,5 +1,5 @@ --- -title: Update manifest reference +title: Manifest reference description: Every field of the VitNode update manifest JSON - packages, channels, releases and security updates - with validation rules and how versions are compared. icon: FileCode --- diff --git a/apps/web/content/docs/dev/updates/meta.json b/apps/web/content/docs/dev/updates/meta.json index 7273704c96..6b08e2cc83 100644 --- a/apps/web/content/docs/dev/updates/meta.json +++ b/apps/web/content/docs/dev/updates/meta.json @@ -1,5 +1,5 @@ { - "title": "Updates & Security", + "title": "Updates", "description": "Daily update checks and security updates for VitNode core and plugins, read from public JSON manifests", "icon": "RefreshCw", "pages": [ diff --git a/apps/web/content/docs/dev/updates/official-releases.mdx b/apps/web/content/docs/dev/updates/official-releases.mdx index 8563f07e78..cc6ff48c6d 100644 --- a/apps/web/content/docs/dev/updates/official-releases.mdx +++ b/apps/web/content/docs/dev/updates/official-releases.mdx @@ -1,6 +1,6 @@ --- -title: Official release feed -description: How VitNode's release workflow publishes the official update manifest after npm publication, where publishedAt comes from, and how reruns and security updates are handled. +title: Official releases +description: A guide for VitNode maintainers on how the release workflow publishes the official update manifest after npm publication, where publishedAt comes from, and how it handles reruns and security updates. icon: Workflow --- diff --git a/apps/web/content/docs/dev/updates/plugin-authors.mdx b/apps/web/content/docs/dev/updates/plugin-authors.mdx index 08cf4ccde0..fe534b2b84 100644 --- a/apps/web/content/docs/dev/updates/plugin-authors.mdx +++ b/apps/web/content/docs/dev/updates/plugin-authors.mdx @@ -1,5 +1,5 @@ --- -title: Publish updates for your plugin +title: Plugin updates description: Declare an update source in your plugin's src/plugin-info.ts and host a small JSON manifest so VitNode installations see your new releases and security updates. icon: Upload --- @@ -66,7 +66,7 @@ The package key must equal the `name` in `package.json`. `publishedAt`, `require ## Check the result -Install the plugin in an app, open **System → Plugins** and press **Check for updates**. With the plugin at `1.1.0` the row says _Update available: 1.2.0_; at `1.2.0` it says _Up to date_. +Install the plugin in an app, open **System → Plugins** and press **Check for updates**. With the plugin at `1.1.0` the row shows an **Update available** badge and _Update to 1.2.0_; at `1.2.0` it shows **Up to date** and _Newest release on the stable channel._ ## Several packages, one manifest diff --git a/apps/web/content/docs/dev/updates/security-updates.mdx b/apps/web/content/docs/dev/updates/security-updates.mdx index cd28e6a833..f966b021c1 100644 --- a/apps/web/content/docs/dev/updates/security-updates.mdx +++ b/apps/web/content/docs/dev/updates/security-updates.mdx @@ -1,5 +1,5 @@ --- -title: Publish a security update +title: Security updates description: Add a security update to an update manifest so VitNode installations running an affected version see a red warning in the AdminCP, with or without a fixed version. icon: ShieldAlert --- @@ -45,4 +45,4 @@ If the fix already landed in `2.1.3-canary.2`, say so in the range: `">=2.0.0 <2 A warning means the publisher listed that version. No warning is not proof that a version is secure, and VitNode does not scan your plugins' own dependencies. A patch release without a security update is not shown as a security release. -For VitNode's own packages, security updates are maintained in `security/security-updates.json` in the VitNode repository; see [Official release feed](/docs/dev/updates/official-releases#add-an-security update-to-the-official-feed). +VitNode keeps the security updates for its own packages in `security/security-updates.json` in the VitNode repository. See [Official release feed](/docs/dev/updates/official-releases#add-a-security-update-to-the-official-feed). diff --git a/apps/web/content/docs/dev/websocket/index.mdx b/apps/web/content/docs/dev/websocket/index.mdx index fe9ecb3bd2..7e445552f1 100644 --- a/apps/web/content/docs/dev/websocket/index.mdx +++ b/apps/web/content/docs/dev/websocket/index.mdx @@ -4,20 +4,20 @@ description: Push real-time updates and notifications from Hono routes to the br icon: Radio --- -import { Tab, Tabs } from "fumadocs-ui/components/tabs" +VitNode provides one shared WebSocket connection at `/api/ws`. Browsers share a single connection across tabs, authenticated by session cookies, and every feature multiplexes its messages over it. Use it to push live updates from Hono routes: a toast for one user, a counter for everyone, or a message for one [room](/docs/dev/websocket/rooms). -VitNode provides a unified, multiplexed WebSocket connection at `/api/ws`. Browsers share a single connection across tabs via a Web Lock leader election, authenticated by session cookies. A single app serves it from the same deployment as the site and the API, Vercel included - see [Set up the endpoint](/docs/dev/websocket/setup). +## Before you begin -## Quick start +- The `/api/ws` endpoint. A single app turns it on with `webSocket: true`, Vercel included; a standalone API mounts it next to its HTTP server. See [Set up the endpoint](/docs/dev/websocket/setup). +- `REDIS_URL`, only if you run more than one API instance. Without it, pushes reach clients connected to the same instance. -### 1. Send a Toast to a User +## Send a toast to a user Push a transient toast to any signed-in user from any Hono route or service: ```ts import { notificationsChannel } from "@vitnode/core/ws/notifications" -// [!code ++:6] c.get("realtime").sendToUser(userId, notificationsChannel, { title: "New Comment", description: "Alex commented on your post.", @@ -27,7 +27,7 @@ c.get("realtime").sendToUser(userId, notificationsChannel, { The user receives an immediate `sonner` toast in all open tabs across devices. -<Callout type="info" title="Need it to stick around? Use the Notifications Center"> +<Callout type="info" title="Need it to stick around? Use notifications"> `notificationsChannel` is for transient toasts: they reach open tabs only and are gone on reload. For anything users should find later - with an inbox, unread count, email and preferences - publish a persistent notification with @@ -36,25 +36,11 @@ The user receives an immediate `sonner` toast in all open tabs across devices. this same connection on `notificationsStateChannel`. </Callout> ---- - -### 2. Broadcast to Everyone - -Broadcast live updates to all connected visitors: - -```ts -c.get("realtime").broadcast(myChannel, { - count: 142, -}) -``` +## Define a channel ---- +A channel is a typed id that the server and the browser both import. Create your plugin's channels in one file: -## Define a Custom Channel - -Create typed channels in your plugin: - -```ts title="plugins/chat/src/ws/chat.channel.ts" +```ts title="plugins/acme-chat/src/ws/chat.channel.ts" import { createWebSocketChannel } from "@vitnode/core/ws/types" export interface MessagePayload { @@ -63,162 +49,89 @@ export interface MessagePayload { senderId: number } -// [!code ++:5] export const chatChannel = createWebSocketChannel<never, MessagePayload>({ pluginId: "@acme/chat", module: "rooms", id: "messages", }) -``` - -The first type parameter is what the client sends, the second what it receives. -The channel id becomes `{pluginId}_{module}_{id}`. - ---- - -## Client-Side Consumption - -Listen for incoming channel messages with `useVitNodeWebSocket`: - -```tsx title="plugins/chat/src/views/chat-room.tsx" -import { useVitNodeWebSocket } from "@vitnode/core/ws/use-websocket" -import { chatChannel } from "../ws/chat.channel" - -export const ChatRoom = ({ roomId }: { roomId: string }) => { - const [messages, setMessages] = React.useState<string[]>([]) - - // [!code ++:7] - useVitNodeWebSocket(chatChannel, { - onMessage: (payload) => { - if (payload.roomId === roomId) { - setMessages((prev) => [...prev, payload.message]) - } - }, - }) - - return ( - <div> - {messages.map((msg, i) => ( - <p key={i}>{msg}</p> - ))} - </div> - ) -} -``` - ---- - -## Rooms - -A room is a named group of browser tabs, such as everyone looking at one chat thread or editing one record. Send a message to a room and only its members get it, on every API instance. A room is any string your feature picks, for example `chat:42`. -### Join and leave per tab - -All tabs of a browser share one socket, so a socket joins a room on behalf of a tab. Let each tab send its own random `clientId` with every message, and join or leave with it: - -```ts title="plugins/chat/src/api/modules/rooms/rooms.socket.ts" -import { buildWebSocket } from "@vitnode/core/api/lib/websocket" -import { wsRegistry } from "@vitnode/core/ws/registry" - -interface RoomMessage { - clientId: string - roomId: string - type: "join" | "leave" -} - -export const roomsSocket = buildWebSocket<RoomMessage>({ - id: "presence", - onMessage: ({ data, ws }) => { - const room = `chat:${data.roomId}` - - // [!code ++:5] - if (data.type === "join") { - wsRegistry.join(ws, room, data.clientId) - } else { - wsRegistry.leave(ws, room, data.clientId) - } - }, +export const visitorsChannel = createWebSocketChannel<never, { count: number }>({ + pluginId: "@acme/chat", + module: "rooms", + id: "visitors", }) ``` -Register the socket on its module with `webSockets: [roomsSocket]`. The socket stays in the room until its last tab leaves, so a closed tab never cuts off the others. Check who may join (the session on `c`, a permission) before calling `join`: a room is only as private as that check. +The first type parameter is what the browser sends, the second what it receives. `never` means the browser only listens. The channel id becomes `{pluginId}_{module}_{id}`. -`join` and `leave` return `true` when they changed something. `wsRegistry.membersOf(room)` lists the members on this instance and `wsRegistry.roomsOf(ws)` lists the rooms of one socket. +## Push to everyone -### Send to a room +Broadcast a live update to every connected visitor, signed in or not: ```ts -// [!code ++:4] -c.get("realtime").toRoom(`chat:${roomId}`, chatChannel, { - roomId, - message: "Hello!", - senderId: user.id, -}) -``` - -Each socket in the room receives the payload once, however many of its tabs joined. Put the room or record in the payload so every tab can ignore what is not its own. - -### Join again after a reconnect - -A new connection starts with no rooms on the server. `useVitNodeWebSocket` returns a `connectionId` that changes every time the shared connection opens, in every tab, so join from an effect that depends on it. `roomsChannel` is `createWebSocketChannel<RoomMessage>({ pluginId: "@acme/chat", module: "rooms", id: "presence" })`, the channel of `roomsSocket` above: - -```tsx title="plugins/chat/src/views/chat-room.tsx" -const [clientId] = React.useState(() => crypto.randomUUID()) -const { connectionId, send } = useVitNodeWebSocket(roomsChannel) +import { visitorsChannel } from "@/ws/chat.channel" -// [!code ++:4] -React.useEffect(() => { - if (!connectionId) return - send({ clientId, roomId, type: "join" }) -}, [clientId, connectionId, roomId, send]) +c.get("realtime").broadcast(visitorsChannel, { count: 142 }) ``` -[Reconnects and missed messages](/docs/dev/websocket/reconnects) covers reloading what the tab missed while it was away. +## Listen in the browser -### Clean up when a socket closes +Receive a channel's messages with `useVitNodeWebSocket`: -A socket that closes leaves all of its rooms. Use `onConnectionClose` to update your own state, such as an "online now" list: +```tsx title="plugins/acme-chat/src/views/visitors-badge.tsx" +import { useVitNodeWebSocket } from "@vitnode/core/ws/use-websocket" +import { useState } from "react" -```ts title="plugins/chat/src/api/modules/rooms/presence.ts" -import { wsRegistry } from "@vitnode/core/ws/registry" +import { visitorsChannel } from "../ws/chat.channel" -// [!code ++:5] -wsRegistry.onConnectionClose((ws, left) => { - for (const { memberId, room } of left) { - markOffline(room, memberId) - } -}) -``` +export const VisitorsBadge = () => { + const [count, setCount] = useState<number | null>(null) -`left` holds every `{ memberId, room }` the socket was in. Register the listener once, at module load. + useVitNodeWebSocket(visitorsChannel, { + onMessage: payload => { + setCount(payload.count) + }, + }) -### Rooms on several instances + if (count === null) return null -With `REDIS_URL` set, `toRoom` also publishes the message over Redis, and every other instance delivers it to its own members. Room membership itself stays local to each instance. + return <p>{count} people online</p> +} +``` -When an instance keeps per-room state in memory, it can follow the other instances: +The hook also returns `{ connectionId, readyState, send }`. `send` talks to a server socket built with `buildWebSocket`, and `connectionId` changes on every reconnect. [Rooms](/docs/dev/websocket/rooms) uses both. -- `onRemoteRoomMessage((room, id, data) => ...)` sees room messages from other instances before they reach local sockets. A collaborative document uses it to apply the same edit to its own copy. -- `publishToInstances(topic, data)` and `onInstanceMessage(({ topic, data, origin }) => ...)` exchange data between instances that never reaches a socket, such as each instance's list of members. Republish it periodically and let entries from a silent instance expire, so a crashed instance leaves no ghosts. +## `c.get("realtime")` methods -Without Redis, all of this works on a single instance with no extra setup: `toRoom` delivers locally and instance messages go nowhere. +| Method | Behavior | +| --- | --- | +| `sendToUser(userId, channel, data)` | Every open connection of one user, on all their devices | +| `sendToUsers(channel, messages)` | A different payload per user, from `{ userId, data }` items | +| `broadcast(channel, data)` | Every open connection, guests included | +| `toRoom(room, channel, data)` | The connections that joined `room`, see [Rooms](/docs/dev/websocket/rooms) | +| `closeUserConnections(userId, deviceId?)` | Closes a user's sockets, or only one device's | ---- +Every method returns nothing and does not wait. A client that is not connected misses the message. -## Architectural Highlights +## How the connection behaves -- **Single Connection**: Only one `/api/ws` socket is opened per client. All features share it via message multiplexing. -- **Tab Leader Election**: When a user opens multiple tabs, a Web Lock elects one leader tab to hold the socket, distributing messages across tabs via `BroadcastChannel`. -- **Automatic Reconnect**: A dropped connection retries after about a second, then backs off (with a little jitter) up to 30 seconds, so a restarted server is not stampeded by every open tab. +- **One connection**: Only one `/api/ws` socket is opened per client. All features share it via message multiplexing. +- **Tab leader election**: When a user opens multiple tabs, a Web Lock elects one leader tab to hold the socket, and it passes messages to the other tabs over `BroadcastChannel`. +- **Automatic reconnect**: A dropped connection retries after about a second, then backs off (with a little jitter) up to 30 seconds, so a restarted server is not stampeded by every open tab. - **Offline aware**: When the browser goes offline, the socket closes and stops retrying. It reconnects the moment the network comes back, or when a tab becomes visible again after sleep, without waiting for the backoff. - **Heartbeat, both ways**: The server pings every socket every 30 seconds and closes the ones that stopped answering, so vanished clients do not linger (the standalone API's `ws` server and Nitro's crossws both do). The browser sends its own ping every 25 seconds and reconnects when no reply arrives within 10 seconds, so a half-dead connection after a Wi-Fi drop never pretends to be online. - **No replay**: Messages sent while a socket is down are not queued. A feature that must not miss updates reloads its state when the socket opens again - see [Reconnects and missed messages](/docs/dev/websocket/reconnects). - **Closed on revoke**: A socket is tied to the session and device that opened it. Revoking sessions (signing out a device, changing or resetting a password, an admin revoke) closes those sockets on every instance through `closeUserConnections`, so a revoked device stops receiving the user's pushes. -- **Multiple Instances**: With `REDIS_URL` set, `sendToUser`, `broadcast` and `toRoom` are relayed to every API instance over Redis pub/sub. Without Redis they only reach clients connected to the same instance. +- **Multiple instances**: With `REDIS_URL` set, `sendToUser`, `broadcast` and `toRoom` are relayed to every API instance over Redis pub/sub. Without Redis they only reach clients connected to the same instance. -## Learn More +## Related <Cards> + <Card + title="Rooms" + description="Send messages only to the tabs that joined a room" + href="/docs/dev/websocket/rooms" + /> <Card title="Set up the endpoint" description="Serve /api/ws from a single app, Vercel included, or a standalone API" @@ -234,14 +147,4 @@ Without Redis, all of this works on a single instance with no extra setup: `toRo description="Persistent inbox, unread count, email and digests" href="/docs/dev/notifications" /> - <Card - title="Events" - description="In-process domain events across plugins" - href="/docs/dev/events" - /> - <Card - title="Queue Tasks" - description="Background jobs and asynchronous processing" - href="/docs/dev/advanced/queue" - /> </Cards> diff --git a/apps/web/content/docs/dev/websocket/meta.json b/apps/web/content/docs/dev/websocket/meta.json index e622357ace..dfa7122ace 100644 --- a/apps/web/content/docs/dev/websocket/meta.json +++ b/apps/web/content/docs/dev/websocket/meta.json @@ -2,5 +2,5 @@ "title": "WebSocket", "description": "Push realtime updates over one shared, cookie-authenticated connection", "icon": "Radio", - "pages": ["index", "setup", "reconnects"] + "pages": ["index", "setup", "rooms", "reconnects"] } diff --git a/apps/web/content/docs/dev/websocket/reconnects.mdx b/apps/web/content/docs/dev/websocket/reconnects.mdx index 9e76f352da..4b5fd1808a 100644 --- a/apps/web/content/docs/dev/websocket/reconnects.mdx +++ b/apps/web/content/docs/dev/websocket/reconnects.mdx @@ -1,5 +1,5 @@ --- -title: Reconnects and missed messages +title: Reconnects description: How VitNode's WebSocket reconnects, why realtime delivery is best effort with no replay, and how to restore rooms and reload state in every tab after a reconnect. icon: RefreshCw --- @@ -20,14 +20,15 @@ Treat every realtime message as a hint that something changed, and the database ## Restore state when the connection opens -`useVitNodeWebSocket` returns `connectionId`, which changes every time the shared connection opens and is `null` while it is down. All tabs share one socket, held by a leader tab, and every tab - leader or follower - gets the new `connectionId`. Depend on it to rejoin and reload: +`useVitNodeWebSocket` returns `connectionId`, which changes every time the shared connection opens and is `null` while it is down. All tabs share one socket, held by a leader tab, and every tab, leader or follower, gets the new `connectionId`. Depend on it to rejoin and reload. This extends the `ChatRoom` component from [Rooms](/docs/dev/websocket/rooms), and assumes the room's messages are loaded with TanStack Query under the key `["chat", roomId]`: -```tsx title="plugins/chat/src/views/chat-room.tsx" -const { connectionId, send } = useVitNodeWebSocket(roomsChannel, { - onMessage: (message) => appendMessage(message), -}) +```tsx title="plugins/acme-chat/src/views/chat-room.tsx (excerpt)" +import { useQueryClient } from "@tanstack/react-query" -React.useEffect(() => { +const queryClient = useQueryClient() +const { connectionId, send } = useVitNodeWebSocket(roomsChannel) + +useEffect(() => { if (!connectionId) return send({ clientId, roomId, type: "join" }) // the server forgot the room @@ -35,6 +36,8 @@ React.useEffect(() => { }, [clientId, connectionId, queryClient, roomId, send]) ``` +New messages keep arriving on `chatChannel` as before; this effect only rejoins and reloads. + Messages sent before the socket is open are queued and sent once it opens, so the join is never lost. Do not resend writes from that effect: a mutation that went over HTTP already happened, and sending it again would do it twice. VitNode's own features follow the same rule: diff --git a/apps/web/content/docs/dev/websocket/rooms.mdx b/apps/web/content/docs/dev/websocket/rooms.mdx new file mode 100644 index 0000000000..e562b18dbf --- /dev/null +++ b/apps/web/content/docs/dev/websocket/rooms.mdx @@ -0,0 +1,210 @@ +--- +title: Rooms +description: Send VitNode WebSocket messages only to the browser tabs that joined a room, such as one chat thread, with wsRegistry.join and c.get("realtime").toRoom(). +icon: Users +--- + +A room is a named group of browser tabs, such as everyone looking at one chat thread or editing one record. Send a message to a room and only its members get it, on every API instance. A room is any string your feature picks, for example `chat:42`. This guide adds rooms to the fictional `@acme/chat` plugin from [WebSocket](/docs/dev/websocket). + +## Before you begin + +- The `/api/ws` endpoint, from [Set up the endpoint](/docs/dev/websocket/setup). +- The `chatChannel` from [Define a channel](/docs/dev/websocket#define-a-channel), which carries the chat messages to the browser. +- A top-level API module for the server socket. Here it is `rooms`, built with `buildModule` from your plugin's `@/api/builders`. + +<Steps> +<Step> + +### Handle join and leave on the server + +All tabs of a browser share one socket, so a socket joins a room on behalf of a tab. Each tab sends its own random `clientId` with every message, and the server joins or leaves with it. Create a server socket with `buildWebSocket`: + +```ts title="plugins/acme-chat/src/api/modules/rooms/rooms.socket.ts" +import { buildWebSocket } from "@vitnode/core/api/lib/websocket" +import { wsRegistry } from "@vitnode/core/ws/registry" + +export interface RoomMessage { + clientId: string + roomId: string + type: "join" | "leave" +} + +export const roomsSocket = buildWebSocket<RoomMessage>({ + id: "presence", + onMessage: ({ data, ws }) => { + const room = `chat:${data.roomId}` + + if (data.type === "join") { + wsRegistry.join(ws, room, data.clientId) + } else { + wsRegistry.leave(ws, room, data.clientId) + } + }, +}) +``` + +Check who may join (the session on `c`, a permission) before calling `join`, because a room is only as private as that check. The socket stays in the room until its last tab leaves, so a closed tab never cuts off the others. + +</Step> +<Step> + +### Register the socket on its module + +```ts title="plugins/acme-chat/src/api/modules/rooms/rooms.module.ts" +import { buildModule } from "@/api/builders" + +import { roomsSocket } from "./rooms.socket" + +export const roomsModule = buildModule({ + name: "rooms", + webSockets: [roomsSocket], +}) +``` + +Pass `roomsModule` to `buildApiPlugin({ modules })`. Only top-level modules are read. The socket's public id is `{pluginId}_{module}_{id}`, here `@acme/chat_rooms_presence`. + +</Step> +<Step> + +### Share the channel with the browser + +Next to `chatChannel`, add a channel with the same plugin, module and id as the socket. Its first type parameter is what the browser sends: + +```ts title="plugins/acme-chat/src/ws/chat.channel.ts" +import { createWebSocketChannel } from "@vitnode/core/ws/types" + +import type { RoomMessage } from "../api/modules/rooms/rooms.socket" + +export const roomsChannel = createWebSocketChannel<RoomMessage>({ + pluginId: "@acme/chat", + module: "rooms", + id: "presence", +}) +``` + +A type-only import keeps the server file out of the browser bundle. + +</Step> +<Step> + +### Join from the tab, again after every reconnect + +A new connection starts with no rooms on the server. `useVitNodeWebSocket` returns a `connectionId` that changes every time the shared connection opens, in every tab, so join from an effect that depends on it: + +```tsx title="plugins/acme-chat/src/views/chat-room.tsx" +import { useVitNodeWebSocket } from "@vitnode/core/ws/use-websocket" +import { useEffect, useState } from "react" + +import { chatChannel, roomsChannel } from "../ws/chat.channel" + +export const ChatRoom = ({ roomId }: { roomId: string }) => { + const [clientId] = useState(() => crypto.randomUUID()) + const [messages, setMessages] = useState<string[]>([]) + const { connectionId, send } = useVitNodeWebSocket(roomsChannel) + + useVitNodeWebSocket(chatChannel, { + onMessage: payload => { + if (payload.roomId === roomId) { + setMessages(prev => [...prev, payload.message]) + } + }, + }) + + useEffect(() => { + if (!connectionId) return + send({ clientId, roomId, type: "join" }) + + return () => { + send({ clientId, roomId, type: "leave" }) + } + }, [clientId, connectionId, roomId, send]) + + return ( + <ul> + {messages.map((message, i) => ( + <li key={i}>{message}</li> + ))} + </ul> + ) +} +``` + +[Reconnects and missed messages](/docs/dev/websocket/reconnects) covers reloading what the tab missed while it was away. + +</Step> +<Step> + +### Send to the room + +From any API handler, for example the route that saves a chat message: + +```ts +c.get("realtime").toRoom(`chat:${roomId}`, chatChannel, { + roomId, + message: "Hello!", + senderId: user.id, +}) +``` + +Each socket in the room receives the payload once, however many of its tabs joined. Put the room or record in the payload so every tab can ignore what is not its own. + +</Step> +</Steps> + +## Check the result + +Open the chat room page for room `42` in two browsers, and room `7` in a third. Send a message to room `42`. The first two show it right away, and the third shows nothing. Close one tab of a browser that has two tabs on room `42`, send again, and its other tab still gets the message. + +## Clean up when a socket closes + +A socket that closes leaves all of its rooms. Use `wsRegistry.onConnectionClose` to update your own state, such as an "online now" list. Register the listener once, at module load: + +```ts title="plugins/acme-chat/src/api/modules/rooms/presence.ts" +import { wsRegistry } from "@vitnode/core/ws/registry" + +const online = new Map<string, Set<string>>() + +wsRegistry.onConnectionClose((ws, left) => { + for (const { memberId, room } of left) { + online.get(room)?.delete(memberId) + } +}) +``` + +`left` holds every `{ memberId, room }` the socket was in. + +## Inspect rooms + +| Method | Returns | Behavior | +| --- | --- | --- | +| `wsRegistry.join(ws, room, memberId)` | `boolean` | Adds the member on this socket; `true` when it was not there yet | +| `wsRegistry.leave(ws, room, memberId)` | `boolean` | Removes the member; `true` when it was there | +| `wsRegistry.membersOf(room)` | `{ memberId, ws }[]` | The room's members on this instance | +| `wsRegistry.roomsOf(ws)` | `string[]` | The rooms of one socket | +| `wsRegistry.onConnectionClose(listener)` | unsubscribe function | Calls `listener(ws, left)` when a socket closes | + +All `wsRegistry` methods act on the current instance only. + +## Rooms on several instances + +With `REDIS_URL` set, `toRoom` also publishes the message over Redis, and every other instance delivers it to its own members. Room membership itself stays local to each instance. + +When an instance keeps per-room state in memory, it can follow the other instances with helpers from `@vitnode/core/ws/registry`: + +```ts +import { + onInstanceMessage, + onRemoteRoomMessage, + publishToInstances, +} from "@vitnode/core/ws/registry" +``` + +- `onRemoteRoomMessage((room, id, data) => ...)` sees room messages from other instances before they reach local sockets. A collaborative document uses it to apply the same edit to its own copy. +- `publishToInstances(topic, data)` and `onInstanceMessage(({ topic, data, origin }) => ...)` exchange data between instances that never reaches a socket, such as each instance's list of members. Republish it periodically and let entries from a silent instance expire, so a crashed instance leaves no ghosts. + +Both listeners return a function that removes them. Without Redis, all of this works on a single instance with no extra setup. `toRoom` delivers locally, and instance messages go nowhere. + +## Related + +- [Reconnects and missed messages](/docs/dev/websocket/reconnects) +- [WebSocket](/docs/dev/websocket) diff --git a/apps/web/content/docs/dev/websocket/setup.mdx b/apps/web/content/docs/dev/websocket/setup.mdx index 161bbc6b15..f132bdf157 100644 --- a/apps/web/content/docs/dev/websocket/setup.mdx +++ b/apps/web/content/docs/dev/websocket/setup.mdx @@ -1,5 +1,5 @@ --- -title: Set up the WebSocket endpoint +title: Setup description: Serve VitNode's /api/ws WebSocket from a single TanStack Start app with Nitro, Vercel included, or from a standalone Node API server. icon: Plug --- @@ -74,11 +74,33 @@ const createVitNodeApi = () => { A separate API server runs on `@hono/node-server` and upgrades with its own helper and a `ws` server. Mount the route after `VitNodeAPI`, and pass the web app's origin when it is not `VITNODE_WEB_URL`: -```ts title="apps/api/src/index.ts" +```ts title="apps/api/src/index.ts (excerpt)" import { serve, upgradeWebSocket } from "@hono/node-server" import { websocketOriginMiddleware } from "@vitnode/core/api/middlewares/websocket-origin.middleware" import { handleVitNodeWebSocket } from "@vitnode/core/ws/handle" -import { WebSocketServer } from "ws" +import { type WebSocket, WebSocketServer } from "ws" + +// `app` is your OpenAPIHono app, already passed to VitNodeAPI({ app, ... }) +const webOrigin = process.env.VITNODE_WEB_URL ?? "http://localhost:3000" +const wss = new WebSocketServer({ noServer: true }) + +// Ping every 30 s and drop sockets that did not answer the previous ping. +const awaitingPong = new WeakSet<WebSocket>() +wss.on("connection", ws => { + ws.on("pong", () => awaitingPong.delete(ws)) +}) +const heartbeat = setInterval(() => { + for (const ws of wss.clients) { + if (awaitingPong.has(ws)) { + ws.terminate() + continue + } + awaitingPong.add(ws) + ws.ping() + } +}, 30_000) +heartbeat.unref() +wss.on("close", () => clearInterval(heartbeat)) app.get( "/ws", @@ -89,10 +111,12 @@ app.get( serve({ fetch: app.fetch, port: Number(process.env.PORT ?? 8000), - websocket: { server: new WebSocketServer({ noServer: true }) }, + websocket: { server: wss }, }) ``` +The `ws` server does not ping on its own. Without the heartbeat, a client that vanished without a close frame (a sleeping laptop, dropped Wi-Fi) stays in the registry forever. The full file is `apps/api/src/index.ts` in the VitNode repository. + A web app pointed at that API with `VITNODE_API_URL` connects to it on its own; it needs no `webSocket` option and no Nitro route. ## Who may connect @@ -103,7 +127,7 @@ Both setups apply the same rules before a socket opens: - **Session**: the session cookie decides the user. Guests connect too, as `null`; a handler that needs a user checks `c.get("user")` itself, and live editing checks the AdminCP session on every message. - **Redis**: with `REDIS_URL` set, the instance answers `503` until it is subscribed to the realtime channel, so it never accepts a socket that other instances cannot reach. The client retries. -A rejected handshake registers nothing. Identity is fixed for the life of a socket: after sign-in or sign-out the client reconnects on its own, but a session revoked from another device keeps its open socket until that socket closes. +A rejected handshake registers nothing. Identity is fixed for the life of a socket. After sign-in or sign-out the client reconnects on its own. Revoking a session (signing out a device, changing a password, an admin revoke) closes that device's sockets on every instance. ## Check the result diff --git a/apps/web/content/docs/dev/widgets/block-variants.mdx b/apps/web/content/docs/dev/widgets/block-variants.mdx index 7a1e0580e9..78b87625fb 100644 --- a/apps/web/content/docs/dev/widgets/block-variants.mdx +++ b/apps/web/content/docs/dev/widgets/block-variants.mdx @@ -1,5 +1,5 @@ --- -title: Widget Variants +title: Variants description: A variant is how one widget instance looks, stored beside its data instead of inside it - declare a few with defineWidget and the editor grows a picker for free. icon: Shapes --- @@ -22,17 +22,24 @@ Keep those three apart and every screen that touches a widget stays honest. Mix Two optional keys on `defineWidget`: -```tsx title="plugins/example/src/widgets/features.tsx" +```tsx title="plugins/newsroom/src/widgets/features.tsx (excerpt)" export const featuresWidget = defineWidget({ component: Features, defaultVariant: 'grid', + description: + 'Up to three features, told the same way in three different shapes.', fields: featuresFields, + icon: SparklesIcon, id: 'features', name: 'Features', variants: [ - { id: 'grid', label: 'Grid', description: 'Cards side by side.' }, - { id: 'list', label: 'List', description: 'One per row.' }, - { id: 'compact', label: 'Compact', description: 'A dense summary.' }, + { description: 'Cards side by side.', id: 'grid', label: 'Grid' }, + { + description: 'One per row, with room to explain.', + id: 'list', + label: 'List', + }, + { description: 'A dense summary.', id: 'compact', label: 'Compact' }, ], }) ``` @@ -68,7 +75,7 @@ A variant is one more key on the instance, beside `data` and never in it: ```json { "id": "01JC4Z8N9WQKX7R2M5T6V3B8HD", - "type": "example:features", + "type": "newsroom:features", "variant": "list", "data": { "heading": "One block, three shapes", @@ -80,7 +87,7 @@ A variant is one more key on the instance, beside `data` and never in it: That placement is the whole feature. Because `variant` is a sibling of `data`: - switching it never touches, re-validates or re-saves the content, -- the block's schema stays the schema of its **content**, so a search indexer reading `data` never trips over a styling token, +- the widget's fields describe only its **content**, never a styling token, - and a block that drops a variant later does not need a data migration to get rid of it. ## How a variant resolves diff --git a/apps/web/content/docs/dev/widgets/blocks-field.mdx b/apps/web/content/docs/dev/widgets/blocks-field.mdx index c642cdd66c..9b8f132013 100644 --- a/apps/web/content/docs/dev/widgets/blocks-field.mdx +++ b/apps/web/content/docs/dev/widgets/blocks-field.mdx @@ -1,59 +1,64 @@ --- -title: The blocks() Field +title: Blocks field description: An ordered list of widget instances stored in one JSONB column, validated against the registry on every write. icon: LayoutList --- +`field.blocks()` stores an ordered list of widgets on a Content Engine record, in one JSONB column, and validates it on every write. Use it when the widgets belong to a record, such as a landing page or an article. For widgets that belong to a page of your app rather than to a record, use an [editable page](/docs/dev/widgets/editable-pages) instead. + ## Adding a zone to a content type -```ts title="plugins/example/src/content/page.ts" +```ts title="plugins/newsroom/src/content/page.ts (excerpt)" import { defineContentType, field } from '@vitnode/core/content' +import { PAGE_BLOCKS_ALLOWED } from './page-blocks' + export const pageContentType = defineContentType({ - id: 'example.page', - tableName: 'example_pages', + id: 'newsroom.page', + tableName: 'newsroom_pages', fields: { title: field.text({ required: true, minLength: 3, maxLength: 200 }), slug: field.slug({ source: 'title' }), + // [!code ++:4] content: field.blocks({ - allowed: ['core:*', 'example:callout'], + allowed: PAGE_BLOCKS_ALLOWED, max: 50, }), }, publication: true, + // … }) ``` -| Option | Default | What it does | -| :---------- | :------ | :-------------------------------------------- | -| `allowed` | `"*"` | Which blocks may be placed here. | -| `max` | `200` | Most block instances one record may hold. | -| `min` | none | Fewest block instances. | -| `localized` | `false` | Give every language its own blocks and order. | +`PAGE_BLOCKS_ALLOWED` is `['core:*', 'newsroom:callout', 'newsroom:features']`, kept in `content/page-blocks.ts` so the page that renders the field can [share it](/docs/dev/widgets/restrict-widgets#share-one-list). `field.widgets()` is an alias of `field.blocks()`. + +| Option | Default | What it does | +| :---------- | :------ | :--------------------------------------------- | +| `allowed` | `"*"` | Which widgets may be placed here. | +| `max` | `200` | Most widgets one record may hold. | +| `min` | none | Fewest widgets. | +| `localized` | `false` | Give every language its own widgets and order. | -`min` and `max` count **block instances**, wherever they sit. A block inside a +`min` and `max` count **widget instances**, wherever they sit. A widget inside a [layout area](/docs/dev/widgets/layout-areas) counts towards the limit; the area -holding it does not, so an empty area counts as nothing. Two blocks are two -blocks whether they sit side by side in an area or one under the other. +holding it does not, so an empty area counts as nothing. -A `blocks()` field is never `required` and never `nullable` - the empty list is what "no blocks" is, so a create that says nothing about the zone gets `[]`. That empty list is still checked against `min`, so a zone that declares `min: 1` refuses a create that leaves it out. +A `blocks()` field is never `required` and never `nullable`. The empty list means "no widgets", so a create that says nothing about the zone gets `[]`. That empty list is still checked against `min`, so a zone that declares `min: 1` refuses a create that leaves it out. ## Restricting what may go in `allowed` takes three shapes, and they mix freely: ```ts -field.blocks({ allowed: '*' }) // every registered block +field.blocks({ allowed: '*' }) // every registered widget field.blocks({ allowed: ['core:*', 'blog:*'] }) // two whole plugins field.blocks({ allowed: ['core:hero', 'core:text'] }) // exactly these two ``` -A namespace wildcard is the useful middle: installing another plugin does not silently widen what a page may contain, while adding a block to a plugin you already trust does. - -The shape is a string union on purpose, so a later `{ categories: [...] }` or `{ excluding: [...] }` object can join the array without any existing definition changing. +A namespace wildcard is the useful middle. Installing another plugin does not silently widen what a page may contain, while adding a widget to a plugin you already trust does. The same list format works on zones and editable pages; see [Restrict widgets](/docs/dev/widgets/restrict-widgets). ## The stored shape @@ -74,45 +79,45 @@ holding an ordered list of instances: }, { "id": "01JC4Z8N9X3R7T2M5Q6K8V4B9F", - "type": "example:callout", + "type": "newsroom:callout", "data": { "title": "Heads up", "body": "...", "tone": "info" } } ] ``` -| Key | Rule | -| :-------- | :------------------------------------------------------------------------------------------------------------------------------------------------- | -| `id` | Stable and unique **within the zone**. Never the array index - a reorder must not change what a block is. | -| `type` | The globally unique block id. | -| `data` | Validated against that block's own field schema. | -| `variant` | Optional. The look this instance picked, from the ones its block [declares](/docs/dev/widgets/block-variants). Absent means "the block's default". | +| Key | Rule | +| :-------- | :--------------------------------------------------------------------------------------------------------------------------------------------------- | +| `id` | Stable and unique **within the zone**. Never the array index, since a reorder must not change what a widget is. | +| `type` | The globally unique widget id, such as `core:hero`. | +| `data` | Validated against that widget's own fields. | +| `variant` | Optional. The look this instance picked, from the ones its widget [declares](/docs/dev/widgets/block-variants). Absent means "the widget's default". | Array order **is** the rendered order. Nothing sorts it. ### Areas in the same list -A zone holds **content nodes**, and a node is either a block instance or an [Area](/docs/dev/widgets/layout-areas) - a small layout container with its own id, a `layout` of four tokens, and `children` that are blocks: +A zone holds **content nodes**, and a node is either a widget instance or an [Area](/docs/dev/widgets/layout-areas), a small layout container with its own id, a `layout` and `children` that are widgets: ```json { "id": "01JC4Z8N9X3R7T2M5Q6K8V4B9F", "kind": "area", - "layout": { "columns": 2, "gap": "md" }, + "layout": { "columns": 2, "gap": 16 }, "children": [ - { "id": "01JC…", "type": "core:text", "data": { "heading": "Left" } } + { + "id": "01JC4Z8N9X3R7T2M5Q6K8V4B9G", + "type": "core:text", + "data": { "heading": "Left", "body": "Two widgets, side by side." } + } ] } ``` -An Area is told apart by `kind: "area"`, needs no entry in `allowed` - its children are checked against the same list the zone's own blocks are - and cannot contain another Area. A zone stored before Areas existed is a list of blocks, which is a list of nodes that never nests, so nothing was migrated. - -### Why one column - -A block instance is a document: an id, a type, and whatever that type declares. Splitting it across tables would buy a join per zone, a migration per new block, and an ordering Postgres would have to rebuild on every read. One `UPDATE` re-orders a whole page, and a revision snapshot of it is a value rather than a graph - which is what makes drafts, revisions and reusable blocks tractable later. +An Area is told apart by `kind: "area"` and cannot contain another Area. It needs no entry in `allowed`, because the API checks its children against the same list as the zone's own widgets. ### Instance ids -`createBlockInstanceId()` produces a 26-character Crockford base32 identifier - 10 characters of millisecond timestamp, then 16 of randomness. It sorts by creation time, which is what lets a later merge of two edits order instances it has never seen. +`createBlockInstanceId()` produces a 26-character identifier that sorts by creation time. ```ts import { createBlockInstance } from '@vitnode/core/widgets' @@ -121,22 +126,22 @@ const hero = createBlockInstance('core:hero', { title: 'Build your community' }) // { id: "01JC4Z8N9W…", type: "core:hero", data: { title: "…" } } ``` -Storage accepts any stable id of 1-64 URL-safe characters, so an imported document or a reusable block can keep the identity it arrived with. +Storage accepts any stable id of 1-64 letters, digits, `-` and `_`, so an imported document can keep the ids it arrived with. ## What the API checks on a write -Every create and update runs the zone through the registry, once, before anything is stored - so a row that exists has already been validated. Nothing re-checks it on read, and [the renderer never repeats that schema parse](/docs/dev/widgets/rendering#validation) - it only compares the stored shape against the block's current fields, so a definition that moved on since the write cannot reach a component. +Every create and update runs the zone through the registry, once, before anything is stored. Nothing re-checks it on read; the renderer only [compares the stored shape](/docs/dev/widgets/rendering#validation) with the widget's current fields. -The whole request is refused if any instance fails: +If any instance fails, the API refuses the whole request with `400` and stores nothing: -| Refused | Message | -| :------------------------------- | :------------------------------------------------------------------------------------ | -| A block the field does not allow | `Block "core:cta" is not allowed in this field.` | -| A block no plugin registers | `Block "shop:cart" is not registered. Install or enable the plugin that provides it.` | -| Data the block's fields reject | `Block "core:hero" data is invalid - title: Too big…` | -| Two instances sharing an id | `Block instance id "01…" appears more than once.` | +| Refused | Message | +| :-------------------------------- | :------------------------------------------------------------------------------------ | +| A widget the field does not allow | `Block "core:cta" is not allowed in this field.` | +| A widget no plugin registers | `Block "shop:cart" is not registered. Install or enable the plugin that provides it.` | +| Data the widget's fields reject | `Block "core:hero" data is invalid - title: Too big…` | +| Two instances sharing an id | `Block instance id "01…" appears more than once.` | -Errors are keyed by the instance's index, so a client can point at the block that is wrong. +Each message names the instance's position, so a client can point at the widget that is wrong. ## Localization @@ -146,22 +151,20 @@ content: field.blocks({ localized: true }) The column moves to the generated translation table, exactly as a localized `text` field does, and keeps the translation lifecycle, revisions and preview it already had - no new concepts. -What this localizes is the **whole block document**, not the text inside each block. Each language owns its own list: which blocks are in it, what is in them, and what order they come in. The two need not correspond at all. +What this localizes is the **whole list**, not the text inside each widget. Each language owns its own list: which widgets are in it, what is in them, and in what order. The two need not correspond at all: ```text en: core:hero → core:text → core:cta pl: core:hero → core:cta → blog:gallery ``` -That is deliberate. A translated landing page is rarely the same page with different words - a market may need a different call to action, or no gallery - and treating a zone as one translatable document is what makes that expressible. It is also the only model that reuses VitNode's existing localization: a per-field locale map inside JSON would be a second localization system that the translation table, the revision history and the public locale resolution all know nothing about. - -The cost is the other side of the same coin: adding a block to one language does not add it to the others, and there is no "this paragraph is the translation of that paragraph" link between two locales. A field inside a block is therefore never `localized: true`; see [Defining a block](/docs/dev/widgets/defining-a-block#localization). +A translated landing page is rarely the same page with different words, so a market can get a different call to action, or none. The flip side is that adding a widget to one language does not add it to the others. A field inside a widget is never `localized: true`; see [Define a widget](/docs/dev/widgets/defining-a-block#localization). ## Editing one -A `blocks()` field is the storage half, and the [Visual Editor](/docs/dev/widgets/visual-editor) is how somebody arranges what goes in it. The two are joined by whatever your page hands the editor and whatever your API does with the payload that comes back - the field's own `allowed`, `min` and `max` are re-checked on the way in either way. +The AdminCP form leaves a `blocks()` field out, and core ships no editor for a record's field. You fill it through the API, or host the [visual editor](/docs/dev/widgets/visual-editor) on it yourself with a save route of your own. See [Custom editor host](/docs/dev/widgets/custom-editor-host). The field's `allowed`, `min` and `max` are checked on that write like any other. -For a zone that is not a column on a record at all - a settings screen, a forum index, anything a page simply _has_ - the storage half is core's, and you declare the zone rather than a field. See [Editable Pages](/docs/dev/widgets/editable-pages). +For widgets that belong to a page rather than to a record, such as a settings screen or a forum index, core stores and saves the layout for you. See [Editable pages](/docs/dev/widgets/editable-pages). ## Exposing the zone publicly @@ -178,9 +181,9 @@ The public read returns the instances in stored order, each one's `data` already ## What a zone is not -| | | -| :------------------------------ | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| **Not indexable** | A btree index over a JSONB document orders pages by their serialised bytes. `indexes` refuses it. | -| **Not filterable or orderable** | For the same reason. | -| **Not searchable** | Indexing block prose into search is its own stage. | -| **Not an AdminCP form field** | The generated form and the DataTable both leave it out - a zone is arranged on the page by the [Visual Editor](/docs/dev/widgets/visual-editor), not typed into a control. | +| | | +| :------------------------------ | :------------------------------------------------------------------------------------------------ | +| **Not indexable** | A btree index over a JSONB document orders pages by their serialised bytes. `indexes` refuses it. | +| **Not filterable or orderable** | For the same reason. | +| **Not searchable** | Widget text is not indexed for search. | +| **Not an AdminCP form field** | The generated form and the DataTable both leave it out. | diff --git a/apps/web/content/docs/dev/widgets/content-zones.mdx b/apps/web/content/docs/dev/widgets/content-zones.mdx index f250e83f74..0229e63f69 100644 --- a/apps/web/content/docs/dev/widgets/content-zones.mdx +++ b/apps/web/content/docs/dev/widgets/content-zones.mdx @@ -1,545 +1,118 @@ --- -title: Content Zones -description: A named place on a page where widgets may appear. It renders what you hand it, adds no element of its own, and makes no request. +title: Content zones +description: A content zone is a named place on a VitNode page where widgets render. Render one with ContentZone, name it, choose its wrapper and see what an empty zone does. icon: LayoutTemplate --- -A **Content Zone** is a place on a page where widgets may appear, named by the developer who put it there. +A **content zone** is a named place on a page where widgets render. `ContentZone` takes a list of widgets and renders them through [`ContentRenderer`](/docs/dev/widgets/rendering). What it adds is the **name**, which the [visual editor](/docs/dev/widgets/visual-editor) and [editable pages](/docs/dev/widgets/editable-pages) use to place and save widgets in the right spot. -```tsx title="src/routes/_main/pages.$slug.tsx" -import { ContentZone } from '@vitnode/core/widgets/zone' - -import { blocksRegistry } from '@/blocks.gen' +## Before you begin -;<ContentZone id="main" blocks={page.content} registry={blocksRegistry} /> -``` +- **Widgets to render**, read by your route's loader: a record with a [`blocks()` field](/docs/dev/widgets/blocks-field), or an [editable page](/docs/dev/widgets/editable-pages), which hands its zones their widgets for you. +- **A widget registry**: `blocksRegistry` from the app's generated `src/blocks.gen.ts`, or one a plugin page builds with `createBlockRegistry`. See [Where the registry comes from](/docs/dev/widgets/rendering#where-the-registry-comes-from). -That is the whole idea. A zone takes a list of block instances and renders them through [`ContentRenderer`](/docs/dev/widgets/rendering). What it adds on top is a **name** - and a name is what the [Visual Editor](/docs/dev/widgets/visual-editor) and [Editable Pages](/docs/dev/widgets/editable-pages) use to place, rearrange, and save widgets on the page. +## Render a zone -```text -Public page - ↓ -ContentZone - ↓ -ContentRenderer - ↓ -Blocks -``` +The newsroom plugin's page route renders a record's `content` field in a zone named `content`: -## A zone id is not a block id +```tsx title="plugins/newsroom/src/pages/site-page.tsx (excerpt)" +import { ContentZone } from '@vitnode/core/widgets/zone' -These two identifiers are easy to confuse and never interchangeable. +<ContentZone + allowedBlocks={PAGE_BLOCKS_ALLOWED} + blocks={page.content} + id="content" + registry={blocksRegistry} +/> +``` -| | Says | Looks like | Written by | -| :-------------------- | :--------------------------- | :-------------------------------- | :------------------------------ | -| **Zone id** | _where_ content is rendered | `main`, `settings:before-profile` | you, in the page's source | -| **Block instance id** | _which block_ sits inside it | `01JC4Z8N9WQKX7R2M5T6V3B8HD` | `createBlockInstanceId()`, once | +`page` is the record the route's loader returned and `blocksRegistry` the registry built in the same file; [First widget](/docs/dev/widgets/end-to-end#render-it-on-a-page) shows the whole page. `allowedBlocks` is optional; see [Restrict widgets](/docs/dev/widgets/restrict-widgets). -A page has a handful of zones and they do not change when content does. A zone holds any number of block instances, and which ones it holds changes every time somebody edits the page. +A zone never fetches. It renders the list it is handed, so every zone on a page shares the loader's one request. A widget that needs its own data, such as "the six newest articles", fetches it inside its own component. -### Rules for a zone id +## Name the zone -One or more `:`-separated segments of lowercase letters, digits and single hyphens, up to 64 characters. +A zone id is one or more `:`-separated segments of lowercase letters, digits and single hyphens, up to 64 characters: ```tsx <ContentZone id="main" /> -<ContentZone id="sidebar" /> <ContentZone id="before-profile" /> <ContentZone id="homepage:hero" /> -<ContentZone id="settings:before-profile" /> ``` -The optional prefix is yours to use however you like - a screen, a plugin, a section. `settings:before-profile` and `profile:before-profile` are two different zones, which is the point. - -A zone id is never generated and never an array index. Saved editor state will address zones by these strings, so an id that changes is a zone that loses its content. `ContentZone` refuses anything it could not address later: +Write the id by hand and never change it. A saved layout addresses zones by these strings, so a renamed zone is a zone that lost its content. An id that `ContentZone` could not address later throws the first time the page renders: ```tsx <ContentZone id="Before Profile" blocks={content} /> // BlockError: Content zone id "Before Profile" is not usable. … ``` -That throws the first time the page renders, in development or in a test, because a zone id is a literal you wrote - never something a visitor can influence. +A zone id says _where_ widgets render. It is unrelated to the id of each widget instance inside it, which says _which_ widget it is and is created once with `createBlockInstanceId()`. -### One id, one place on the page +## Add a wrapper -A zone id names a **location**, so two zones on one page may not share one. Render the same id twice and view mode simply renders both lists, but edit mode refuses: - -```tsx -<ContentZone id="main" /> -<ProfileForm /> -<ContentZone id="main" /> -// BlockError: Two `<ContentZone id="main" />` outlets are on the page at the same time… -``` - -The editor stores, moves and saves blocks by zone id. With two of them it cannot tell which place a block belongs to, and a save would write one over the other - so it says so the moment both are on the page, rather than picking one and being wrong quietly. Moving a zone to a different element, re-rendering it under another branch of the page, and React's double-invoked effects in development are all fine: the refusal is only for two elements that are genuinely on the page at once. - -Need the same content in two places? Two ids - `main` and `main-aside` - and the page decides what goes in each. - -## Zones around locked application UI - -This is the case Content Zones exist for. Not every page is a CMS page: most of an application is code, and the useful thing is to be able to put content _around_ it. - -```tsx title="src/routes/_main/settings.tsx" -<UserSettingsLayout> - <ContentZone - id="settings:before-profile" - blocks={layoutContent.beforeProfile} - /> - - <ProfileForm /> - - <ContentZone - id="settings:after-profile" - blocks={layoutContent.afterProfile} - /> -</UserSettingsLayout> -``` - -`ProfileForm` is application code. It has validation, permissions and tests, and nobody is going to drag a block into the middle of it. The two zones around it are content widgets - an authorized moderator clicks **Edit widgets** in the user menu to rearrange them without touching the form. - -```text -editable zone - ↓ -locked system UI - ↓ -editable zone -``` - -<Callout title="See it running"> - `/example/zones` in the example plugin is exactly this page: four zones around - a profile form that stays application code. -</Callout> - -## Several zones on one page - -Nothing assumes one zone per page. Each one has its own id, its own list and its own rules. - -```tsx -<> - <ContentZone id="hero" blocks={page.hero} /> - - <ArticleContent /> - - <ContentZone id="after-content" blocks={page.afterContent} /> -</> -``` - -## What a zone renders - -By default: **nothing but the blocks**. No wrapper, no element, no class. - -That is deliberate. A zone often lives inside a flex or grid container, and an uninvited `<div>` there is a broken `gap` and a column that stopped being a column. - -Ask for a wrapper when you want one: +By default a zone renders **nothing but the widgets**, with no wrapper element or class around them. A zone often sits inside a flex or grid container, and an uninvited `<div>` there would break its `gap`. Ask for a wrapper when you want one: ```tsx <ContentZone as="aside" className="flex w-full flex-col gap-4 md:w-64" - id="settings:sidebar" - blocks={content} + id="sidebar" + blocks={sidebar} + registry={blocksRegistry} /> ``` -| Props | Renders | -| :--------------------------------- | :---------------------------------- | -| neither `as` nor `className` | the blocks, and nothing around them | -| `className` only | a `<div>` with that class | -| `as` (with or without `className`) | that element | +| Props | Renders | +| :--------------------------------- | :----------------------------------- | +| neither `as` nor `className` | the widgets, and nothing around them | +| `className` only | a `<div>` with that class | +| `as` (with or without `className`) | that element | -`as` takes an **HTML element name** - `"section"`, `"aside"`, `"header"`, `"ul"` - and not a component. The wrapper's job is to carry the zone's identity into the DOM, and a component is free to drop the `data-*` props it is handed, which would lose that identity silently and only in the case nobody tests. If you want the zone inside a component of yours, compose instead of substituting: +`as` takes an HTML element name, such as `"section"`, `"aside"` or `"ul"`, not a component. The wrapper carries the zone's identity in `data-*` attributes, and a component could drop them. To put a zone inside your own component, nest it instead: ```tsx <Card> - <ContentZone id="settings:sidebar" blocks={content} /> + <ContentZone id="sidebar" blocks={sidebar} registry={blocksRegistry} /> </Card> ``` -That reads better anyway, and the zone stays exactly as heavy as it was. - -## Empty zones - -An empty zone renders **nothing at all** - wrapper included. - -```tsx -<ContentZone id="sidebar" blocks={[]} /> -``` - -There is no empty container, no stray border and no gap in a flex column. `null`, `undefined` and `[]` behave identically, so a page whose zones are all empty is byte-for-byte a page without zones. The registry is not even consulted. - -This is a deliberate performance and layout decision, not an oversight. Most zones in an application are empty most of the time - a page with eight placement points and two filled ones should cost exactly two. Emitting a marker element for the other six would put six nodes in every visitor's document, participate in every flex and grid calculation, and exist for the benefit of a person who is not there. - -An editor will need to _see_ an empty zone in order to drop something into it. That is edit mode's job, and edit mode can render whatever placeholder it likes, because by then somebody has asked for it. - -## Restricting what may go in a zone - -`allowedBlocks` takes the same shape - and means the same thing - as `allowed` on a [`blocks()` field](/docs/dev/widgets/blocks-field#restricting-what-may-go-in): - -```tsx -<ContentZone - id="main" - blocks={page.content} - allowedBlocks={['core:*', 'blog:*']} -/> -``` - -There is one allowlist format in VitNode, one function that evaluates it, and this is it. - -### Two allowlists, two jobs - -The same value means two different things in two places, and the difference matters. - -| | `field.blocks({ allowed })` | `<ContentZone allowedBlocks>` | -| :------------- | :-------------------------- | :------------------------------------------ | -| **Is** | a write boundary | a description of the location | -| **Runs** | on every create and update | not at all in production | -| **On refusal** | the whole request is a 422 | the block is skipped in development | -| **Answers** | "may this be stored?" | "what belongs here?" | -| **For** | the API | the future editor, and you reading the page | - -The field is the thing that _enforces_. A stored instance has already crossed it, so a page re-checking the same list on every render would be paying twice for one answer. The zone's copy exists to say what a location is for - which is what a block picker will need, and what a reader of the JSX needs. - -Public rendering is therefore **not** a second _schema_ validation boundary - it re-parses nothing, though it does always check that a stored block still structurally matches its block's fields. What the zone does with its allowlist follows the same `validate` modes the [renderer](/docs/dev/widgets/rendering#validation) already uses, and it _skips_, never rejects: - -| `validate` | A block the zone does not allow | -| :-------------------------- | :----------------------------------------------- | -| `"never"` | renders | -| `"development"` _(default)_ | is skipped in development, renders in production | -| `"always"` | is skipped everywhere | - -In development the block is skipped, a dashed notice names it, and one `console.warn` is logged per block type - a development aid for content that drifted, not a gate. In production the write boundary is trusted and nothing is checked at all. - -`validate="always"` is the exception, for a page rendering blocks the API never saw - a live import, a third-party feed. Even then it costs one `Map` lookup and a list scan per block, not a schema parse. - -### Declaring the allowlist once - -The zone's allowlist and the field's `allowed` are usually the same value, and writing it twice is a typo waiting to happen. - -If the page is an [editable page](/docs/dev/widgets/editable-pages), do not write it on the page at all. `defineEditablePage` declares each zone's allowlist once, and `<EditablePage>` hands every zone under it both the allowlist and the blocks: - -```tsx title="plugins/example/src/pages/zones-page.tsx" -<EditablePage layout={layout} page={settingsPage}> - <ContentZone id="before-profile" registry={blocksRegistry} /> -</EditablePage> -``` - -An `id` is the whole zone. There is no second copy to drift, and asking for a zone the page does not declare throws rather than rendering one no save will ever reach. - -You may still pass props on a declared zone, with one rule: the page declaration is the ceiling. - -| | | -| :-------------- | :-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `blocks` | Overrides where the content comes from - the array you pass is what renders. | -| `allowedBlocks` | **Narrows only.** It is intersected with the page's allowlist, so `"*"` here cannot re-open a zone the page restricted, and an intersection of nothing is a developer error rather than a zone nobody can fill. | -| `min` / `max` | **Tighten only.** The higher `min` and the lower `max` win, and a `min` that ends up above the `max` throws where you wrote it. | - -That is only true for a zone an [editable page](/docs/dev/widgets/editable-pages#what-a-call-site-may-still-say) declares, because that declaration is what the save route checks against. A standalone `<ContentZone>` has nothing to be intersected with, so its own props are simply its own. - -For a zone outside an editable page, put the array in a module of its own and import it from both ends: - -```ts title="plugins/example/src/content/page-blocks.ts" -export const PAGE_BLOCKS_ALLOWED = ['core:*', 'example:callout'] as const -``` - -```ts title="plugins/example/src/content/page.ts" -content: field.blocks({ allowed: PAGE_BLOCKS_ALLOWED, max: 50 }) -``` - -```tsx -<ContentZone - id="settings:before-profile" - allowedBlocks={PAGE_BLOCKS_ALLOWED} - blocks={content} -/> -``` - -One declaration, two readers, and the page imports an array rather than a content type. - -<Callout title="Why the zone does not take the field itself" type="warn"> - An earlier draft of this API accepted `field={pageContentType.fields.content}` so the allowlist could be read straight off the descriptor. It was measured and removed. - -A content type is authored by _calling_ `defineContentType` at module scope, so importing one retains everything that call reaches - the field factories, the schema builders, Zod, the admin labels. On `/example/zones` the difference was **8 chunks and 23.5 kB** against **15 chunks and 181.8 kB**, to read one array of strings. - -A boundary test keeps that honest: `blocks/zone.tsx` reaches `react` and nothing else, `content/define.ts` reaches Zod, and `content/fields.ts` reaches nothing - the descriptors are cheap, the content _type_ is not. - -</Callout> - -### Where the registry comes in - -Pass it, from the route that renders blocks: - -```tsx -import { blocksRegistry } from '@/blocks.gen' -``` - -`blocks.gen.ts` statically imports every configured plugin's block components, so importing it from a root module - `router.tsx`, a root layout - would put every block in the application on every page, including the ones that render none. Import it in the route that needs it and it stays in that route's chunk. A test asserts the application's root router cannot reach it. - -A plugin cannot see the host's generated file at all, so a plugin-owned page builds the registry it needs from the blocks it knows about: - -```tsx title="plugins/example/src/pages/zones-page.tsx" -import { createBlockRegistry } from '@vitnode/core/widgets' -import { blocks as coreBlocks } from '@vitnode/core/widgets/built-in' - -import { blocks as exampleBlocks } from '@/blocks' - -const blocksRegistry = createBlockRegistry([coreBlocks, exampleBlocks]) -``` - -Either way the registry is a value the page owns, and nothing global had to be set up first. - -## A zone never fetches - -`ContentZone` is rendering infrastructure. It takes block data as a prop and never goes looking for it. - -```text -route loader - ↓ -one request for the page - ↓ -zones render what it returned -``` - -The alternative writes itself, and it is the thing to avoid: - -```text -page - ├ zone A → request - ├ zone B → request - └ zone C → request -``` - -Five zones on a settings page would be five round trips for data that came from one row. A block that genuinely needs its own data - "the six newest articles" - fetches it inside its own component, where it can be cached and suspended on its own terms. - -## Performance - -A zone costs a function call. - -- **No request.** It renders the list it was given. -- **No hydration.** It holds no state, subscribes to nothing, and server-renders like any other component. A page full of zones ships the same JavaScript as a page without them. -- **No editor.** `@vitnode/core/widgets/zone` reaches React and `ContentRenderer`, and that is the entire list. A boundary test asserts the reachable module graph, so drag-and-drop, forms, query and AdminCP code cannot drift into it later. -- **No DOM.** Unless you ask for a wrapper, a zone is not in the markup at all. - -## What the editor will find - -When a zone renders a wrapper, the wrapper carries its identity: +A wrapper carries the zone's id and allowlist: ```html -<aside - class="…" - data-vitnode-zone="settings:sidebar" - data-vitnode-zone-allowed="core:*,example:callout" -></aside> +<aside data-vitnode-zone="sidebar" data-vitnode-zone-allowed="core:text">…</aside> ``` -Two attributes, both short strings. That is the whole metadata story for now, and it is free: they ride on an element you asked for anyway. There is no zone registry, no context and no module-level state holding a list of zones - a public page has nobody to tell. - -The format stays small and predictable on purpose. An id, and a comma-separated allowlist. Nothing here will ever grow into a serialised JSON blob in an attribute: anything bigger than a couple of identifiers belongs in edit mode, where it can be a JavaScript value instead of a string somebody has to parse back out of the document. - -<Callout title="A zone without a wrapper has no marker - by design" type="warn"> - Most zones render no element, so most zones leave no trace in view-mode HTML. That is the [empty-zone and no-wrapper behaviour](#what-a-zone-renders) working as intended, and it means **the set of zones on a page is not discoverable by scanning public DOM**. - -Edit mode does not try. It knows its zones because it rendered them - each `ContentZone` registers itself while edit mode is on - rather than walking the document looking for attributes that were never promised to be there. That registration is also what notices [two zones claiming one id](#one-id-one-place-on-the-page): the list the editor keeps is the truth, not the DOM. +A zone without a wrapper leaves no trace in the HTML, so do not look for zones by scanning the DOM. The editor doesn't either. Each `ContentZone` registers itself while edit mode is on. -</Callout> - -If you need those values yourself, `@vitnode/core/widgets` exports the pieces without any React: +## Empty zones -```ts -import { - assertContentZoneId, - contentZoneAttributes, - isContentZoneId, - parseContentZoneId, -} from '@vitnode/core/widgets' +An empty zone renders **nothing at all**, wrapper included. `null`, `undefined` and `[]` behave the same, so a page whose zones are all empty is the same markup as a page without zones. Most zones are empty most of the time, and visitors should not pay for placeholders nobody is looking at. -parseContentZoneId('settings:before-profile') -// { name: "before-profile", scope: "settings" } -``` +In edit mode the same zone shows **Drag and drop widgets here** and an **Add widget** button, so a moderator can fill it. See [Visual editor](/docs/dev/widgets/visual-editor#open-the-editor). ## Props -| Prop | Default | | -| :-------------- | :------------------------ | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `id` | — | The zone's stable name. Required. | -| `blocks` | — | The instances to render. `null`, `undefined` and `[]` all render nothing. | -| `registry` | the process default | The registry to resolve blocks against. | -| `allowedBlocks` | none | What may be placed here. Same shape as a `blocks()` field's `allowed`. Under an editable page it may only narrow what the page allows. | -| `min` / `max` | `0` / `200` | How many blocks belong here. Under an editable page they may only tighten the page's own bounds, and with no `max` written anywhere storage still applies its usual ceiling of `200`. | -| `as` | none | An HTML element name to wrap in. Components are not accepted. | -| `className` | none | A class for that wrapper. Implies `as="div"`. | -| `validate` | `"development"` | Passed through to the renderer, and gates the allowlist check. | -| `fallback` | a development-only notice | What to render for a block that is skipped. | - -Everything but `id` and `blocks` is optional and named, so later additions - a label, a maximum, a permission - are additions rather than a new signature. - -## Verifying it yourself - -`/example/zones` in the example plugin is the manual test page for everything above. It renders four zones around a locked profile form, and its own copy lists what each one should look like. - -### In development - -<Steps> -<Step> - -Start the development environment from the repository root: - -```bash -pnpm dev -``` - -</Step> -<Step> +| Prop | Default | | +| :-------------- | :------------------------ | :------------------------------------------------------------------------------------------------------------------------------------------------ | +| `id` | - | The zone's stable name. Required. | +| `blocks` | - | The widgets to render. `null`, `undefined` and `[]` all render nothing. Leave it out on an editable page, which supplies them. | +| `registry` | the process default | The registry to resolve widgets against. | +| `allowedBlocks` | none | What may be placed here, in the [allowlist format](/docs/dev/widgets/restrict-widgets). On an editable page it may only narrow the page's list. | +| `min` / `max` | none | How many widgets the editor lets the zone hold. On an editable page they may only tighten the page's own bounds. | +| `as` | none | An HTML element name to wrap in. Components are not accepted. | +| `className` | none | A class for that wrapper. Implies `as="div"`. | +| `validate` | `"development"` | Passed to the renderer; decides when `allowedBlocks` is checked. See [Validation](/docs/dev/widgets/rendering#validation). | +| `fallback` | a development-only notice | What to render for a widget that is skipped. | -Open [http://localhost:3000/example/zones](http://localhost:3000/example/zones) and check the page itself: - -| | Expected | -| :----------- | :--------------------------------------------------------------------- | -| Order | Blocks appear in the order the loader lists them - callout, then text. | -| Locked UI | The profile form renders untouched between the two zones. | -| Empty zone | `settings:before-footer` leaves no gap below the layout. | -| Independence | Each zone renders its own blocks; none affects another. | - -</Step> -<Step> - -Open DevTools → Elements and check the DOM: - -| Zone | Expected | -| :------------------------ | :----------------------------------------------------------------------------------- | -| `settings:sidebar` | `<aside data-vitnode-zone="settings:sidebar" data-vitnode-zone-allowed="core:text">` | -| `settings:before-profile` | its two blocks, with **no** element wrapping them | -| `settings:after-profile` | its block, with **no** element wrapping it | -| `settings:before-footer` | nothing - no element, no comment, no whitespace node | - -</Step> -<Step> - -Open DevTools → Network, filter to Fetch/XHR, and reload. `ContentZone` issues **no request of its own**: the blocks arrived with the route's loader data. - -</Step> -<Step> - -Break a zone id on purpose, to confirm it fails loudly rather than silently: - -```tsx -<ContentZone id="Settings Sidebar" blocks={loaderData.sidebar} /> -``` +`ContentZone` comes from `@vitnode/core/widgets/zone`, which pulls in React and the renderer and nothing else, so no editor code reaches a page that only shows zones. For code without React, `@vitnode/core/widgets` exports `isContentZoneId`, `assertContentZoneId`, `parseContentZoneId` and `contentZoneAttributes`. -The page should throw a `BlockError` naming the id and the rule. Put it back. +## Check the result -</Step> -<Step> - -Open an unrelated route - `/`, or `/docs` - with the Network panel open, and confirm **no block module loads**. A page that renders no blocks should never request one. This is the property the root router must not break; the [import-graph test](#performance) is the standing guard, and this is how you see it. - -</Step> -</Steps> - -### A block whose plugin is gone - -Content outlives plugins, so the interesting case is a page referencing a block nothing registers. Simulate it **temporarily** - do not commit the change. - -<Steps> -<Step> - -In `plugins/example/src/blocks.tsx`, comment the callout out of the registered list: - -```ts -export const blocks = { - pluginId: manifest.id, - blocks: [], // was [calloutBlock] -} satisfies BlockPluginSource -``` - -The loader in `zones-page.tsx` still places an `example:callout`, which is exactly the situation: stored content naming a block that is no longer installed. - -</Step> -<Step> - -**In development**, reload `/example/zones`. Expect a dashed notice reading _Block "example:callout" is not registered_, one `console.warn` in the terminal and browser console, and **every other block still rendering** - the text block, the profile form, the CTA, the sidebar. - -</Step> -<Step> - -**In production**, run the production flow below with the same edit in place. Expect the callout to be silently absent, no notice, no error, and a 200 response. A visitor sees a page with one section missing, not a stack trace. - -</Step> -<Step> - -Revert `blocks.tsx`. - -</Step> -</Steps> - -### Against a production build - -Dev-mode checks miss bundling, minification and real SSR, so repeat the important ones against a build: - -<Steps> -<Step> - -Build and start: - -```bash -pnpm build -pnpm start -``` - -`pnpm build` runs `vite build` for the web app; `pnpm start` serves `.output/server/index.mjs`. To do just the web app, use `pnpm --filter web build` and `pnpm --filter web start`. - -</Step> -<Step> - -Read the **server-rendered markup**, not the hydrated DOM - `view-source:`, or: - -```bash -curl -s http://localhost:3000/example/zones | grep -o 'data-vitnode-zone[^>]*' -``` - -Exactly one match, for the sidebar. The unwrapped zones contribute no attribute, and the empty one contributes nothing. - -</Step> -<Step> - -Confirm the block content is in the HTML the server sent, not painted in afterwards - search the source for a block's text. If it is there, the zone server-rendered and needs no hydration to be readable. - -</Step> -<Step> - -Confirm no editor code reached the page: - -```bash -curl -s http://localhost:3000/example/zones | grep -cE 'dnd-kit|tiptap|cmdk' -``` - -`0`. The [import-graph tests](#performance) are the standing guarantee; this is the spot check that the guarantee survived bundling. - -</Step> -<Step> - -Load an unrelated route and confirm its chunks hold no block components - search the modules it preloads for a block id such as `core:hero`: - -```bash -curl -s http://localhost:3000/ | grep -o '/assets/[^"]*\.js' | sort -u -``` - -Fetch those and grep for `core:hero`. Nothing should match: a page that renders no blocks must not carry any. - -</Step> -</Steps> - -## The same zone, in edit mode - -A zone renders blocks. It is also the surface an authorized editor arranges them on - see [Visual Edit Mode](/docs/dev/widgets/visual-editor). - -```text -ContentZone in view mode: -render blocks - -ContentZone in edit mode: -render blocks + the editing surface -``` +Open the page and view its source. The zone's widgets are in the server-rendered HTML, in stored order, with no element around them unless you asked for a wrapper. On `/newsroom/zones` from the newsroom plugin, only the sidebar zone, rendered `as="aside"`, carries `data-vitnode-zone="sidebar" data-vitnode-zone-allowed="core:text"`; its three unwrapped zones add no element. -The part worth saying is what did **not** change. Zones are still addressed by the ids you wrote, block instances by the ids already stored, and the allowlist by the format both the field and the zone already speak. Edit mode is a change inside `ContentZone`, not a change to the pages that use it - so there is no `EditableContentZone` to migrate to, and nothing stored before it existed needed rewriting. +## Related -The editor is not in the bundle of a page nobody is editing. It arrives on the first click of **Edit widgets**, through a dynamic import that a [boundary test](/docs/dev/widgets/visual-editor#the-lazy-loading-guarantee) keeps honest. +- [Several zones](/docs/dev/widgets/several-zones): zones around a form, unique ids and per-zone saving. +- [Restrict widgets](/docs/dev/widgets/restrict-widgets): limit what a zone accepts. +- [Editable pages](/docs/dev/widgets/editable-pages): let moderators arrange the zones in the browser. diff --git a/apps/web/content/docs/dev/widgets/custom-editor-host.mdx b/apps/web/content/docs/dev/widgets/custom-editor-host.mdx new file mode 100644 index 0000000000..a8ec3c0655 --- /dev/null +++ b/apps/web/content/docs/dev/widgets/custom-editor-host.mdx @@ -0,0 +1,127 @@ +--- +title: Custom editor host +description: Open the VitNode visual editor on widgets that live in your own storage, such as a record's blocks() field, with ContentEditorRuntime and a save adapter you write. +icon: Plug +--- + +[Editable pages](/docs/dev/widgets/editable-pages) cover most needs. Core stores the layout, checks permissions and saves. When the widgets live somewhere else, such as a content record's [`blocks()` field](/docs/dev/widgets/blocks-field), you host the editor yourself. You decide when it is open, and you write the adapter that saves. + +| Core provides | You provide | +| --- | --- | +| The editor, loaded on first use | When editing is allowed, and the button that opens it | +| The sidebar, drag and drop and properties panel | Loading the widgets in your route | +| The save payload, with each zone's baseline | A save route that checks permissions and conflicts | +| Error, conflict and unsaved-changes handling | Updating your page with what was stored | + +## Before you begin + +- A page that renders its widgets in [content zones](/docs/dev/widgets/content-zones). +- A route that stores those widgets and checks the editor's permission. The adapter only calls it; the route is what refuses a save. + +## Wrap the zones in ContentEditorRuntime + +```tsx +import { ContentEditorRuntime } from "@vitnode/core/widgets/edit"; +import { ContentZone } from "@vitnode/core/widgets/zone"; +import { useState } from "react"; + +const [editing, setEditing] = useState(false); + +<> + {canEdit ? <button onClick={() => setEditing(true)}>Edit widgets</button> : null} + + <ContentEditorRuntime adapter={adapter} enabled={editing} onExit={() => setEditing(false)}> + <ContentZone id="content" blocks={zones.content} registry={blocksRegistry} /> + </ContentEditorRuntime> +</>; +``` + +| Prop | | | +| --- | --- | --- | +| `enabled` | required | `false` renders the children only. `true` loads and opens the editor. | +| `children` | required | The page. Every `ContentZone` inside becomes editable. | +| `adapter` | optional | Where **Save** sends its payload. Without one, Save says there is nowhere to save. | +| `onExit` | optional | Called by **Finish editing**, after the unsaved-changes prompt. | + +The user menu's **Edit widgets** entry exists only for editable pages, so a custom host draws its own button. Hiding the button is only a courtesy. Your save route must check the permission again. + +## Write the adapter + +The editor never makes a request. **Save** calls your adapter with: + +```ts +import type { + VisualEditorAdapter, + VisualEditorSaveInput, + VisualEditorSaveResult, +} from "@vitnode/core/editor/adapter"; +``` + +| `VisualEditorSaveInput` | | +| --- | --- | +| `zones` | Every zone on the page, keyed by id, emptied ones included. | +| `changedZoneIds` | The zones that differ from what loaded. Write only these. | +| `expectedZones` | For each changed zone, the widgets it started from. Compare with what is stored to refuse a save that would overwrite somebody else's. | + +Resolve and the editor marks itself saved. Throw and it keeps every change and shows an error. Throw `{ refusal: "message" }` to show your own message, or `{ refusal, conflict: true }` for the editor's _Somebody else saved this page first_. + +An adapter that saves a page record's `content` zone through a route of your own: + +```tsx +import type { VisualEditorAdapter } from "@vitnode/core/editor/adapter"; + +import { fetcher } from "@vitnode/core/tanstack/fetcher"; + +import { manifest } from "@/manifest.gen"; + +const adapter: VisualEditorAdapter = { + save: async ({ changedZoneIds, expectedZones, zones }) => { + if (!changedZoneIds.includes("content")) return; + + // Your route: checks the permission, compares `expected` with what is + // stored (409 when it differs) and stores `content`. + const response = await fetcher({ + plugin: manifest.id, + method: "put", + module: "admin/pages", + path: "/{id}/content", + args: { + params: { id: page.id }, + body: { content: zones.content, expected: expectedZones.content }, + }, + }); + + if (!response.ok) { + throw { + conflict: response.status === 409, + refusal: `Saving answered ${response.status}. Nothing was stored.`, + }; + } + + const stored = await response.json(); + setZones(current => ({ ...current, content: stored.content })); + + return { zones: { content: stored.content } }; + }, +}; +``` + +- **Return what you stored.** The API may normalise what it was sent. Returning it lets the editor rebase onto the real stored version, so the next save does not send a stale copy. +- **Update your page's state** with it, so **Finish editing** shows the saved page without a reload. +- **Never fill in missing zones with defaults.** Write only `changedZoneIds`; a zone that is not in the payload stays as stored. + +## Rules for the host + +- **Load before you edit.** If reading the widgets fails, show an error and do not open the editor. An empty zone handed to the editor after a failed read is what the next **Save** writes over the real content. +- **Changes from outside.** When your page re-renders with new widgets, a zone without unsaved changes adopts them; a zone with unsaved changes keeps them until **Save** or **Discard**. +- **Context.** In edit mode, widgets render inside the editor's React tree. Context from your app root reaches them; a provider between your page and a `ContentZone` does not. +- **Widget state restarts.** Entering edit mode re-creates each widget's component, so a playing video or an open accordion inside a widget resets. The rest of the page keeps its state. + +## How it stays out of the way + +`@vitnode/core/widgets/edit` imports React and nothing else. The editor arrives with a dynamic `import()` the first time `enabled` turns `true`, and a test in `@vitnode/core` fails the build if the editor ever reaches the public bundle. The runtime mounts the editor beside your page, not around it, so opening it never remounts the page. + +## Related + +- [Visual editor](/docs/dev/widgets/visual-editor): what editors see and can do. +- [Editable pages](/docs/dev/widgets/editable-pages): `createContentEditorAdapter`, which builds this adapter for core's page layouts. diff --git a/apps/web/content/docs/dev/widgets/custom-loader.mdx b/apps/web/content/docs/dev/widgets/custom-loader.mdx new file mode 100644 index 0000000000..0f4ba04aae --- /dev/null +++ b/apps/web/content/docs/dev/widgets/custom-loader.mdx @@ -0,0 +1,184 @@ +--- +title: Custom loader +description: Load a VitNode editable page's layout in your own route loader and save it with EditablePage and createContentEditorAdapter, when PageWidgets does not fit your page. +icon: CodeXml +--- + +[`PageWidgets`](/docs/dev/widgets/editable-pages) does the loading, the permission check and the saving for you. Write them yourself when the page needs the layout in its own loader data: a route declared with `definePluginRoute`, whose loader has no query client, or a page that shows the layout's `updatedAt` ("Last rearranged…"). The newsroom plugin's `/newsroom/zones` page is built this way; the snippets below follow `plugins/newsroom/src/pages/zones-page.tsx`, trimmed. + +## Before you begin + +- A page declared with `defineEditablePage` and registered in `buildApiPlugin` with its moderator permission, as in steps 1 and 2 of [Editable pages](/docs/dev/widgets/editable-pages). Here it is `settingsPage` from `plugins/newsroom/src/content/settings-page.ts`. +- A widget registry for the zones; see [Where the registry comes from](/docs/dev/widgets/rendering#where-the-registry-comes-from). + +<Steps> + +<Step> + +### Load the layout and the permissions + +Read the page's layout and the viewer's moderator permissions in one round trip. Both routes are core's and public: + +```tsx title="plugins/newsroom/src/pages/zones-page.tsx (excerpt)" +import type { EditablePageLayoutPayload } from "@vitnode/core/content/editor"; + +import { definePluginRoute } from "@vitnode/core/routing"; +import { fetcher } from "@vitnode/core/tanstack/fetcher"; +import { holdsPagePermission } from "@vitnode/core/tanstack/widgets"; +import { zodContentNode } from "@vitnode/core/widgets"; +import { z } from "zod"; + +import { settingsPage } from "@/content/settings-page"; +import { manifest } from "@/manifest.gen"; + +const zodLayout = z.object({ + pageId: z.string(), + updatedAt: z.string().nullable(), + zones: z.record(z.string(), z.array(zodContentNode)), +}); + +interface ZonesPageData { + canEdit: boolean; + layout: EditablePageLayoutPayload; +} + +export const route = definePluginRoute<ZonesPageData>({ + load: async () => { + const [stored, staff] = await Promise.all([ + fetcher({ + plugin: "@vitnode/core", + method: "get", + module: "pages", + path: "/layout", + args: { query: { pageId: settingsPage.id } }, + }), + fetcher({ + plugin: "@vitnode/core", + method: "get", + module: "users", + path: "/permissions", + }), + ]); + + if (!stored.ok) { + throw new Error(`Reading this page's layout answered ${stored.status}.`); + } + + return { + canEdit: staff.ok + ? holdsPagePermission(await staff.json(), { + ...settingsPage.permission, + plugin: manifest.id, + }) + : false, + layout: zodLayout.parse(await stored.json()), + }; + }, +}); +``` + +The `throw` is the important line. "Nothing is stored yet" is a `200` full of the zones' defaults with `updatedAt: null`; "the layout could not be read" must never look like that, or the next **Save** writes defaults over the real layout. `holdsPagePermission` counts a root role as yes. (The newsroom page uses a helper of its own, `mayEditSettingsPage`, for the same check.) + +</Step> + +<Step> + +### Build the save adapter + +`createContentEditorAdapter` turns the editor's **Save** into a payload holding only the changed zones and what each held before. You send it to `PUT /pages/layout` and hand back what was stored. This goes in the page component, where `loadedLayout` is the loader's `layout`: + +```tsx title="plugins/newsroom/src/pages/zones-page.tsx (excerpt)" +import { + createContentEditorAdapter, + EditablePageSaveRefused, +} from "@vitnode/core/content/editor"; +import { useMemo, useState } from "react"; + +const [layout, setLayout] = useState(loadedLayout); + +const adapter = useMemo( + () => + createContentEditorAdapter({ + page: settingsPage, + save: async payload => { + const response = await fetcher({ + plugin: "@vitnode/core", + method: "put", + module: "pages", + path: "/layout", + args: { body: payload }, + }); + + if (!response.ok) { + throw new EditablePageSaveRefused(`Saving answered ${response.status}.`, { + conflict: response.status === 409, + pageId: settingsPage.id, + }); + } + + const stored = zodLayout.parse(await response.json()); + + setLayout(current => ({ + ...stored, + zones: { ...current.zones, ...stored.zones }, + })); + + return stored; + }, + }), + [], +); +``` + +- **Throw `EditablePageSaveRefused`**, not a plain `Error`. The editor shows its message, and with `conflict: true` shows **Somebody else saved this page first** instead. Every change stays in the editor either way. (The newsroom page passes the API's own error message; `savePageLayout` from `@vitnode/core/tanstack/widgets` does the same request and refusal for you.) +- **Merge the answer.** `PUT` answers with the saved zones only, so carry the others over. The new `updatedAt` comes with it. +- **Return what was stored**, so the editor rebases onto the server's copy. + +</Step> + +<Step> + +### Wrap the zones in EditablePage + +Render `EditablePage` around the zones, with the layout from state: + +```tsx title="plugins/newsroom/src/pages/zones-page.tsx (excerpt)" +import { EditablePage } from "@vitnode/core/widgets/page"; +import { ContentZone } from "@vitnode/core/widgets/zone"; + +<EditablePage + adapter={adapter} + canEdit={canEdit} + layout={layout} + openEditing={openEditing} + page={settingsPage} +> + <ContentZone id="before-profile" registry={blocksRegistry} /> + <ProfileForm /> + <ContentZone as="aside" id="sidebar" registry={blocksRegistry} /> +</EditablePage> +``` + +| Prop | Required | Description | +| :------------ | :------- | :--------------------------------------------------------------------------------------------------------------------------- | +| `page` | yes | The definition. It resolves each zone's widgets, allowlist and limits. | +| `layout` | no | What the loader read. `null` renders every zone's declared default. | +| `canEdit` | no | `true` mounts the editor runtime and offers **Edit widgets** in the user menu. This is UI only; the save route checks again. | +| `openEditing` | no | Open in edit mode on arrival, for an `?edit=true` link. Ignored unless `canEdit`. | +| `adapter` | no | Where **Save** goes. | +| `onExit` | no | Called by **Finish editing**, after the unsaved-changes prompt. | + +Each `ContentZone` takes its widgets and allowlist from the page, so it needs only an `id`. A layout that belongs to another page id throws, so one page's widgets can never be saved onto another. + +</Step> + +</Steps> + +## Check the result + +Open `/newsroom/zones` signed in as an administrator, choose **Edit widgets**, move a widget in one zone and **Save**. In DevTools, the one `PUT /api/@vitnode/core/pages/layout` request carries only that zone in `zones` and `expectedZones`, and the "Last rearranged" line under the title updates without a reload. + +## Related + +- [Saving layouts](/docs/dev/widgets/saving-layouts): what the two routes accept, refuse and store. +- [Custom editor host](/docs/dev/widgets/custom-editor-host): widgets stored somewhere other than core's page layouts. diff --git a/apps/web/content/docs/dev/widgets/defining-a-block.mdx b/apps/web/content/docs/dev/widgets/defining-a-block.mdx index fc4935275e..937ef7ca92 100644 --- a/apps/web/content/docs/dev/widgets/defining-a-block.mdx +++ b/apps/web/content/docs/dev/widgets/defining-a-block.mdx @@ -1,21 +1,25 @@ --- -title: Defining a Widget +title: Define a widget description: defineWidget takes an id, Content Engine fields and one React component, and infers the widget's data type from the fields. icon: SquarePen --- +A widget is one `defineWidget` call: a bare id, the fields an editor fills in and the React component that renders them. This page covers each part, the fields a widget may hold and how ids are namespaced. For the whole path to a rendered page, follow [First widget](/docs/dev/widgets/end-to-end). + +`defineWidget` and `defineBlock` are the same function, exported from `@vitnode/core/widgets` under both names; the types have both names too (`WidgetData` is `BlockData`). + ## One call -```tsx title="plugins/example/src/widgets/callout.tsx" +```tsx title="plugins/newsroom/src/widgets/callout.tsx (excerpt)" import type { WidgetComponentProps, WidgetData } from '@vitnode/core/widgets' +import { field } from '@vitnode/core/content/fields' import { defineWidget } from '@vitnode/core/widgets' -import { field } from '@vitnode/core/content' import { InfoIcon } from 'lucide-react' const calloutFields = { - body: field.textarea({ required: true, minLength: 1, maxLength: 600 }), - title: field.text({ required: true, minLength: 1, maxLength: 120 }), + body: field.textarea({ maxLength: 600, minLength: 1, required: true }), + title: field.text({ maxLength: 120, minLength: 1, required: true }), tone: field.enum({ defaultValue: 'info', values: ['info', 'success', 'warning'], @@ -24,10 +28,22 @@ const calloutFields = { type CalloutData = WidgetData<typeof calloutFields> +const tones: Record<string, string> = { + info: 'border-primary/40 bg-primary/5', + success: 'border-success/40 bg-success/5', + warning: 'border-destructive/40 bg-destructive/5', +} + const Callout = ({ data }: WidgetComponentProps<CalloutData>) => ( - <aside> - <h2>{data.title}</h2> - <p>{data.body}</p> + <aside + className={`flex flex-col gap-2 rounded-lg border p-4 md:p-6 ${tones[data.tone ?? 'info']}`} + > + <h2 className="text-base font-semibold text-balance md:text-lg"> + {data.title} + </h2> + <p className="text-sm leading-relaxed text-pretty md:text-base"> + {data.body} + </p> </aside> ) @@ -43,6 +59,27 @@ export const calloutWidget = defineWidget({ `WidgetData<typeof calloutFields>` is the inferred data type: `title` and `body` are `string`, `tone` is `"info" | "success" | "warning" | undefined` because it has a default. Nothing is written twice. +## Register it + +A definition does nothing until the plugin exports it. List it in `src/widgets.tsx`, export that object as **`blocks`** (the name VitNode's Vite plugin imports), and pass the same object to `buildApiPlugin({ blocks })` so the API can validate writes: + +```tsx title="plugins/newsroom/src/widgets.tsx" +import type { WidgetPluginSource } from '@vitnode/core/widgets' + +import { manifest } from '@/manifest.gen' + +import { calloutWidget } from './widgets/callout' + +export const widgets = { + pluginId: manifest.id, + blocks: [calloutWidget], +} satisfies WidgetPluginSource + +export const blocks = widgets +``` + +[First widget](/docs/dev/widgets/end-to-end#give-the-api-the-same-list) shows the `buildApiPlugin` side. A widget missing from either place does not render in the app, or is refused on every write. + ## Give it an icon `icon` is the picture people see next to the widget's name in the [Visual Editor](/docs/dev/widgets/visual-editor): on its card in the catalogue, on the card that follows the pointer while it is dragged, and at the top of its properties panel. Pass the icon **component**, not an element - `icon: InfoIcon`, not `icon: <InfoIcon />` - so the editor can size and colour it to match everything around it. @@ -95,23 +132,23 @@ export const featuresWidget = defineWidget({ }) ``` -The chosen variant is stored **beside** the instance's data, never inside it, and the editor grows a picker for it with no further work. See [Block Variants](/docs/dev/widgets/block-variants) for the resolution rules and what happens to a stored variant a block stops offering. +The chosen variant is stored **beside** the instance's data, never inside it, and the editor grows a picker for it with no further work. See [Variants](/docs/dev/widgets/block-variants) for the resolution rules and what happens to a stored variant a widget stops offering. ## Ids are namespaced for you Write `id: "callout"`. The registry prefixes the namespace of the plugin that registered it, derived from the **last segment** of the package name: -| Plugin | Block id | Stored as | +| Plugin | Widget id | Stored as | | :------------------- | :------------- | :--------------------- | | `@vitnode/core` | `hero` | `core:hero` | | `@vitnode/blog` | `latest-posts` | `blog:latest-posts` | | `@acme/page-builder` | `pricing` | `page-builder:pricing` | -Writing the namespace yourself is an error - a block would otherwise be addressable under two names, and content written under the wrong one would be unrenderable. +Writing the namespace yourself is an error. A widget would otherwise be addressable under two names, and content written under the wrong one would be unrenderable. -Two packages whose names end in the same segment (`@vitnode/blog` and `@acme/blog`) collide. The registry refuses to build and names both; set `namespace` on one of their `blocks` modules to settle it. +Two packages whose names end in the same segment (`@vitnode/blog` and `@acme/blog`) collide. The registry refuses to build and names both; set `namespace` on one plugin's widgets object (`{ pluginId, namespace: 'acme-blog', blocks: [...] }`) to settle it. -## Which fields a block may hold +## Which fields a widget may hold | Kind | Supported | Why | | :------------------------------------------------- | :-------- | :-------------------------------------------------------------------------------------------------------------------------------------------------- | @@ -121,16 +158,17 @@ Two packages whose names end in the same segment (`@vitnode/blog` and `@acme/blo | `field.group()` | ✅ | Nested objects are ordinary JSON. | | `field.file()`, `field.relation()`, `field.user()` | Not yet | A reference is a row identifier the owning record has to pin against deletion, and a value inside a JSON document has nowhere to put a foreign key. | | `field.repeatable()` | Not yet | Its rows are rows on a generated child table, identified by a database id a JSON document does not have. | -| `field.slug()` | No | A slug is a record's address, and a block is not addressable on its own. | -| `field.blocks()` | Not yet | Nested blocks need depth limits, their own allowlist and cycle detection first. | +| `field.richText()` | Not yet | Not one of the supported kinds yet. | +| `field.slug()` | No | A slug is a record's address, and a widget is not addressable on its own. | +| `field.container()` | No | A container is where a whole record is filed. A widget is part of a record. | +| `field.blocks()` | Not yet | Nested widgets need depth limits, their own allowlist and cycle detection first. | -Every one of those rules lives in one table, `blockFieldKindRefusal`, and the error a refused field produces quotes the reason from it. - -<Callout title="Media and relations are a limitation of today, not of the model"> - The serialized shape - `{ id, type, data }`, with `data` holding this field map's values - does not change when these land: a `file` field would store a `core_files.id` exactly as a file *column* does. What is missing is the lifecycle around it. A content column pins its file with `ON DELETE RESTRICT`, which is what makes the Files screen answer 409 instead of leaving a published page pointing at bytes that are gone; a value inside JSONB has no foreign key to carry that. - -The intended solution is to extract a widget's references out of its data on write - the field map already says which leaves are references - and pin them in a reference table the same way, so deletion, cleanup and `onDelete` keep working. Until that exists, storing a bare id in JSON would be a foreign key with none of the guarantees of one, which is worse than not supporting it. Put the image on the record for now and let the widget choose how to present it. +`defineWidget` throws for a refused field, quoting the reason from `blockFieldKindRefusal`. A widget also needs at least one field. +<Callout title="Need an image in a widget?"> + Put the image on the record for now and let the widget choose how to present + it. A file id inside widget JSON would have no foreign key protecting it from + deletion, which is why `file`, `relation` and `user` fields wait. </Callout> ## Localization @@ -140,9 +178,11 @@ A widget's **fields** are never `localized: true`. There is no translation row f The **zone** is what translates: ```ts -content: field.widgets({ localized: true }) +content: field.blocks({ localized: true }) ``` +`field.widgets()` is an alias of `field.blocks()`; both build the same field. + This is document-level localization, not translated field values: each language gets its own widgets, in its own order, and the two need not correspond at all. ```text @@ -150,9 +190,7 @@ en: Hero → Text → CTA pl: Hero → CTA → Gallery ``` -See [The blocks() field](/docs/dev/widgets/blocks-field#localization). - -Editing a widget's text in the [properties panel](/docs/dev/widgets/visual-editor#the-properties-panel) changes nothing about this. What somebody types goes into the `data` of the snapshot the page mounted for the locale it is showing, and it is saved by whoever already owned that locale's zone. +See [Blocks field](/docs/dev/widgets/blocks-field#localization). Text typed in the [visual editor](/docs/dev/widgets/visual-editor#edit-a-widget) goes into the zone of the language the page is showing. ## Validating data by hand diff --git a/apps/web/content/docs/dev/widgets/editable-pages-saved.webp b/apps/web/content/docs/dev/widgets/editable-pages-saved.webp new file mode 100644 index 0000000000..ca6e07f6e7 Binary files /dev/null and b/apps/web/content/docs/dev/widgets/editable-pages-saved.webp differ diff --git a/apps/web/content/docs/dev/widgets/editable-pages.mdx b/apps/web/content/docs/dev/widgets/editable-pages.mdx index 23a6f17264..110a582115 100644 --- a/apps/web/content/docs/dev/widgets/editable-pages.mdx +++ b/apps/web/content/docs/dev/widgets/editable-pages.mdx @@ -1,6 +1,6 @@ --- -title: Editable Pages -description: Declare a page's zones once, and the people who run the site rearrange them on the page itself - Edit widgets from the user menu, drag, Save, done. No admin screen, no zone-to-column mapping, no hand-written save route. +title: Editable pages +description: Make a VitNode page editable in the browser. Declare its widget zones once with defineEditablePage, register it with a moderator permission, and render it with PageWidgets so moderators get Edit widgets in the user menu. icon: PanelsTopLeft implementation: objective: Let moderators place and arrange widgets on an existing public page. @@ -11,443 +11,206 @@ implementation: - Who may arrange the page? Offer existing moderator permissions in the plugin, or a new one. - Should an area show default widgets before anyone edits it? constraints: - - Declare the page with `defineEditablePage` using a stable, hand-written id, and register it in `editablePages` of `buildApiPlugin()` together with its moderator permission. - - Render `EditablePage` around one `ContentZone` per area, and save through `createContentEditorAdapter`. Write no save route of your own. - - If reading the saved layout fails, the loader throws instead of rendering the defaults. + - Declare the page with `defineEditablePage` using a stable, hand-written id, and set `permission.plugin` to the plugin's manifest id. + - Add `editablePages` and `permissionStaff.moderator` to the existing `buildApiPlugin()` call, keeping its modules and any existing `permissionStaff` keys, with labels in the plugin's `src/locales/en.json`. + - Warm the layout with `loadPageWidgets(context.queryClient, page)` in a `defineRoute` loader, and wrap the zones in `PageWidgets`. Write no save route, adapter or permission check of your own. + - If a zone allows the plugin's own widgets, render it as `ContentZone` with a registry from `createBlockRegistry` instead of `PageWidgetsZone`. related: - - title: Content Zones + - title: Content zones href: /docs/dev/widgets/content-zones - - title: Defining a widget - href: /docs/dev/widgets/defining-a-block - - title: Visual edit mode + - title: Restrict widgets + href: /docs/dev/widgets/restrict-widgets + - title: Visual editor href: /docs/dev/widgets/visual-editor --- -A settings page is edited on the settings page. A forum index is edited on the forum index. Nobody opens an admin screen to arrange a page they cannot see. +import { ImgDocs } from "@/components/fumadocs/img"; +import savedLayout from "./editable-pages-saved.webp"; -```text -the real page - ↓ Edit widgets ← in the user menu, only if you hold the page's permission -the same page, editable - ↓ Save - ↓ Finish editing -the real page again -``` - -That is the whole feature. What you write to get it is one page declaration, one line of registration, and `<EditablePage>` around the zones you already had. **The button is not yours to draw** - it lives in the user menu in the site header, on every page, and appears there by itself the moment an editable page somebody may edit is on screen. - -{/* Image prompt: A clean left-to-right technical diagram on a dark background. Three labelled boxes joined by arrows. First: "defineEditablePage" showing a small list of zone names (before-profile, sidebar, after-profile) each with a tiny allowlist pill beside it. Second: a miniature browser window titled "/example/zones" with two dashed zone outlines around a solid form, and a small outlined button in the corner reading "Edit widgets". Third: a database icon labelled "core_page_layouts" with one row shape beside it, split into a "pageId" cell and a wider "zones JSONB" cell holding two small zone chips. A thin annotation under the middle box reads "one declaration, three readers". Blue accent on the arrows, neutral grey boxes, 1600x600. */} - -## The whole thing, in three files - -### 1. Declare the page - -```ts title="plugins/example/src/content/settings-page.ts" -import { defineEditablePage } from '@vitnode/core/content/editor' - -import { PAGE_BLOCKS_ALLOWED, PAGE_SIDEBAR_BLOCKS_ALLOWED } from './page-blocks' - -export const settingsPage = defineEditablePage({ - id: 'example:settings', - - permission: { module: 'widgets', permission: 'can_edit' }, - - zones: { - 'before-profile': { - allowed: PAGE_BLOCKS_ALLOWED, - max: 20, - default: [ - { - id: '01JEXAMPLEZONESBEFORE00001', - type: 'example:callout', - data: {/* … */}, - }, - ], - }, - 'after-profile': { allowed: PAGE_BLOCKS_ALLOWED, max: 20 }, - sidebar: { allowed: PAGE_SIDEBAR_BLOCKS_ALLOWED, max: 20 }, - 'before-footer': { allowed: PAGE_BLOCKS_ALLOWED, max: 20 }, - }, -}) -``` +An editable page is an ordinary page whose [content zones](/docs/dev/widgets/content-zones) moderators arrange on the page itself: **Edit widgets** in the user menu, drag, **Save**. You declare the zones once; core stores the layout, checks the permission on every save and draws the menu entry. This guide makes the `site-notes` plugin's notes page editable, with a zone above and below the list of notes. -| | | | -| :----------- | :------- | :---------------------------------------------------------------------------------------------------------------------------------- | -| `id` | required | The page's stable identity, `plugin:page`. The stored layout is keyed by it, so it is written once by hand and never generated. | -| `permission` | required | The **moderator** permission somebody needs before the editor will offer to open. `plugin` defaults to whoever registered the page. | -| `zones` | required | One entry per place on the page blocks may go. At least one. | +| Core provides | You write | +| :-------------------------------------------------------------------- | :---------------------------------------------- | +| The editor, loaded on the first click, and the **Edit widgets** entry | The page declaration: id, zones, permission | +| `GET` and `PUT /pages/layout`, and the `core_page_layouts` table | Two lines of registration in `buildApiPlugin` | +| The permission check on every save, and conflict detection | One line in the route loader, and `PageWidgets` | -And per zone: +## Before you begin -| | | -| :------------ | :---------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `allowed` | The block allowlist, or `"*"`. Declared **here**, so the page never repeats it. | -| `max` / `min` | How many **blocks** the zone accepts. An area counts nothing itself; the blocks inside it each count one. Leaving `max` out is not "unlimited" - see below. | -| `default` | The layout the zone ships with, as real stored nodes. | +- A plugin page to make editable. This guide uses `/site-notes/notes` from [Plugin page](/docs/dev/plugins/page). +- An account with full access, such as the first account, to try the editor. Anyone else needs the moderator permission this guide adds. -#### Three limits, and they count different things +<Steps> -Leave `max` out and the zone still has one: **200**, the `CONTENT_BLOCKS_DEFAULT_MAX` a [`blocks()` field](/docs/dev/widgets/blocks-field) already defaults to, because a zone and a field are stored and validated by the same code. Omitted means "the usual ceiling", never "as many as you like". +<Step> -| Limit | Value | Counts | -| :---------------------------- | :--------------------------------------------- | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| A zone's `max` / `min` | `200` when `max` is omitted, `0` when `min` is | **Block instances.** A block inside an [area](/docs/dev/widgets/layout-areas) counts one; the area holding it counts nothing, so an empty area counts as nothing at all. | -| `CONTENT_BLOCKS_ABSOLUTE_MAX` | `1000` | **Nodes in the zone's root list** - blocks and areas alike, children not included. It is the ceiling the stored column can be read back through, so a zone's own `max` may lower it and nothing may raise it. | -| `AREA_CHILDREN_DEFAULT_MAX` | `50` | **One area's children.** A third limit, checked per area, unrelated to the two above. | +### Declare the page -All three are exported from `@vitnode/core/widgets`, and a zone that declares a `min` above its effective `max` is refused where it is declared rather than the first time somebody saves. +Create `src/editable-pages/notes-page.ts`. It names the page, its zones and who may arrange them: -Zone ids are **page-local**: `"sidebar"`, not `"settings:sidebar"`. The page id already namespaces them - it is what the stored layout is keyed by, and the zone ids are the keys inside it. +```ts title="plugins/site-notes/src/editable-pages/notes-page.ts" +import { defineEditablePage } from "@vitnode/core/content/editor"; -<Callout title="The defaults are content, not a placeholder"> - A zone nobody has customised renders its `default`, and the read that serves - it says so with `updatedAt: null`. That is the layout you shipped - it is what - a visitor sees before anybody has touched the page, and what a future *reset* - will restore to. So it lives in code rather than being seeded into the - database, and the first **Save** is entitled to write over it. -</Callout> +import { manifest } from "@/manifest.gen"; -The definition module is a plain value with **no third-party import anywhere in its graph**, which is what lets the API and a public page import the same file. Ask for a zone it does not declare - on the page, in a save, anywhere - and it throws with the zone ids it does declare. +export const notesPage = defineEditablePage({ + id: "site-notes:notes", -### 2. Register it - -```ts title="plugins/example/src/config.api.ts" -buildApiPlugin({ - manifest, - // [!code ++:4] - editablePages: [settingsPage], - permissionStaff: { - moderator: { widgets: ['can_edit'] }, + permission: { + plugin: manifest.id, + module: "widgets", + permission: "can_edit", }, - modules: [adminModule /* … */], -}) -``` -Two lines, and both are needed for different reasons. `editablePages` is what makes the page id resolvable on the server: without it, a read is a `404` and a save is a `400`. `permissionStaff.moderator` is what puts **Widgets → can edit** in front of somebody granting it - a permission nobody can grant is a permission nobody holds. - -Registering one page id twice, or two plugins claiming the same id, is a startup error rather than a race about which loaded last. So is declaring a page whose permission is missing from that plugin's `permissionStaff.moderator` catalogue: the server would boot, the page would read fine, and nobody outside a root role could ever edit it - which is a thing you want to hear at boot rather than from somebody who cannot find the button. - -### 3. Render it - -```tsx title="plugins/example/src/pages/zones-page.tsx" -// [!code ++:4] -<EditablePage - adapter={adapter} - canEdit={canEdit} - layout={layout} - openEditing={search.edit === true} - page={settingsPage} -> - <ContentZone id="before-profile" registry={blocksRegistry} /> - <ProfileForm /> - <ContentZone - as="aside" - className="w-full lg:w-64" - id="sidebar" - registry={blocksRegistry} - /> -</EditablePage> + zones: { + "before-notes": { allowed: ["core:*"], max: 10 }, + "after-notes": { allowed: ["core:*"], max: 10 }, + }, +}); ``` -`<ContentZone id="sidebar" />`. That is the entire zone. No `blocks`, because the provider holds the layout. No `allowedBlocks`, because the provider holds the allowlist. On a page that declared its zones there is nothing left to pass. - -#### What a call site may still say - -You can pass those props anyway, and they still do something - just not _anything_. The page declaration is what the server checks a save against, so a zone on that page can be narrowed at the call site and never widened. - -| At the call site | | -| :--------------- | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `blocks` | **Overrides the source.** For a zone whose content genuinely comes from somewhere else, the array you pass is what renders. | -| `allowedBlocks` | **Only narrows.** It is intersected with the page's allowlist, so `"*"` here cannot open a zone the page restricted, and asking for blocks the page does not allow is a developer error rather than a quietly wider zone. | -| `min` / `max` | **Only tighten.** The higher `min` and the lower `max` win. Ask for a combination no list of blocks could satisfy - a `min` above the `max` - and it throws where you wrote it. | +| Option | Required | Description | +| :----------- | :------- | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `id` | yes | The page's permanent identity, `plugin:page`. The stored layout is keyed by it, so write it by hand and never change it. | +| `permission` | yes | The **moderator** permission that unlocks editing. Set `plugin`. The server falls back to the registering plugin, but the browser cannot, and `PageWidgets` never offers editing without it. | +| `zones` | yes | One entry per zone, keyed by a page-local zone id. At least one. | -The page stays the ceiling and the floor. That is what makes "the zone as declared" the one thing both the editor and the save route have to agree on, and it is why an intersection is refused rather than silently emptied: a zone allowing nothing at all is a page nobody could edit. +Each zone takes `allowed` (the [allowlist](/docs/dev/widgets/restrict-widgets), default `"*"`), `max` (default `200`, counting widgets, not areas), `min`, and `default`, the widgets the zone shows until somebody saves it, written as real stored nodes like `{ id, type, data }`. Without `default`, a zone starts empty and renders nothing. -A **standalone** `<ContentZone>` - one no editable page declares - is unaffected. There is nothing to intersect with, so its own `allowedBlocks`, `min` and `max` are simply its own. +The file imports nothing heavy, so the API and the page can both import it. -| Prop | | | -| :------------ | :------- | :--------------------------------------------------------------------------------------------------- | -| `page` | required | The definition. It is what resolves each zone's blocks and allowlist. | -| `layout` | optional | What the loader read. `null` renders every zone's shipped default. | -| `canEdit` | optional | `true` mounts the editor runtime and offers **Edit widgets** in the user menu. `false` does neither. | -| `openEditing` | optional | Open in edit mode on arrival - what `?edit=true` asks for. Ignored unless `canEdit`. | -| `adapter` | optional | Where **Save** goes. `createContentEditorAdapter` builds one. | -| `onExit` | optional | Called by **Finish editing**, after the unsaved-changes prompt. Edit mode ends either way. | +</Step> -Edit mode itself is **not a prop**. The page that is editable and the action that opens it are in two different parts of the document - one in `<main>`, the other in the header - so neither may own the state between them. `<EditablePage>` publishes what it is and whether this visitor may edit it; the site shell holds "is somebody editing", answers the user menu with it, and reserves the sidebar's room from the whole page while it is true. Walk away from the page and the offer leaves with it, so the entry is never there for a page that has no zones. +<Step> -`EditablePage` also adopts the canonical **zones** a save comes back with, so the editor rebases onto what the server actually stored. View mode shows the stored result the moment you press **Finish editing**, with no reload and no second request. The metadata around those zones - `updatedAt` and friends - belongs to the page that owns the layout, not to the editor: hold the payload the save answered with in your own state, and a "Last rearranged 2 minutes ago" line moves with it instead of sitting on the timestamp the loader happened to read. Hand `EditablePage` a layout belonging to a different page id and it throws, because rendering one page's blocks under another page's zones is how a save writes them onto the wrong page. +### Register it -## Loading it +In `src/config.api.ts`, add two keys to the existing `buildApiPlugin` call: the page in `editablePages`, and its permission under `permissionStaff.moderator`. After [Plugin page](/docs/dev/plugins/page) the call looks like this; keep your `modules` as they are: -```ts title="plugins/example/src/pages/zones-page.tsx" -const [stored, staff] = await Promise.all([ - fetcher({ - plugin: '@vitnode/core', - method: 'get', - module: 'pages', - path: '/layout', - args: { query: { pageId: settingsPage.id } }, - }), - fetcher({ - plugin: '@vitnode/core', - method: 'get', - module: 'users', - path: '/permissions', - }), -]) +```ts title="plugins/site-notes/src/config.api.ts (excerpt)" +import { notesPage } from "./editable-pages/notes-page"; // [!code ++] -if (!stored.ok) { - throw new Error(`Reading this page's layout answered ${stored.status}…`) -} -``` - -Two public reads, one round trip. The first is the layout; the second is who is looking. - -The `if` is the important line. "Nothing is stored" and "I could not read what is stored" are two different answers and must never look alike: the first is a `200` full of shipped defaults, the second is a throw. Render something plausible in place of a failed read and the next **Save** writes it over whatever the page really held. - -## Saving it - -```tsx -const adapter = useMemo( - () => - createContentEditorAdapter({ - page: settingsPage, - save: async (payload) => { - const response = await fetcher({ - plugin: '@vitnode/core', - method: 'put', - module: 'pages', - path: '/layout', - args: { body: payload }, - }) - - if (!response.ok) throw new Error(`Saving answered ${response.status}.`) - - return zodLayout.parse(await response.json()) - }, - }), - [], -) -``` - -The adapter turns the editor's snapshot into a payload carrying **only the zones that changed**, refuses up front to send a zone the page does not declare, and hands the server's canonical answer back so the editor rebases onto what was _stored_ rather than onto what it sent. - -```ts -interface EditablePageSavePayload { - pageId: string - zones: Record<string, ContentNode[]> // what you want stored - expectedZones: Record<string, ContentNode[]> // what you were editing -} +export const siteNotesApiPlugin = () => + buildApiPlugin({ + manifest, + modules: [helloModule, notesModule], + editablePages: [notesPage], // [!code ++] + // [!code ++:3] + permissionStaff: { + moderator: { widgets: ["can_edit"] }, + }, + }); ``` -`expectedZones` is the second half of every save: for each zone being written, the value the editor started from. The server compares it with what is actually stored before touching anything, which is what turns "last save wins" into "the save that knew what it was replacing wins". The adapter fills it in from the editor's own baseline - you do not assemble it. - -<Callout title="Two files, and neither of them the editor"> - `createContentEditorAdapter` produces a `VisualEditorAdapter`, but it needs - nothing from the editor to do it. So it ships beside the page definition, its - whole graph is two modules with no third-party package in it, and a plain - `import` keeps the example plugin's boundary test green: nothing under core's - `src/editor/` is in the page's eager graph. The `@vitnode/core/editor/adapter` - barrel, by contrast, is thirty-three modules and Zod. -</Callout> +If your call already has `permissionStaff`, for example `admin: { notes: ["can_create"] }` from the [Permissions](/docs/dev/working-with-users/staff-permissions) lesson, add the `moderator` key inside it instead of writing a second `permissionStaff`. -## The two routes +Then label the permission in `src/locales/en.json`. Add the two permission keys at the root, next to the `site-notes` object, and a `title` inside that object if it has none yet; the AdminCP shows it as the plugin's name. Keep the keys you already have: -They are core's, not your plugin's, and they are addressed by page id. - -```http -GET /api/vitnode/core/pages/layout?pageId=example:settings public -PUT /api/vitnode/core/pages/layout that page's permission -``` - -```ts -interface EditablePageLayoutPayload { - pageId: string - updatedAt: string | null // null: nothing stored, these are the defaults - zones: Record<string, ContentNode[]> +```json title="plugins/site-notes/src/locales/en.json (excerpt)" +{ + "site-notes": { + "title": "Site Notes" + }, + "site-notes:widgets": "Widgets", + "site-notes:widgets:can_edit": "Edit page widgets" } ``` -`GET` is public, because a visitor renders the page from it. It answers with the **effective** layout - the shipped defaults with whatever has been customised laid over them - so the page renders what it is handed and resolves nothing itself. The defaults it sends have been through the same block schemas a saved zone goes through, so a zone still on its default arrives in the same shape as one somebody has edited, fields the block fills in and all. Hand `EditablePage` the layout from this route and it never has to guess: the `default` you declared is a fallback for a zone the server did not send, and it has not been through those schemas. A page id nobody registered is a `404`; a registered page nobody has rearranged is a `200` holding the declared defaults and `updatedAt: null`. - -`PUT` takes `{ pageId, zones, expectedZones }` with only the zones that changed, and answers with the canonical stored value of **those zones only** - the same `EditablePageLayoutPayload`, `updatedAt` and all, which is what the page adopts so its own layout metadata stays current. The mapping from a zone id to somewhere to store it comes from the registered page, never from the body, so there is no shape of request that reaches a zone the page did not declare. A body that writes a zone without a baseline for it is a `400`: without one the server cannot tell an edit from an overwrite. - -After validation the write is one read, one comparison, one merge and one upsert, whatever number of zones the save named. The block validator is the engine's own; the event - `core.page-layout.updated`, carrying the page id and the zones that changed - is emitted after the commit rather than inside it; and a save that changed nothing stores nothing, bumps no `updatedAt` and emits nothing, while still answering canonically. - -### Two moderators, one page - -The comparison is **per zone**, inside the same transaction that writes, which is what lets an ordinary afternoon work out: - -| | | -| :------------------------------ | :--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Two people, **different zones** | Both saves land. One writes the sidebar, the other the footer, and each leaves the other's zone exactly as it found it. Zones merge because a save only ever writes what it names. | -| Two people, **the same zone** | The second one gets a `409` and **nothing is written**. The zone moved after their editor read it, so storing their copy would delete a change they never saw. | - -A `409` is not a lost afternoon: the refusal says which zones moved, nothing that was arranged is thrown away, and reloading the page picks up what is really there to redo the change on top of it. Nothing polls and nothing locks - the baseline travels with the save, so the only thing that can ever be overwritten is a zone whose stored value is still the one being edited. - -## "Can I edit this?" - -The page asks the existing public route: +Without `editablePages`, reading the layout answers `404`. A page whose permission is missing from `permissionStaff.moderator`, or a page id registered twice, stops the API at startup with a message naming the fix. -```http -GET /api/vitnode/core/users/permissions → { root, permissions[] } -``` - -`root` is a role that holds everything and lists nothing, so it counts as yes on its own. Otherwise the page looks for its own `{ plugin, module, permission }` in the list. +</Step> -<Callout title="Why a moderator permission and not an admin one" type="warn"> - An admin-type check reads `c.get("admin")`, and only `globalAdminMiddleware` - populates it. On a public page that middleware never ran, so - `checkStaffPermission(c, { type: "admin", … })` does not merely answer *no* - it - answers no **structurally**, for everybody, including the person who genuinely - administers the site. A moderator permission reads `c.get("user")`, which a public - request does have. That is why `permission` on a page definition is a - `permissionStaff.moderator` entry, and why `PUT /pages/layout` asserts it as one. -</Callout> +<Step> -`?edit=true` may **request** edit mode - it is what a link hands you, and what `openEditing` passes on - and it never authorizes it. The page only opens in edit mode when the permission agrees, the **Edit widgets** entry is absent from the user menu otherwise, and the save asserts the permission again server-side whatever the URL said. +### Load the layout in the route -## Where a layout is stored +The loader warms the page's layout and the viewer's permissions, so the zones render on the server. `loadPageWidgets` needs the query client, which `defineRoute` from `@vitnode/core/tanstack/plugin-routes` hands the loader. In `src/pages/notes-page.tsx`, replace `definePluginRoute`: -In core, in one ordinary table: +```tsx title="plugins/site-notes/src/pages/notes-page.tsx (excerpt)" +import { defineRoute } from "@vitnode/core/tanstack/plugin-routes"; // [!code ++] +import { loadPageWidgets } from "@vitnode/core/tanstack/widgets"; // [!code ++] -```ts -core_page_layouts - pageId varchar primary key - zones jsonb not null default '{}' - createdAt timestamp - updatedAt timestamp -``` +import { notesPage } from "@/editable-pages/notes-page"; // [!code ++] -**One row per page, holding overrides only.** `zones` is a `Record<zoneId, ContentNode[]>` carrying just the zones somebody has actually rearranged. What the page renders is the two halves put together: +export const route = defineRoute<NotesPageData>({ + load: async ({ context }) => { + const [notes] = await Promise.all([ + loadNotes(), + loadPageWidgets(context.queryClient, notesPage), // [!code ++] + ]); -```ts -effective[zoneId] = Object.hasOwn(stored.zones, zoneId) - ? stored.zones[zoneId] - : zone.default + return notes; + }, +}); ``` -Defaults live in code; the database stores the custom ones. A page nobody has touched has **no row at all** - which is exactly what `updatedAt: null` means. Save a zone back to precisely what it ships with and its key is dropped again; empty the object and the row goes with it. A reset is therefore a delete rather than a re-seed, and the shipped layout is written down in exactly one place. - -A stored zone the page no longer declares is **ignored for rendering but not deleted**. Only declared zones are read, so nothing renders it, and no ordinary save touches it: take a zone off a page for one release, put it back in the next, and what was stored for it is still there. - -None of this is a Content Engine content type. No publication, no search indexing, no generated CRUD, no AdminCP screens. Layout persistence is **internal infrastructure** - you do not browse it, any more than you browse the sessions table. - -<Callout title="There is no AdminCP → Layouts screen in V1, on purpose"> - Central layout administration - listing the pages somebody has customised, - resetting one to its shipped defaults, exporting a layout as JSON, copying one - page's layout onto another - is deliberately left to a possible **V2**. Each - of them is a query on one table keyed by `pageId`: list is `select "pageId"`, - reset is a `delete`, export is a single row. The shape makes them easy later; - none of them is built now, and none of them is needed to arrange a page. -</Callout> +`loadPageWidgets` never throws. If the layout cannot be read, the page still renders, without its zones and without **Edit widgets**, so nobody can save empty zones over the real ones. -Every submitted zone is re-validated against that zone's own `allowed`, `min` and `max`, read off the **registered page** rather than off the request, before anything is stored. +</Step> -## When a save is refused +<Step> -Every refusal is named, and none of them falls back to a default or silently drops content. +### Wrap the zones in PageWidgets -| | | -| :--------------------------------- | :-------------------------------------------------------------------------------------------------------------------------- | -| A page id nobody registered | `400`, saying there is nothing this save could be writing. | -| A zone the page does not declare | `400`, listing the zones it does. | -| No zones at all | `400`. A save names what it changed, and this one named none. | -| A zone written without a baseline | `400`. Every zone in `zones` needs its counterpart in `expectedZones`. | -| A zone somebody else changed first | `409`, naming the zones that moved. Nothing is written - not even the zones that would have been fine. | -| A block the zone does not allow | `400`, from the zone's own `allowed` - nested inside an area too. | -| Too many or too few blocks | `400`, from the zone's own `max` and `min`, counting an area's children. | -| The permission is not held | `403`. | -| The stored layout cannot be read | `500`. A read that fails is never answered with defaults: the next **Save** would write them over whatever is really there. | +In the same file, wrap the page in `PageWidgets` and put a `PageWidgetsZone` wherever a declared zone goes: -The client has its own guard before any of this: a zone holding a value the editor cannot read keeps **Save** disabled until somebody removes it deliberately. See [unreadable content](/docs/dev/widgets/visual-editor#content-the-editor-cannot-read). +```tsx title="plugins/site-notes/src/pages/notes-page.tsx (excerpt)" +import { PageWidgets, PageWidgetsZone } from "@vitnode/core/tanstack/widgets"; // [!code ++] -## What this costs a public page +const NotesPage = ({ loaderData: notes }: PluginRoutePageProps<NotesPageData>) => { + const t = useTranslations("site-notes.notes"); -The page definition is a module a public page imports, so it was measured rather than asserted. Every number is the import graph of a real file in this repository. + return ( + <PageWidgets page={notesPage}> + <main className="container mx-auto flex max-w-2xl flex-col gap-6 p-4"> + <h1 className="text-2xl font-semibold tracking-tight text-balance">{t("title")}</h1> -| Entry | Source files | Third-party | -| :-------------------------------------------- | -----------: | :---------- | -| `@vitnode/core/content/editor` | 12 | none | -| `createContentEditorAdapter` alone | 2 | none | -| `EditablePage` (`blocks/page.tsx`), eagerly | 15 | `react` | -| `ContentZone` (`blocks/zone.tsx`) | 19 | `react` | -| **The example's page definition, end to end** | **15** | **none** | -| `@vitnode/core/content/define` | 34 | `zod` | -| `@vitnode/core/content` (the barrel) | 54 | `zod` | -| `@vitnode/core/editor/adapter` (the barrel) | 33 | `zod` | + <PageWidgetsZone id="before-notes" /> -A module whose whole graph has no external specifier cannot add a package to anything that imports it. That is the entire argument for keeping the declaration off the content type: a page gets its zone ids, allowlists and defaults without the schema builder that turns field descriptors into Zod. + {/* the list of notes, unchanged */} -The same rule applies to blocks. `plugins/example/src/blocks/callout.tsx` used to import `field` from `@vitnode/core/content`; switching two import paths to `@vitnode/core/content/fields` took the playground's eager graph from **132 modules to 107**, and the Content Engine modules it reaches from **41 to 16**. The rendered page was byte-for-byte identical. - -```ts -import { field } from '@vitnode/core/content' // [!code --] -import { field } from '@vitnode/core/content/fields' // [!code ++] + <PageWidgetsZone id="after-notes" /> + </main> + </PageWidgets> + ); +}; ``` -Two boundary tests keep it that way. Core's own names every module `@vitnode/core/content/editor` reaches and asserts it reaches no third-party one; the example plugin's walks the **real page** and asserts nothing under core's `src/editor/` - and no drag-and-drop, command palette or database package - is in its eager graph, while following its dynamic imports does reach `@dnd-kit/core`, so the guarantee is not vacuous. - -## `<PageWidgets>`: the loading, the saving and the permission, once +`PageWidgets` reads the layout and the permissions the loader warmed, builds the save adapter and keeps the cache up to date after each save. Pass `openEditing` to honour an `?edit=true` link; it only opens the editor for somebody who already may edit. -Everything above is the contract. You rarely write it, because core owns the two routes a layout is read and written through - so core also owns talking to them. +`PageWidgetsZone` renders a [`ContentZone`](/docs/dev/widgets/content-zones) with a `flex flex-col gap-6` wrapper and the default widget registry, which always holds core's widgets. If a zone allows your plugin's own widgets, render `<ContentZone id="before-notes" registry={blocksRegistry} />` inside `PageWidgets` instead, with a registry from [`createBlockRegistry`](/docs/dev/widgets/rendering#where-the-registry-comes-from), so the server and the browser render the same widgets. -```tsx title="Any page that declares its zones" -import { loadPageWidgets, PageWidgets, PageWidgetsZone } from '@vitnode/core/tanstack/widgets' +</Step> -export const route = defineRoute({ - load: async ({ context }) => await loadPageWidgets(context.queryClient, settingsPage), -}) - -const Page = () => ( - <PageWidgets page={settingsPage}> - <PageWidgetsZone id="header" /> - <ProfileForm /> - <PageWidgetsZone id="footer" /> - </PageWidgets> -) -``` +<Step> -That is the whole integration. No `fetcher` call, no `createContentEditorAdapter`, no permission comparison and no registry plumbing: `PageWidgets` reads the layout and the viewer's moderator permissions, builds the adapter, folds each save's canonical answer back into the cache, and hands every `PageWidgetsZone` the right blocks and the application's block registry. It takes the page definition and nothing else - which is why the definition names its permission's `plugin` rather than leaning on the server-side default, so the browser can answer "may I edit this?" without knowing who registered the page. +### Grant the permission -`loadPageWidgets` is the one line a **route** writes. A zone never fetches and a loader is where this framework reads, so the warm belongs there - and neither query rejects, so a widget area that cannot be read costs the page it decorates nothing. +Members with full access hold every permission already. For anyone else, open **Staff → Moderators** in the AdminCP (`/admin/core/staff/moderators`), choose **Add Moderator**, pick a role or a user and choose **Add member**. Then edit that moderator's permissions, choose **Site Notes** and tick **Edit page widgets** under **Widgets**. -<Callout title="When to reach past it"> - Write the loader and adapter by hand when the page is not read from core's own - layout route - zones stored on your own record, a different API, a preview of - something unsaved. The sections above are that contract, and `<EditablePage>` - is still what you wrap them in. -</Callout> +</Step> -## Core ships one: the account settings page +</Steps> -`/settings` - the account settings screen every member of every VitNode install visits - declares two zones of its own, and is the reference for the three lines above: +## Check the result -| Zone | Where it sits | -| :------- | :-------------------------------------------------- | -| `header` | Under the "Settings" heading, above the two panels. | -| `footer` | Below the panels, above the site footer. | - -Both take **every installed block**, so a plugin's own block can go there too, and both cap at ten. They ship empty: an install nobody has touched renders no extra markup at all, and the zones are invisible until somebody puts something in them. - -```text -Settings ← the page heading - header zone ← "Two-factor sign-in becomes required on 1 October." - nav | panel ← application UI, not editable - footer zone ← "Need a hand? Contact support" -``` +Sign in with an account that may edit and open `http://localhost:3000/site-notes/notes`. Nothing looks different yet, because both zones are empty and render nothing. Open the user menu and choose **Edit widgets**. The **Widgets** sidebar slides in and each zone shows **Drag and drop widgets here** with an **Add widget** button. Add a **Text** widget, fill in its body and choose **Save**; a **Page saved** toast confirms it. **Finish editing**, reload, and the text is still there. -Rearranging them is gated on the moderator permission `@vitnode/core` → **Widgets** → `can_edit`, grantable from **Staff → Moderators** in the AdminCP. Hold it (or a root role) and **Edit widgets** appears in the user menu while you are on `/settings`, exactly as it does on a page your own plugin declared. +<ImgDocs + withoutBackground + className="*:max-w-full" + src={savedLayout} + alt="The site-notes notes page in edit mode right after saving: a Text widget in the Before notes zone above the two sample notes, the empty After notes zone with Drag and drop widgets here, the widget's body in the editor sidebar, a Page saved toast and Saved in the sidebar footer" +/> -<Callout> - A zone renders from the block registry your application generated, so every plugin you configured is offered. In an install that has never rendered a block anywhere else, core's own `core:text`, `core:cta` and `core:hero` are offered instead of nothing. -</Callout> +Signed out, the page shows the saved widgets and no menu entry, and no editor code is downloaded. [Visual editor](/docs/dev/widgets/visual-editor) covers everything a moderator can do from here. -A read failure is not fatal. If the layout route cannot be reached, the page still renders in full - just without its widget areas, and with **Edit widgets** withheld, because an editor that opened on empty zones would offer to save over whatever is really stored. +## Core does this too -## The playground +Core's account settings page, `/settings`, is an editable page with two zones, `header` and `footer`, that ship empty and accept every installed widget, up to ten each. It is built exactly like this guide, with `loadPageWidgets` in its loader and `PageWidgetsZone` for both zones, and is edited with the moderator permission **Rearrange widgets on core's pages**. -`/example/zones` is the reference integration and the manual test page: four zones around a locked profile form, blocks from two plugins, a block stored with a variant, an area holding two blocks, and a second area left empty. The page itself draws no button at all: **Edit widgets** appears in the user menu for somebody holding `example.widgets/can_edit`, and nowhere else. +## Related -Visit it signed out and it is an ordinary page - shipped defaults, no entry in any menu, no editor chunk. Two checklists on the page say exactly what to look for in DevTools, including the two that matter here: that a save carries one zone rather than the whole page, and that nothing under `src/editor/` is requested until somebody clicks **Edit widgets**. +- [Custom loader](/docs/dev/widgets/custom-loader): load and save the layout yourself, with `EditablePage`, when `PageWidgets` does not fit. +- [Saving layouts](/docs/dev/widgets/saving-layouts): the two routes, storage, limits, refusals and what happens when two moderators save. +- [Restrict widgets](/docs/dev/widgets/restrict-widgets): what each zone accepts, and narrowing it at the call site. diff --git a/apps/web/content/docs/dev/widgets/end-to-end-page.webp b/apps/web/content/docs/dev/widgets/end-to-end-page.webp new file mode 100644 index 0000000000..168838444c Binary files /dev/null and b/apps/web/content/docs/dev/widgets/end-to-end-page.webp differ diff --git a/apps/web/content/docs/dev/widgets/end-to-end.mdx b/apps/web/content/docs/dev/widgets/end-to-end.mdx index d07ce68c73..515fe5f863 100644 --- a/apps/web/content/docs/dev/widgets/end-to-end.mdx +++ b/apps/web/content/docs/dev/widgets/end-to-end.mdx @@ -1,18 +1,19 @@ --- -title: End-to-end Example -description: Define a block, register it with a plugin, add a blocks() field, write a record, and render it - the whole path in seven steps. +title: First widget +description: Build a VitNode widget end to end. Define it, register it for the browser and the API, store it in a blocks() field, publish a record and render it on a public page. icon: Route implementation: objective: Add a custom widget and let editors place it on a content type's public page. decisions: - - Which plugin defines the widget? Offer plugins in the project, preferring one that already exports a `blocks` module. + - Which plugin defines the widget? Offer plugins in the project, preferring one that already exports a `widgets` module. - What does the widget show, and what does an editor fill in? Settle the content in user terms, such as a title, a short text and a tone. Widget fields can be text, numbers, yes/no switches, choices, dates or groups of these; images, files, relations, users and repeatable lists are not supported yet. - Which items hold widgets? Offer content types from the chosen plugin that could gain a `blocks()` field, or a new content type such as pages if none fits. - Where do the widgets render? Offer the public pages that show the chosen items, and which other widgets the area may accept (only this plugin's, core widgets too, or all). constraints: - - Define the widget with `defineWidget` (`defineBlock` is the same function) and register the same `blocks` module in the browser plugin export and in `buildApiPlugin()`. Write the bare id; the registry adds the namespace. + - Define the widget with `defineWidget` and export it from the plugin's `src/widgets.tsx` as a named `blocks` export. Pass the same object to `buildApiPlugin({ blocks })`. Write the bare id; the registry adds the namespace. - Store widgets in a `blocks()` field with an explicit `allowed` list, then build and migrate with the project's scripts. - - Render with `ContentRenderer` or a named `ContentZone` using the generated `blocksRegistry`. Do not fetch or validate widget data separately. + - "Records of a content type with `publication: true` start as drafts. Publish them before reading them from the public API." + - Render with `ContentZone` or `ContentRenderer`. In an app, pass the generated `blocksRegistry` from `src/blocks.gen.ts`; in a plugin page, build one with `createBlockRegistry`. Do not fetch or validate widget data separately. related: - title: Defining a widget href: /docs/dev/widgets/defining-a-block @@ -22,130 +23,153 @@ implementation: href: /docs/dev/widgets/content-zones --- -import { Tab, Tabs } from 'fumadocs-ui/components/tabs' +import { Tab, Tabs } from "fumadocs-ui/components/tabs"; -Everything below is the `@vitnode/example` plugin as it ships, so you can read the finished version in the repository. +import { ImgDocs } from "@/components/fumadocs/img"; +import sitePage from "./end-to-end-page.webp"; + +A widget is a small, configurable piece of a page: a callout, a hero, a list of features. Editors place widgets in an ordered list stored on a record, and your page renders that list. This tutorial follows one callout widget through the whole path in the `newsroom` plugin from the Content Engine lessons. The `newsroom` plugin in the VitNode repository holds a finished version, plus a second widget you do not need here. ```text -define block → register in plugin → blocks() field → create content → ContentRenderer +widget → widgets module → API → blocks() field → table → record → publish → public API → page ``` +## Before you begin + +- The `newsroom` plugin from [Create a content type](/docs/content-engine/create-a-content-type) and [Public API](/docs/content-engine/public-api): its `@/api/builders`, an admin content module and a public content module registering `articleContent`, and an `admin/nav.tsx`. Any plugin with the same setup works. +- `pnpm dev` running, and an AdminCP account with permission to create and publish the content type. The first account has both. + <Steps> + <Step> -## Define the block +### Define the widget + +A widget is a React component plus the fields an editor fills in. Create `src/widgets/callout.tsx`: + +```tsx title="plugins/newsroom/src/widgets/callout.tsx" +import type { WidgetComponentProps, WidgetData } from "@vitnode/core/widgets"; -```tsx title="plugins/example/src/blocks/callout.tsx" -import type { BlockComponentProps, BlockData } from '@vitnode/core/widgets' +import { field } from "@vitnode/core/content/fields"; +import { defineWidget } from "@vitnode/core/widgets"; +import { InfoIcon } from "lucide-react"; -import { defineBlock } from '@vitnode/core/widgets' -import { field } from '@vitnode/core/content' const calloutFields = { - body: field.textarea({ required: true, minLength: 1, maxLength: 600 }), - title: field.text({ required: true, minLength: 1, maxLength: 120 }), + body: field.textarea({ maxLength: 600, minLength: 1, required: true }), + title: field.text({ maxLength: 120, minLength: 1, required: true }), tone: field.enum({ - defaultValue: 'info', - values: ['info', 'success', 'warning'], + defaultValue: "info", + values: ["info", "success", "warning"], }), -} +}; -type CalloutData = BlockData<typeof calloutFields> +type CalloutData = WidgetData<typeof calloutFields>; const tones: Record<string, string> = { - info: 'border-primary/40 bg-primary/5', - success: 'border-success/40 bg-success/5', - warning: 'border-destructive/40 bg-destructive/5', -} + info: "border-primary/40 bg-primary/5", + success: "border-success/40 bg-success/5", + warning: "border-destructive/40 bg-destructive/5", +}; -const Callout = ({ data }: BlockComponentProps<CalloutData>) => ( +const Callout = ({ data }: WidgetComponentProps<CalloutData>) => ( <aside - className={`flex flex-col gap-2 rounded-lg border p-4 md:p-6 ${tones[data.tone ?? 'info']}`} + className={`flex flex-col gap-2 rounded-lg border p-4 md:p-6 ${tones[data.tone ?? "info"]}`} > - <h2 className="text-base font-semibold text-balance md:text-lg"> - {data.title} - </h2> - <p className="text-sm leading-relaxed text-pretty md:text-base"> - {data.body} - </p> + <h2 className="text-base font-semibold text-balance md:text-lg">{data.title}</h2> + <p className="text-sm leading-relaxed text-pretty md:text-base">{data.body}</p> </aside> -) +); -export const calloutBlock = defineBlock({ +export const calloutWidget = defineWidget({ component: Callout, - description: 'A short highlighted note in one of three tones.', + description: "A short highlighted note in one of three tones.", fields: calloutFields, - id: 'callout', - name: 'Callout', -}) + icon: InfoIcon, + id: "callout", + name: "Callout", +}); ``` +- **`fields`** use the same `field.*` builders as content types. They validate what is stored and describe the editor's form. +- **`WidgetData<typeof calloutFields>`** is the type of `data` the component receives. +- **`id`** is bare. The registry adds the plugin's namespace, so this widget is stored as `newsroom:callout`. +- The component only renders `data`. It runs in the browser and during SSR, so keep server-only code, such as database access, out of it. + </Step> + <Step> -## Export the plugin's `blocks` module +### Export the plugin's widgets -The subpath is the registration. Name the file `src/blocks.tsx` so it resolves as `@vitnode/example/blocks`. +Create `src/widgets.tsx`, exporting the plugin's widget list as `blocks`: -```tsx title="plugins/example/src/blocks.tsx" -import type { BlockPluginSource } from '@vitnode/core/widgets' +```tsx title="plugins/newsroom/src/widgets.tsx" +import type { WidgetPluginSource } from "@vitnode/core/widgets"; -import { manifest } from '@/manifest.gen' +import { manifest } from "@/manifest.gen"; -import { calloutBlock } from './blocks/callout' +import { calloutWidget } from "./widgets/callout"; -export const blocks = { +export const widgets = { pluginId: manifest.id, - blocks: [calloutBlock], -} satisfies BlockPluginSource -``` + blocks: [calloutWidget], +} satisfies WidgetPluginSource; -That is the whole registration for the browser. VitNode's Vite plugin finds the module, writes `src/blocks.gen.ts` in every application that configures the plugin, and the block is available as `example:callout`. +export const blocks = widgets; +``` -Add `namespace: "acme"` here if the derived one - the last segment of the package name - is not the id you want. +The file name and the `blocks` export are the registration. VitNode's Vite plugin looks for each configured plugin's `widgets` module (`src/blocks.tsx` in older plugins), imports `blocks` from it and writes `src/blocks.gen.ts` in the app. That file exports `blocksRegistry`, the registry of core's and every plugin's widgets. Add later widgets to the same `blocks` array. </Step> -<Step> - -## Give the API the same list -The API validates writes, so it needs the definitions too. Pass the **same object**: +<Step> -```ts title="plugins/example/src/config.api.ts" -import { buildApiPlugin } from '@vitnode/core/api/lib/plugin' +### Give the API the same list -import { manifest } from '@/manifest.gen' +The API validates every write, so it needs the same definitions. Pass the **same object** to `buildApiPlugin` in `src/config.api.ts`: -// [!code ++:2] -import { blocks } from './blocks' +```ts title="plugins/newsroom/src/config.api.ts (excerpt)" +import { widgets } from "./widgets"; // [!code ++] -export const exampleApiPlugin = () => +export const newsroomApiPlugin = () => buildApiPlugin({ manifest, - // [!code ++:2] - blocks, - modules: [adminModule], - }) + blocks: widgets, // [!code ++] + // ... + }); ``` -Passing a different plugin's module is an error - the API and the browser must namespace a block identically or a stored `type` would resolve to two different things. +Passing another plugin's object is an error, because the API and the browser must namespace a widget the same way. </Step> + <Step> -## Add a `blocks()` field to a content type +### Add a blocks() field to a content type -```ts title="plugins/example/src/content/page.ts" -import { defineContentType, field } from '@vitnode/core/content' +Share the list of widgets a page may hold in `src/content/page-blocks.ts`, so the field and the page that renders it agree: + +```ts title="plugins/newsroom/src/content/page-blocks.ts" +export const PAGE_BLOCKS_ALLOWED = ["core:*", "newsroom:callout"] as const; +``` + +Then add a `blocks()` field to the content type, here a new `newsroom.page`: + +```ts title="plugins/newsroom/src/content/page.ts" +import { defineContentType, field } from "@vitnode/core/content"; + +import { PAGE_BLOCKS_ALLOWED } from "./page-blocks"; export const pageContentType = defineContentType({ - id: 'example.page', - tableName: 'example_pages', + id: "newsroom.page", + tableName: "newsroom_pages", fields: { title: field.text({ required: true, minLength: 3, maxLength: 200 }), - slug: field.slug({ source: 'title' }), + slug: field.slug({ source: "title" }), + content: field.blocks({ - allowed: ['core:*', 'example:callout'], + allowed: PAGE_BLOCKS_ALLOWED, max: 50, }), }, @@ -153,49 +177,158 @@ export const pageContentType = defineContentType({ publication: true, publicApi: { - path: 'pages', - fields: ['title', 'slug', 'content', 'publishedAt'], + path: "pages", + fields: ["title", "slug", "content", "publishedAt"], }, -}) + + admin: { + titleField: "title", + list: { + orderableFields: ["title"], + searchableFields: ["title"], + }, + }, +}); +``` + +- **`allowed`** is enforced on every write. `core:*` admits core's built-in widgets, such as the `core:hero` this tutorial places above the callout, and `newsroom:callout` admits yours. +- **`publication: true`** gives records a draft and a published state. New records are drafts. +- **`publicApi.path`** makes the public route `content/pages`, returning the listed fields. + +</Step> + +<Step> + +### Create the model + +Create `src/database/pages.ts`. The model is what the API modules and the migration generator read: + +```ts title="plugins/newsroom/src/database/pages.ts" +import { createContentModel } from "@vitnode/core/content/server"; + +import { pageContentType } from "@/content/page"; + +export const pageContent = createContentModel(pageContentType); + +export const newsroom_pages = pageContent.table; +``` + +The `newsroom_pages` export must stay, since tables reach the migration only as named exports of files in `src/database/`. + +</Step> + +<Step> + +### Register the admin and public modules + +Add `pageContent` to the plugin's admin content module, which creates the AdminCP routes (create, update, publish...). Keep the content types you already registered, such as `articleContent`: + +```ts title="plugins/newsroom/src/api/modules/admin/admin.module.ts (excerpt)" +import { pageContent } from "@/database/pages"; // [!code ++] + +export const adminModule = buildModule({ + name: "admin", + routes: [], + modules: [ + buildContentAdminModule({ + contentTypes: [ + articleContent, + pageContent, // [!code ++] + ], + }), + ], +}); +``` + +And add it next to `articleContent` in the public content module in `src/config.api.ts`, which creates `GET /api/newsroom/content/pages/{slug}`: + +```ts title="plugins/newsroom/src/config.api.ts (excerpt)" + modules: [ + adminModule, + buildContentPublicModule({ + contentTypes: [ + articleContent, + pageContent, // [!code ++] + ], + }), + ], ``` -```ts title="plugins/example/src/database/pages.ts" -import { createContentModel } from '@vitnode/core/content/server' +</Step> + +<Step> + +### Add the AdminCP screen -import { pageContentType } from '@/content/page' +Add the content type to `src/admin/nav.tsx`, after the articles entry, and add `LayoutTemplateIcon` to the existing `lucide-react` import, so staff get a **Pages** screen to find, publish and delete records: -export const pageContent = createContentModel(pageContentType) +```tsx title="plugins/newsroom/src/admin/nav.tsx (excerpt)" +import { LayoutTemplateIcon, NewspaperIcon } from "lucide-react"; -export const example_pages = pageContent.table +import { pageContentType } from "@/content/page"; // [!code ++] + +export const adminNav = { + pluginId: manifest.id, + contentTypes: [ + { definition: articleContentType, icon: <NewspaperIcon /> }, + { definition: pageContentType, icon: <LayoutTemplateIcon /> }, // [!code ++] + ], +}; +``` + +Then name the content type in `src/locales/en.json`: a `page` entry next to `content.article`, and its permission labels at the root, next to the article ones. Keep the keys you already have: + +```json title="plugins/newsroom/src/locales/en.json (excerpt)" +{ + "newsroom": { + "content": { + "page": { + "label": "{count, plural, one {Page} other {Pages}}", + "title": "Pages", + "desc": "Pages built from widgets.", + "fields": { "title": "Title", "slug": "Slug", "content": "Content" } + } + } + }, + "newsroom:page": "Pages", + "newsroom:page:can_view": "View pages", + "newsroom:page:can_create": "Create pages", + "newsroom:page:can_edit": "Edit pages", + "newsroom:page:can_delete": "Delete pages", + "newsroom:page:can_publish": "Publish and unpublish pages" +} ``` -Register `pageContent` in the plugin's admin module and its public module, exactly like any other content type. +The generated form edits `title` and `slug` only. You fill a `blocks()` field through the API, as in the next steps, or through a [visual editor](/docs/dev/widgets/visual-editor) you build for it. </Step> + <Step> -## Build and migrate +### Build and migrate + +The migration generator reads the plugin's build output, so build the plugin first, then migrate from the workspace root: <Tabs groupId="package-manager" persist items={["bun", "pnpm", "npm"]} label="Build and migrate"> ```bash tab="bun" -bun run db:migrate +bun run build:plugins && bun run db:migrate ``` ```bash tab="pnpm" -pnpm db:migrate +pnpm build:plugins && pnpm db:migrate ``` ```bash tab="npm" -npm run db:migrate +npm run build:plugins && npm run db:migrate ``` </Tabs> -The generated migration is one table with one JSONB column for the zone: +The new table stores the widget list in one JSONB column: ```sql -CREATE TABLE "example_pages" ( +CREATE TABLE "newsroom_pages" ( "id" serial PRIMARY KEY, "createdAt" timestamp DEFAULT now() NOT NULL, "updatedAt" timestamp DEFAULT now() NOT NULL, @@ -208,91 +341,208 @@ CREATE TABLE "example_pages" ( ``` </Step> + <Step> -## Create a record with blocks +### Create a record with widgets + +The content type's admin routes are generated, so there is no route type for `fetcher` to infer. Call them with `rawFetcher`, the untyped twin with the same transport. Add a temporary component that creates a page with two widgets and publishes it. Create `src/pages/check-create-page.tsx`: + +```tsx title="plugins/newsroom/src/pages/check-create-page.tsx" +import { Button } from "@vitnode/core/components/ui/button"; +import { rawFetcher } from "@vitnode/core/tanstack/fetcher"; +import { useState } from "react"; + +import { manifest } from "@/manifest.gen"; + +export const CheckCreatePage = () => { + const [result, setResult] = useState(""); + const [isPending, setIsPending] = useState(false); + + const onClick = async () => { + setIsPending(true); + try { + const created = await rawFetcher({ + pluginId: manifest.id, + method: "post", + module: "admin/content/page", + path: "/", + body: { + title: "About the newsroom", + content: [ + { + id: crypto.randomUUID(), + type: "core:hero", + data: { + title: "Local news, written by neighbours", + description: "Stories, events and letters from the people who live here.", + }, + }, + { + id: crypto.randomUUID(), + type: "newsroom:callout", + data: { + title: "Heads up", + body: "Widgets are validated before they are stored.", + tone: "info", + }, + }, + ], + }, + }); + if (!created.ok) { + setResult(`Create answered ${created.status}: ${await created.text()}`); + + return; + } + const page: { id: number; slug: string } = await created.json(); + + const published = await rawFetcher({ + pluginId: manifest.id, + method: "post", + module: "admin/content/page", + path: "/{id}/publish", + params: { id: page.id }, + }); + setResult(`Page ${page.id} (${page.slug}): publish answered ${published.status}`); + } finally { + setIsPending(false); + } + }; + + return ( + <div className="flex flex-col gap-3"> + <Button isLoading={isPending} onClick={onClick}> + Create a test page + </Button> + {result ? <output>{result}</output> : null} + </div> + ); +}; +``` -The generated admin route takes the zone as part of the payload. Give every instance its own stable id. +Render `<CheckCreatePage />` on any newsroom page, sign in to the AdminCP in the same browser (the routes need an AdminCP session and the content type's create and publish permissions) and press the button. It shows `Page 2 (about-the-newsroom): publish answered 200`. Remove the component when you are done. -The generated admin module has no static type to infer from, so it is reached with `rawFetcher` - the same call the AdminCP itself makes. +Each widget instance is `{ id, type, data }`. The `id` is any unique string of letters, digits, `-` and `_`, and stays with the widget when it is moved. In server code, `createBlockInstance("newsroom:callout", data)` from `@vitnode/core/widgets` builds one with a fresh id. -```ts -import { createBlockInstance } from '@vitnode/core/widgets' -import { rawFetcher } from '@vitnode/core/tanstack/fetcher' - -await rawFetcher({ - method: 'post', - module: 'content/page', - path: '/', - pluginId: '@vitnode/example', - prefixPath: '/admin', - body: { - title: 'Build your community', - content: [ - createBlockInstance('core:hero', { - title: 'Build your community', - description: 'Everything you need, in one place.', - }), - createBlockInstance('example:callout', { - title: 'Heads up', - body: 'Blocks are validated before they are stored.', - tone: 'info', - }), - ], - }, -}) -``` - -`content/page` is the content type's `permissionModule`, derived from the entity half of its id. +The write is all or nothing. A widget the field does not allow, data that breaks a widget's fields or two instances with the same id return `400`, with a message naming the widget's position, and nothing is stored. -Anything the registry refuses - an unregistered block, one the field does not allow, data that breaks a field, two instances sharing an id - is a 422 naming the index of the block at fault. Nothing half-valid is stored. +The record starts as a draft, and the public API does not serve drafts, which is why the component publishes it. You can also publish it from **Newsroom → Pages** in the AdminCP. </Step> + <Step> -## Render it +### Fetch it from the public API -```tsx title="src/site/pages/page-screen.tsx" -import { ContentRenderer } from '@vitnode/core/widgets/renderer' +The public route is typed from the content type, so `fetcher` infers the response, `content` included: -import { blocksRegistry } from '@/blocks.gen' +```ts +import { fetcher } from "@vitnode/core/tanstack/fetcher"; + +const response = await fetcher({ + plugin: "newsroom", + method: "get", + module: "content/pages", + path: "/{slug}", + args: { params: { slug: "about-the-newsroom" } }, +}); +``` -export const PageScreen = ({ page }: { page: PageContent }) => ( - <main className="mx-auto flex w-full max-w-3xl flex-col gap-8 px-4 py-10"> - <h1 className="text-3xl font-semibold text-balance">{page.title}</h1> +`module` is `content/` plus the `publicApi.path`. A draft, or a slug that does not exist, answers `404`. The widgets come back as stored, in order; nothing re-validates them. - <ContentRenderer blocks={page.content} registry={blocksRegistry} /> - </main> -) -``` +</Step> -Read the record with the generated public route - the zone comes back whole, in stored order: +<Step> -```ts -import { fetcher } from '@vitnode/core/tanstack/fetcher' - -const page = await fetcher({ - plugin: '@vitnode/example', - method: 'get', - module: 'content/pages', - path: '/{slug}', - args: { param: { slug } }, -}).then(async (res) => await res.json()) +### Render it on a page + +Create `src/pages/site-page.tsx`. It loads the record by slug and renders its widgets in a `ContentZone`: + +```tsx title="plugins/newsroom/src/pages/site-page.tsx" +import { definePluginRoute, type PluginRoutePageProps } from "@vitnode/core/routing"; +import { contentPageItem } from "@vitnode/core/tanstack/content"; +import { fetcher } from "@vitnode/core/tanstack/fetcher"; +import { createBlockRegistry } from "@vitnode/core/widgets"; +import { blocks as coreBlocks } from "@vitnode/core/widgets/built-in"; +import { ContentZone } from "@vitnode/core/widgets/zone"; + +import { PAGE_BLOCKS_ALLOWED } from "@/content/page-blocks"; +import { manifest } from "@/manifest.gen"; +import { widgets } from "@/widgets"; + +const blocksRegistry = createBlockRegistry([coreBlocks, widgets]); + +const loadSitePage = async (slug: string) => { + const response = await fetcher({ + plugin: manifest.id, + method: "get", + module: "content/pages", + path: "/{slug}", + args: { params: { slug: encodeURIComponent(slug) } }, + }); + + return contentPageItem(response.ok ? await response.json() : null); +}; + +type SitePageData = Awaited<ReturnType<typeof loadSitePage>>; + +export const route = definePluginRoute({ + load: async ({ params }) => await loadSitePage(params.slug), + + head: ({ loaderData }) => ({ title: loaderData?.title }), +}); + +const SitePage = ({ loaderData: page }: PluginRoutePageProps<SitePageData>) => ( + <article className="container mx-auto flex max-w-3xl flex-col gap-8 px-4 py-8 md:py-12"> + <h1 className="text-foreground text-3xl font-semibold tracking-tight text-balance md:text-4xl"> + {page.title} + </h1> + + <ContentZone + allowedBlocks={PAGE_BLOCKS_ALLOWED} + blocks={page.content} + id="content" + registry={blocksRegistry} + /> + </article> +); + +export default SitePage; ``` -That one is fully typed: `pages` is the content type's `publicApi.path`, and the response shape - `content` included - is inferred from the definition. +Register it in `src/routes.ts`: -No extra request is made for the blocks themselves: the definitions came from the plugin configuration when the application started, and nothing re-validates data the write in step 6 already checked. +```ts title="plugins/newsroom/src/routes.ts" + page("/newsroom/pages/:slug", { + component: lazy(() => import("./pages/site-page")), + }), +``` + +- **`contentPageItem`** turns a missing record into the app's 404 page. +- **The registry.** A plugin page cannot import the app's `src/blocks.gen.ts`, so it builds a registry from core's widgets and its own with `createBlockRegistry`. A page that lives in the app passes the generated `blocksRegistry` instead. +- **`ContentZone`** renders the widgets and names the place, so a [visual editor](/docs/dev/widgets/visual-editor) can find it later. `ContentRenderer` from `@vitnode/core/widgets/renderer` renders the same list without a name. </Step> + </Steps> -## Where this goes next +## Check the result -[Content Zones](/docs/dev/widgets/content-zones) put a name on the place those blocks are rendered, so the step above becomes: +Open `http://localhost:3000/newsroom/pages/about-the-newsroom`. The page shows the title, the core hero and the callout: -```tsx -<ContentZone id="main" blocks={page.content} registry={blocksRegistry} /> -``` +<ImgDocs + withoutBackground + className="*:max-w-full" + src={sitePage} + alt="The About the newsroom page: the page title, a core hero widget reading Local news, written by neighbours, and a newsroom callout titled Heads up" +/> + +`/newsroom/pages/does-not-exist` returns the 404 page, and so does a page you unpublish in **Newsroom → Pages**. + +## Related -The [Visual Editor](/docs/dev/widgets/visual-editor) and [Editable Pages](/docs/dev/widgets/editable-pages) sit on top of exactly this. Clicking **Edit widgets** turns an interactive editing surface on for an authorized moderator: the block catalogue lists what the zone's allowlist resolves to, drag and drop re-orders widgets in place, and the properties panel edits each widget's fields. +- [Variants](/docs/dev/widgets/block-variants) (optional): give the callout several looks, or add a second widget to the same `blocks` array. +- [Content zones](/docs/dev/widgets/content-zones): several named zones on one page, and what they render when empty. +- [Editable pages](/docs/dev/widgets/editable-pages): let moderators place widgets on a page in the browser, with storage and saving handled by core. +- [Custom editor host](/docs/dev/widgets/custom-editor-host): edit a record's own `blocks()` field, like this page's `content`, in the visual editor. That isn't built in, so you write the adapter and save route. diff --git a/apps/web/content/docs/dev/widgets/index.mdx b/apps/web/content/docs/dev/widgets/index.mdx index 8c763533e0..7959611816 100644 --- a/apps/web/content/docs/dev/widgets/index.mdx +++ b/apps/web/content/docs/dev/widgets/index.mdx @@ -1,97 +1,43 @@ --- -title: Widgets Overview -description: Plugins define widgets, VitNode collects them into a registry, and content types or editable page zones store an ordered list of widgets that moderators can visually edit in place. +title: Widgets +description: Widgets are configurable page sections that VitNode plugins define and moderators place on pages. See how they are defined, stored, rendered and edited, and find every widget guide. icon: Blocks --- -A **widget** is a page section somebody can place, in an order they choose: a hero, a paragraph of prose, a call to action, "the six newest articles". A plugin defines it once - the fields it holds and the React component that renders it - and every content type or editable page in the installation can offer it. +A **widget** is a page section somebody can place, in an order they choose: a hero, a paragraph of prose, a call to action, a list of features. A plugin defines it once, with the fields it holds and one React component that renders them, and any content type or editable page in the installation can offer it. + +<Callout title="Widget or block?"> + "Widget" is the name people see in the UI. In code the same thing is a + **block**: `defineBlock`, `field.blocks()`, `blocksRegistry`. Most APIs export + both names (`defineWidget` is `defineBlock`), so use whichever reads better. +</Callout> + +## How the pieces fit ```text -Plugin - ↓ defines widgets (defineWidget) -Widget Registry - ↓ allowed by a blocks() field or defineEditablePage() -Content record / Page layout +defineWidget() a widget: fields + one component, in a plugin ↓ -ContentZone +src/widgets.tsx exported as `blocks`, and passed to buildApiPlugin({ blocks }) ↓ -ContentRenderer / Visual Editor (Edit widgets) -``` - -## What a widget is made of - -| Part | What it is | -| :------------ | :-------------------------------------------------------------------------------------------------------------------------------------------- | -| **Id** | `hero`, written without a namespace. The registry prefixes the plugin's own, so it is stored as `core:hero`. | -| **Fields** | Content Engine field descriptors - the same `field.text()`, `field.enum()` and friends a content type uses. There is no second schema system. | -| **Component** | One public React component. It receives the data the record stores for that instance. | -| **Schema** | Derived from `fields` at the write boundary. You never write it, and a public render never builds it. | - -## What a widget is not - -A widget is **not** a content type. It has no table, no id of its own in the database, no permissions and no API routes. It is a value that lives inside a record or a page layout - which is exactly why re-ordering a page is one `UPDATE` and not a migration. - -## Visual editing - -Widgets power in-place visual editing. Wrap your page's content zones in `<EditablePage>`: - -```tsx title="plugins/example/src/pages/settings-page.tsx" -// [!code ++:4] -<EditablePage - adapter={adapter} - canEdit={canEdit} - layout={layout} - page={settingsPage} -> - <ContentZone id="before-profile" /> - <ProfileForm /> - <ContentZone id="sidebar" /> -</EditablePage> -``` - -There is no button to write. **Edit widgets** appears in the user menu in the site header whenever a page like this one is on screen and `canEdit` says yes. When an authorized moderator clicks it: - -- **Drag and drop**: Rearrange widgets or drag new widgets into allowed zones. -- **Properties panel**: Configure widget settings, variants, and [Layout Areas](/docs/dev/widgets/layout-areas). -- **Zero visitor footprint**: The editor chunk is lazily loaded on click and never ships to ordinary visitors. -- **Layout persistence**: Overrides save directly to `core_page_layouts`—see [Editable Pages](/docs/dev/widgets/editable-pages). -- **Room, not overlap**: The whole site slides over for the sidebar, so nothing you are arranging ends up underneath it. - -## How widgets reach an application - -Nothing is scanned and nothing is queried. A plugin exports a `widgets` module; VitNode's Vite plugin sees it and writes `src/widgets.gen.ts`, which builds the registry and registers it. - -```ts title="src/widgets.gen.ts (generated)" -import { - createWidgetRegistry, - setDefaultWidgetRegistry, -} from '@vitnode/core/widgets' - -import { widgets as widgets0 } from '@vitnode/core/widgets/built-in' -import { widgets as widgets1 } from '@vitnode/example/widgets' - -export const pluginWidgets = [widgets0, widgets1] - -export const widgetsRegistry = createWidgetRegistry(pluginWidgets) - -setDefaultWidgetRegistry(widgetsRegistry) +stored in a blocks() field on a content record + or an editable page's layout (core stores it) + ↓ +<ContentZone> a named place on a page that renders them + ↓ +visual editor Edit widgets in the user menu, for moderators ``` -Installing a plugin is the whole registration step. Removing it takes its widgets with it, and a page that still references one keeps rendering everything else - see [Rendering](/docs/dev/widgets/rendering). +Registration is two lines in the plugin: the `blocks` export, which VitNode's Vite plugin collects into the app's generated `src/blocks.gen.ts`, and `buildApiPlugin({ blocks })`, which lets the API validate every write. [First widget](/docs/dev/widgets/end-to-end#export-the-plugins-widgets) shows both. -A registry is a plain value, not a service: `createWidgetRegistry` returns an isolated one, and every consumer takes it as an argument. The last line installs it as the process default, which is a convenience for a renderer used without a `registry` prop. Two applications in one process each create their own and pass it explicitly. +## What a widget is made of -Import `src/blocks.gen.ts` **only from a route that renders widgets** - it statically imports every plugin's widget components, so a root module that touches it makes every visitor pay for all of them. The API never needs it: it builds its own registry from the same plugins when it boots, which is what validates every write. +| Part | What it is | +| :------------ | :------------------------------------------------------------------------------------------------------------------ | +| **Id** | `hero`, written without a namespace. The registry adds the plugin's own, so it is stored as `core:hero`. | +| **Fields** | Content Engine field builders, the same `field.text()`, `field.enum()` and friends a content type uses. | +| **Component** | One React component. It receives the data stored for that instance and renders during SSR like any other component. | -## Where validation happens - -```text -write (untrusted) → full schema check, defaults applied → JSONB -read → nothing -render → structural check, every mode -``` - -Widget data is checked in full once, where it arrives: every create and update runs each instance through its widget's own schema before a row is written, and `ContentRenderer` never repeats that parse. What it does repeat, in every mode, is a cheap structural comparison of the stored object against the widget's current fields - because the write was validated against the definition the widget had _then_, and a plugin can rename a field afterwards. A widget whose stored shape no longer matches is skipped rather than handed to a component that would reach for a key that is gone. [Rendering](/docs/dev/widgets/rendering#validation) has the detail. +A widget is **not** a content type. It has no table, no permissions and no API routes of its own. It is a value inside a record or a page layout, so re-ordering a page is one `UPDATE`, not a migration. The API validates data once, when it is written; the renderer only checks that it still fits the widget's fields (see [Validation](/docs/dev/widgets/rendering#validation)). ## Widgets that ship with core @@ -101,52 +47,21 @@ Widget data is checked in full once, where it arrives: every create and update r | `core:text` | Plain paragraphs of prose. Blank lines separate paragraphs; nothing is parsed as markup. | | `core:cta` | A short call to action with one link. | -## Guides - -<Cards> - <Card - title="Defining a Widget" - description="defineWidget, fields, namespacing and typed component props" - href="/docs/dev/widgets/defining-a-block" - /> - <Card - title="The blocks() Field" - description="Storing an ordered zone on a content type, and restricting what may go in it" - href="/docs/dev/widgets/blocks-field" - /> - <Card - title="Widget Variants" - description="One widget, several looks - stored beside the data instead of inside it" - href="/docs/dev/widgets/block-variants" - /> - <Card - title="Layout Areas" - description="Widgets side by side in up to four columns, without a columns container" - href="/docs/dev/widgets/layout-areas" - /> - <Card - title="Rendering" - description="ContentRenderer, missing widgets, and why a widget costs no extra request" - href="/docs/dev/widgets/rendering" - /> - <Card - title="Content Zones" - description="Naming a place on a page where widgets may appear, including around locked application UI" - href="/docs/dev/widgets/content-zones" - /> - <Card - title="Editable Pages" - description="Declaring a page's zones once, so the people who run the site rearrange them on the page itself" - href="/docs/dev/widgets/editable-pages" - /> - <Card - title="Visual Editor" - description="Turn a public page into an interactive editing surface with drag and drop and a widget sidebar" - href="/docs/dev/widgets/visual-editor" - /> - <Card - title="End-to-end Example" - description="From an empty plugin to a rendered page, in seven steps" - href="/docs/dev/widgets/end-to-end" - /> -</Cards> +## Find your guide + +| I want to | Guide | +| :------------------------------------------------------------- | :--------------------------------------------------------- | +| Build a widget from definition to a public page | [First widget](/docs/dev/widgets/end-to-end) | +| Define a widget's fields, icon and component | [Define a widget](/docs/dev/widgets/defining-a-block) | +| Store widgets on a content type | [Blocks field](/docs/dev/widgets/blocks-field) | +| Render a stored list of widgets | [Render widgets](/docs/dev/widgets/rendering) | +| Give widgets a named place on a page | [Content zones](/docs/dev/widgets/content-zones) | +| Put several zones on one page, around application UI | [Several zones](/docs/dev/widgets/several-zones) | +| Limit which widgets a place accepts | [Restrict widgets](/docs/dev/widgets/restrict-widgets) | +| Let moderators arrange a page in the browser | [Editable pages](/docs/dev/widgets/editable-pages) | +| Load and save an editable page yourself | [Custom loader](/docs/dev/widgets/custom-loader) | +| Understand how layouts are saved, checked and conflict-proofed | [Saving layouts](/docs/dev/widgets/saving-layouts) | +| Give one widget several looks | [Variants](/docs/dev/widgets/block-variants) | +| Put widgets side by side in columns | [Layout areas](/docs/dev/widgets/layout-areas) | +| Arrange widgets as a moderator | [Visual editor](/docs/dev/widgets/visual-editor) | +| Edit widgets stored in your own table | [Custom editor host](/docs/dev/widgets/custom-editor-host) | diff --git a/apps/web/content/docs/dev/widgets/layout-areas.mdx b/apps/web/content/docs/dev/widgets/layout-areas.mdx index 2eb8e974f5..25b6ac7f50 100644 --- a/apps/web/content/docs/dev/widgets/layout-areas.mdx +++ b/apps/web/content/docs/dev/widgets/layout-areas.mdx @@ -1,5 +1,5 @@ --- -title: Layout Areas +title: Layout areas description: An Area is a small layout container stored inside a zone - it holds widgets in up to four columns, it is not a widget itself, and it cannot hold another Area. icon: Columns3 --- @@ -17,7 +17,7 @@ zone └─ block a closing note ``` -{/* Image prompt: A wireframe of one content zone, top to bottom: a full-width block, then a rounded container labelled "Area · 2 columns" holding two blocks side by side, then another full-width block. A small sidebar on the right shows an "Area" panel with a 1-2-3-4 Columns switch, Alignment and Distribution selects under "LAYOUT", and Gap sliders under "SPACING". Dark UI, blue accents, 1600x900. */} +{/* Image prompt: A screenshot of the newsroom /newsroom/zones page in edit mode with the two-column area in the after-profile zone selected, showing the Area properties panel: a 1-2-3-4 Columns switch, Alignment and Distribution selects under Layout, and Gap, Space above & below and Space left & right sliders under Spacing. */} ## The three-way split @@ -27,7 +27,7 @@ This is the idea the whole feature is built on, and it is worth stating plainly: | :------------------------------------------------ | :-------------------------------- | :----------------------------------- | | **`data`** | One block | "Ships on Friday", a heading, a link | | **[`variant`](/docs/dev/widgets/block-variants)** | One block instance | `grid`, `list`, `compact` | -| **`Area.layout`** | The relationship _between_ blocks | two columns, medium gap, top-aligned | +| **`Area.layout`** | The relationship _between_ blocks | two columns, a 16px gap, top-aligned | A block never describes the block next to it. That is why columns are not a field, and why `Area` exists at all. @@ -41,10 +41,18 @@ An Area sits in the zone's array, beside the blocks, and is told apart by its `k { "id": "01JC…KP", "kind": "area", - "layout": { "columns": 2, "gap": "md", "align": "stretch" }, + "layout": { "columns": 2, "gap": 16, "align": "stretch" }, "children": [ - { "id": "01JC…R2", "type": "core:text", "data": { "heading": "Left" } }, - { "id": "01JC…T7", "type": "core:cta", "data": { "title": "Right" } } + { + "id": "01JC…R2", + "type": "core:text", + "data": { "heading": "Left column", "body": "Two widgets, side by side." } + }, + { + "id": "01JC…T7", + "type": "core:cta", + "data": { "title": "Right column", "label": "Read more", "href": "/docs" } + } ] } ] @@ -54,22 +62,26 @@ An Area sits in the zone's array, beside the blocks, and is told apart by its `k | :--------- | :------------------------------------------------------------------------------------------------------------------------ | | `kind` | Always `"area"`. It is the only thing that distinguishes an Area from a block, which is why a block may never declare it. | | `id` | The same kind of id a block instance has - stable, unique within the zone, and kept through every move and re-order. | -| `layout` | The tokens below. `columns` is required; the rest have defaults. | +| `layout` | The settings below. `columns` is required; the rest have defaults. | | `children` | Blocks, in rendered order. **Blocks only.** | Array order is rendered order, inside an Area exactly as outside one. -## The layout tokens +## The layout settings -Four closed sets. Not CSS, not class names - tokens VitNode renders, so a stored layout cannot turn into arbitrary styling smuggled through a content column. +Closed values and whole pixel numbers, never CSS or class names, so a stored layout cannot smuggle arbitrary styling through a content column. The interface is `BlockAreaLayout` from `@vitnode/core/widgets`: -| Token | Values | Default | What it does | +| Setting | Values | Default | What it does | | :-------- | :------------------------- | :-------- | :--------------------------------------------------------------------------- | | `columns` | `1` `2` `3` `4` | `2` | How many columns on a wide screen. Always one column on a phone - see below. | -| `gap` | `none` `sm` `md` `lg` | `md` | The space between children, in both directions. | +| `gap` | whole pixels, `0`-`100` | `16` | The space between children, in both directions. | +| `marginY` | whole pixels, `0`-`100` | `0` | Space above and below the area. | +| `marginX` | whole pixels, `0`-`100` | `0` | Space to the left and right of the area. | | `align` | `start` `center` `stretch` | `stretch` | How children line up across a row. `stretch` gives equal-height columns. | | `justify` | `start` `center` `stretch` | `stretch` | How each child sits across its own column. `stretch` fills it. | +Layouts stored before spacing was measured in pixels used names; `none`, `sm`, `md` and `lg` still read as `0`, `8`, `16` and `32`, and the next save writes the number. + Areas are **mobile-first**: below the `md` breakpoint every Area is one column, whatever `columns` says. Two columns on a 360px screen is not a layout, it is a dare. ## An Area is not a block @@ -78,7 +90,7 @@ This is the part worth reading twice, because it is what keeps Areas cheap: - There is **no `core:area` block**. Nothing registers it, nothing can be installed to provide it, and `registry.get("core:area")` is `undefined` - on purpose. - It needs **no entry in `allowedBlocks`**. A zone that allows `["core:text"]` still accepts an Area holding `core:text`. The allowlist governs blocks, and the children inside an Area are checked against the very same list as blocks at the zone's root. -- It has **no `data`, no fields and no component of its own**. There is nothing for a properties form to build, which is why the editor shows four selects instead. +- It has **no `data`, no fields and no component of its own**. There are no widget fields to edit; the editor shows the layout settings instead. In the editor's block catalogue it therefore lives in its own **Layout** section above the plugin groups, rather than pretending to be somebody's block. @@ -94,16 +106,11 @@ That zone accepts: `core:text` at the root, an Area, and `core:text` inside the ## Areas cannot contain Areas -**In Stage 4, an Area's children are blocks. Never another Area.** +**An Area's children are widgets. Never another Area.** That is enforced everywhere it can be: the drag-and-drop resolver refuses the drop and says so out loud, the catalogue hides the Layout section while an Area is the insertion target, and the API refuses a nested Area on write. A stored document that somehow contains one is shown as unreadable rather than rendered. -<Callout title="Why one level, when the shape would obviously recurse?"> - Because every extra level costs more than it looks. Nesting needs depth limits, cycle detection, an answer for what an allowlist means three levels down, a keyboard drag model that can climb out of a container, and a properties panel that can say *which* area you are editing. One level covers "put these two things side by side", which is what people actually ask for, at a fraction of the surface area. - -The shape is ready for it - `children` is a list of nodes, and a nested area would be one more node kind in it - so the door is closed, not locked. - -</Callout> +One level covers "put these two things side by side", which is what people ask for; nesting would need depth limits and a much harder drag model. ## In the editor @@ -130,12 +137,7 @@ contentNodeBlocks(nodes) // every block in the zone, areas flattened away `contentNodeBlocks` is what you want for anything that cares about blocks and not about arrangement: counting them, indexing their text, checking an allowlist. Rendering is already handled - `ContentRenderer` walks nodes and lays an Area out for you. -<Callout title="A migration you do not have to run"> - A zone stored before Areas existed is a list of blocks, and a list of blocks - is a list of nodes that never nests. Nothing was rewritten, no column changed, - and a page that never uses an Area keeps exactly the markup it had - the Area - only exists in the DOM when one is stored. -</Callout> +A page that never uses an Area keeps exactly the markup it had, because an Area only exists in the DOM when one is stored. ## When an Area is the wrong answer diff --git a/apps/web/content/docs/dev/widgets/meta.json b/apps/web/content/docs/dev/widgets/meta.json index e24d2f688d..0d4efa73a3 100644 --- a/apps/web/content/docs/dev/widgets/meta.json +++ b/apps/web/content/docs/dev/widgets/meta.json @@ -4,14 +4,19 @@ "icon": "Blocks", "pages": [ "index", + "end-to-end", "defining-a-block", "blocks-field", "rendering", "content-zones", - "visual-editor", + "several-zones", + "restrict-widgets", "editable-pages", + "custom-loader", + "saving-layouts", + "visual-editor", "block-variants", "layout-areas", - "end-to-end" + "custom-editor-host" ] } diff --git a/apps/web/content/docs/dev/widgets/rendering.mdx b/apps/web/content/docs/dev/widgets/rendering.mdx index 253e8d1e8f..55d6822c02 100644 --- a/apps/web/content/docs/dev/widgets/rendering.mdx +++ b/apps/web/content/docs/dev/widgets/rendering.mdx @@ -1,17 +1,25 @@ --- -title: Rendering Widgets +title: Render widgets description: ContentRenderer walks the stored instances, resolves each one in the registry, and renders it on the server like any other component. icon: MonitorPlay --- +`ContentRenderer` turns a stored list of widgets into React elements. It looks each one up in a widget registry and renders its component, on the server like anything else. Use it, or a [content zone](/docs/dev/widgets/content-zones) built on it, wherever a page shows stored widgets. + ## The renderer -```tsx title="src/routes/_main/pages.$slug.tsx" +A screen component in the app, handed a record the route loader read: + +```tsx title="src/views/page-screen.tsx" import { ContentRenderer } from '@vitnode/core/widgets/renderer' import { blocksRegistry } from '@/blocks.gen' -export const PageScreen = ({ page }: { page: PageContent }) => ( +interface PageScreenProps { + page: { content: readonly unknown[]; title: string } +} + +export const PageScreen = ({ page }: PageScreenProps) => ( <main className="mx-auto flex w-full max-w-3xl flex-col gap-8 px-4 py-10"> <h1 className="text-3xl font-semibold text-balance">{page.title}</h1> @@ -20,19 +28,43 @@ export const PageScreen = ({ page }: { page: PageContent }) => ( ) ``` -For each stored instance it resolves the definition in the registry and renders the component with the instance's own id as the React key - so re-ordering a page moves DOM nodes instead of re-mounting them. +`blocks` takes the stored list as it came from the API, typed `readonly unknown[]`, because the renderer checks each entry itself. For each instance it resolves the definition in the registry and renders the component with the instance's own id as the React key, so re-ordering a page moves DOM nodes instead of re-mounting them. [First widget](/docs/dev/widgets/end-to-end#render-it-on-a-page) shows the same thing in a plugin page, with its loader. -| Prop | Default | | -| :--------- | :------------------------ | :-------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `blocks` | — | The stored list. `null`, `undefined` and `[]` all render nothing. | -| `registry` | the process default | The registry to resolve against. Passing it is the normal way. | -| `validate` | `"development"` | `"never"`, `"development"` or `"always"`. It governs the allowlist and the developer warnings, never the structural check - see below. | -| `fallback` | a development-only notice | What to render for a block that cannot be resolved or fails a check. | -| `allowed` | none | An allowlist to hold the instances to, under the same `validate` gate. [Content Zones](/docs/dev/widgets/content-zones#restricting-what-may-go-in-a-zone) pass theirs here. | +| Prop | Default | | +| :--------- | :------------------------ | :--------------------------------------------------------------------------------------------------------------------------------------------- | +| `blocks` | — | The stored list. `null`, `undefined` and `[]` all render nothing. | +| `registry` | the process default | The registry to resolve against. Passing it is the normal way. | +| `validate` | `"development"` | `"never"`, `"development"` or `"always"`. It governs the allowlist and the developer warnings, never the structural check - see below. | +| `fallback` | a development-only notice | What to render for a block that cannot be resolved or fails a check. | +| `allowed` | none | An allowlist to hold the instances to, under the same `validate` gate. [Zones](/docs/dev/widgets/restrict-widgets#on-a-zone) pass theirs here. | ## Where the registry comes from -Pass it. `blocksRegistry` is exported from the generated `src/blocks.gen.ts`, and handing it to the renderer is explicit, testable, and works when one process serves more than one application. +Pass it. In an app, `blocksRegistry` comes from `src/blocks.gen.ts`, which VitNode writes on every `vite dev` and `vite build` from core and every configured plugin that exports a `blocks` object from its widgets module: + +```ts title="src/blocks.gen.ts (generated, excerpt)" +import { createBlockRegistry, setDefaultBlockRegistry } from '@vitnode/core/widgets' + +import { blocks as blocks0 } from '@vitnode/core/widgets/built-in' +import { blocks as blocks1 } from 'newsroom/widgets' + +export const pluginBlocks = [blocks0, blocks1] + +export const blocksRegistry = createBlockRegistry(pluginBlocks) + +setDefaultBlockRegistry(blocksRegistry) +``` + +A plugin cannot import the app's generated file, so a plugin page builds its own registry from the widgets it knows: + +```ts +import { createBlockRegistry } from '@vitnode/core/widgets' +import { blocks as coreBlocks } from '@vitnode/core/widgets/built-in' + +import { widgets } from '@/widgets' + +const blocksRegistry = createBlockRegistry([coreBlocks, widgets]) +``` <Callout title="Import blocks.gen only where you render blocks" type="warn"> That file statically imports every configured plugin's block components, so @@ -42,9 +74,9 @@ Pass it. `blocksRegistry` is exported from the generated `src/blocks.gen.ts`, an router cannot reach it. </Callout> -Evaluating `src/blocks.gen.ts` also installs that registry as the process default, so a renderer with no `registry` prop finds one. Treat that as a convenience for a renderer deep inside a tree that cannot be handed one, not as setup: it only works if something already imported the file on that route. +Evaluating `src/blocks.gen.ts` also installs that registry as the process default, so a renderer with no `registry` prop finds one. Treat that as a convenience, not as setup. It only works if something already imported the file in that environment. -The API does not rely on it. It builds its own registry when it boots, from the same plugins' `blocks`, so write-time validation is never waiting on a front end to evaluate a module. With no registry at all, `ContentRenderer` throws a `BlockRegistryMissingError` naming both ways out. +The API does not rely on it. It builds its own registry when it boots, from the `blocks` each plugin passes to `buildApiPlugin`. With no registry at all, `ContentRenderer` throws a `BlockRegistryMissingError` naming both ways out. `setDefaultBlockRegistry` returns the function that undoes it, so a test - or a second application - can install one without leaking it: @@ -112,31 +144,35 @@ Pass `fallback` to decide for yourself: ```tsx <ContentRenderer blocks={page.content} + registry={blocksRegistry} fallback={({ instance, reason }) => reason === 'unknown-type' ? ( - <PluginMissingNotice type={instance.type} /> + <p className="text-muted-foreground text-sm"> + This section needs the plugin that provides {instance.type}. + </p> ) : null } /> ``` +`reason` is one of `unknown-type`, `not-allowed`, `invalid-data` or `unknown-variant`. + ## Performance A block is a normal React component and the renderer is a loop. -- **No request.** Definitions come from the configured plugins, not from the database. A page of hero + text + image + CTA is exactly as many requests as a page without blocks. -- **No client state.** `ContentRenderer` holds none, subscribes to nothing, and renders on the server like anything else. -- **No validation library.** `defineBlock` and `ContentRenderer` do not reach Zod; the schema machinery lives in the write path. Bundling core's three blocks plus a plugin's, the registry and the renderer comes to about 25 kB of JavaScript with no third-party dependency in it at all. -- **No editor.** `@vitnode/core/widgets` is free of React entirely - it is the metadata surface the API validates with - and `@vitnode/core/widgets/renderer` reaches no drag-and-drop, AdminCP, form or query code. Tests assert the reachable module graph of each entry point, so future editor code cannot drift into the public bundle. +- **No request.** Definitions come from the configured plugins, not from the database. A page of widgets makes exactly as many requests as a page without them. +- **No client state.** `ContentRenderer` holds none and subscribes to nothing. +- **No editor.** `@vitnode/core/widgets/renderer` reaches no drag-and-drop, AdminCP, form or query code, and a boundary test in `@vitnode/core` keeps it that way. -Keep your own block components in that spirit: a block that needs a chart library should `lazy()` it inside its own module, so a page that never places it never pays for it. +Keep your own widget components in that spirit. A widget that needs a chart library should `lazy()` it inside its own module, so a page that never places it never pays for it. -## Content Zones +## Content zones -`ContentRenderer` is the loop. A [Content Zone](/docs/dev/widgets/content-zones) is that loop with a name on it: +`ContentRenderer` is the loop. A [content zone](/docs/dev/widgets/content-zones) is that loop with a name on it, which is what the visual editor needs to place widgets on a page: ```tsx <ContentZone id="main" blocks={page.content} registry={blocksRegistry} /> ``` -The name is what lets an editor put something in a particular place later, and what lets blocks sit around application UI that stays locked. A zone renders through this renderer, so everything on this page applies to it unchanged. +A zone renders through this renderer, so everything on this page applies to it unchanged. diff --git a/apps/web/content/docs/dev/widgets/restrict-widgets.mdx b/apps/web/content/docs/dev/widgets/restrict-widgets.mdx new file mode 100644 index 0000000000..4fdd8bb3e8 --- /dev/null +++ b/apps/web/content/docs/dev/widgets/restrict-widgets.mdx @@ -0,0 +1,96 @@ +--- +title: Restrict widgets +description: Limit which widgets a VitNode blocks() field, content zone or editable page zone accepts with one allowlist format, share the list between them, and know which check runs on save. +icon: ListFilter +--- + +Every place widgets go takes the same allowlist. Write it on a [`blocks()` field](/docs/dev/widgets/blocks-field), on an [editable page](/docs/dev/widgets/editable-pages) zone or on a [content zone](/docs/dev/widgets/content-zones), and the editor only offers what fits. Where you write it decides whether the API enforces it. + +## The list format + +An allowlist is `"*"` or an array mixing exact ids and plugin wildcards: + +```ts +'*' // every installed widget +['core:*', 'newsroom:*'] // every widget of two plugins +['core:hero', 'newsroom:callout'] // exactly these two +``` + +A plugin wildcard is the useful middle. Installing another plugin does not silently widen what a page may contain, while a new widget in a plugin you already trust appears automatically. An [Area](/docs/dev/widgets/layout-areas) needs no entry; the widgets inside it are checked against the same list. + +## Where it is enforced + +| Written on | As | Checked | On refusal | +| :------------------------- | :-------------------------------- | :----------------------------------------------- | :------------------------------------------------ | +| A `blocks()` field | `field.blocks({ allowed })` | By the API, on every create and update | `400`, nothing stored | +| An editable page zone | `allowed` in `defineEditablePage` | By the API, on every save to `PUT /pages/layout` | `400`, nothing stored | +| A standalone `ContentZone` | `allowedBlocks` | Only while rendering, per its `validate` mode | The widget is skipped (by default in development) | + +In the visual editor, all three behave the same. **Add widget** on a zone lists only what the zone accepts, and a zone turns red and refuses a widget dragged onto it that it does not accept. Only the first two are also checked on the server. A [custom editor host](/docs/dev/widgets/custom-editor-host) that saves a standalone zone must check the list in its own save route. + +## On a blocks() field + +```ts title="plugins/newsroom/src/content/page.ts (excerpt)" +content: field.blocks({ + allowed: PAGE_BLOCKS_ALLOWED, + max: 50, +}), +``` + +The default is `"*"`. A write holding a widget the field does not allow answers `400` with `Block "core:cta" is not allowed in this field.`, and nothing is stored. + +## On an editable page + +Each zone declares its list once, in `defineEditablePage`, and every `ContentZone` with that id inherits it: + +```ts title="plugins/newsroom/src/content/settings-page.ts (excerpt)" +zones: { + 'before-profile': { allowed: PAGE_BLOCKS_ALLOWED, max: 20 }, + sidebar: { allowed: PAGE_SIDEBAR_BLOCKS_ALLOWED, max: 20 }, +}, +``` + +The server reads the list from the registered page, never from the request, so no request can sneak a widget into a zone that refuses it. Props on the page's `ContentZone` can narrow the declaration but never widen it: + +| At the call site | Effect | +| :--------------- | :--------------------------------------------------------------------------------------------------------------------------------- | +| `allowedBlocks` | **Only narrows.** It is intersected with the page's list, so `"*"` cannot re-open a restricted zone. An empty intersection throws. | +| `min` / `max` | **Only tighten.** The higher `min` and the lower `max` win. A `min` above the `max` throws where you wrote it. | +| `blocks` | **Overrides the source.** The array you pass is what renders, for a zone whose widgets come from somewhere else. | + +## On a zone + +A standalone `ContentZone` describes what belongs in a place; it does not guard storage. While rendering, its `validate` mode decides what happens to a stored widget the list does not allow: + +| `validate` | A widget the zone does not allow | +| :-------------------------- | :-------------------------------------------------------------------- | +| `"never"` | renders | +| `"development"` _(default)_ | is skipped with a dashed notice in development, renders in production | +| `"always"` | is skipped everywhere | + +Use `"always"` on a page that renders widgets the API never checked, such as a live import. See [Validation](/docs/dev/widgets/rendering#validation) for the rest of what the renderer checks. + +## Share one list + +When a field and the page that renders it should accept the same widgets, put the list in a module of its own and import it from both. The newsroom plugin keeps its lists in `src/content/page-blocks.ts`: + +```ts title="plugins/newsroom/src/content/page-blocks.ts" +export const PAGE_BLOCKS_ALLOWED = [ + 'core:*', + 'newsroom:callout', + 'newsroom:features', +] as const + +export const PAGE_SIDEBAR_BLOCKS_ALLOWED = ['core:text'] as const +``` + +`page.ts` passes `PAGE_BLOCKS_ALLOWED` to the field, and `pages/site-page.tsx` passes the same constant to its zone as `allowedBlocks`. Import the array, not the content type. A page that imports a content type definition pulls the whole schema builder into its bundle to read one list. + +## Check the result + +Sign in as an administrator, open `/newsroom/zones` and choose **Edit widgets** from the user menu. **Add widget** on the sidebar zone lists only **Text**, because the sidebar allows `core:text` alone; drag the callout from another zone onto the sidebar and the sidebar turns red and refuses it. + +## Related + +- [Editable pages](/docs/dev/widgets/editable-pages): declare a page's zones and their lists. +- [Saving layouts](/docs/dev/widgets/saving-layouts#when-a-save-is-refused): every refusal a layout save can answer. diff --git a/apps/web/content/docs/dev/widgets/saving-layouts.mdx b/apps/web/content/docs/dev/widgets/saving-layouts.mdx new file mode 100644 index 0000000000..3d068d9dde --- /dev/null +++ b/apps/web/content/docs/dev/widgets/saving-layouts.mdx @@ -0,0 +1,112 @@ +--- +title: Saving layouts +description: How VitNode reads, permission-checks, validates and stores an editable page's widget layout, covering the GET and PUT /pages/layout routes, the core_page_layouts table, limits, refusals and per-zone conflict detection. +icon: Database +--- + +Core owns the storage of every [editable page](/docs/dev/widgets/editable-pages): two routes, one table and the checks around them. `PageWidgets` and `createContentEditorAdapter` call them for you; this page is the reference for what they accept, store and refuse, for debugging or for a [custom loader](/docs/dev/widgets/custom-loader). + +## The two routes + +Both are addressed by page id, never by your plugin: + +```http +GET /api/@vitnode/core/pages/layout?pageId=newsroom:settings public +PUT /api/@vitnode/core/pages/layout the page's moderator permission +``` + +With `fetcher`, that is `plugin: "@vitnode/core", module: "pages", path: "/layout"`. + +```ts +interface EditablePageLayoutPayload { + pageId: string + updatedAt: string | null // null: nothing saved yet, these are the defaults + zones: Record<string, ContentNode[]> +} + +interface EditablePageSavePayload { + pageId: string + zones: Record<string, ContentNode[]> // only the zones that changed + expectedZones: Record<string, ContentNode[]> // what each of them held when editing began +} +``` + +Both types are exported from `@vitnode/core/content/editor`. + +**`GET`** answers with the **effective** layout. For each declared zone, that is the saved widgets if somebody saved it, otherwise the zone's `default`. A page nobody has saved answers `200` with the defaults and `updatedAt: null`. A page id nobody registered answers `404`. + +**`PUT`** takes `{ pageId, zones, expectedZones }` and answers with the same payload shape, holding the saved zones only. Which zones exist and what each accepts comes from the registered page, never from the request body. + +## Who may save + +`PUT` checks the page's `permission` as a **moderator** permission, on every request, whatever the browser showed. A moderator permission is used because it is read from the signed-in member, which a public page has; an AdminCP permission only exists inside the AdminCP. A root role passes. + +`?edit=true` in a URL only asks for edit mode. The editor opens only when the permission agrees, and the save checks the permission again. + +The page asks "may I edit?" through `GET /api/@vitnode/core/users/permissions`, which answers `{ root, permissions }` for the signed-in member and `{ "root": false, "permissions": [] }` for a visitor. + +## How a layout is stored + +In core's `core_page_layouts` table, one row per page: + +```text +core_page_layouts + pageId varchar primary key + zones jsonb not null default '{}' + createdAt timestamp + updatedAt timestamp +``` + +The row holds **overrides only**, just the zones somebody saved. Defaults live in code, in `defineEditablePage`. + +- A page nobody has saved has no row, which is what `updatedAt: null` means. +- Saving a zone back to exactly its default drops its override; when no overrides are left, the row is deleted. +- A saved zone the page no longer declares is ignored, not deleted. Put the zone back and its widgets return. + +Nothing here is a content type: no publishing, revisions, search or AdminCP screen. + +## Limits + +| Limit | Value | Counts | +| :---------------------------- | :----------------------------------- | :------------------------------------------------------------------------------------------------ | +| A zone's `max` / `min` | `200` if `max` is left out; no `min` | **Widgets.** A widget inside an [area](/docs/dev/widgets/layout-areas) counts; the area does not. | +| `CONTENT_BLOCKS_ABSOLUTE_MAX` | `1000` | **Entries at the zone's top level**, widgets and areas alike. Nothing can raise it. | +| `AREA_CHILDREN_DEFAULT_MAX` | `50` | **Widgets in one area.** | + +All three constants are exported from `@vitnode/core/widgets`. `defineEditablePage` itself refuses a zone whose `min` is above its `max`. + +## Two moderators, one page + +`expectedZones` turns "last save wins" into "the save that knew what it was replacing wins". Before writing, the server compares each submitted zone's `expectedZones` entry with what that zone holds now, inside the same transaction as the write: + +| Situation | Result | +| :------------------------------ | :---------------------------------------------------------------------------------------------- | +| Two people, **different zones** | Both saves land. Each save writes only the zones it names and leaves the rest as it found them. | +| Two people, **the same zone** | The second save answers `409`, naming the zones that moved, and **nothing** is written. | + +The editor shows **Somebody else saved this page first** and keeps every change, so the moderator can reload and redo it on top of the new layout. Nothing locks and nothing polls. + +## When a save is refused + +| Refused | Status | +| :--------------------------------- | :------------------------------------------------------------------------------- | +| A page id nobody registered | `400` | +| The permission is not held | `403` | +| No zones at all | `400` | +| A zone without its `expectedZones` | `400`, since without a baseline the server cannot tell an edit from an overwrite | +| A zone the page does not declare | `400`, listing the zones it does | +| A widget the zone does not allow | `400`, also inside an area | +| Too many or too few widgets | `400`, from the zone's `max` and `min` | +| A zone somebody else changed first | `409`, naming the zones. Nothing is written, not even the other zones | +| The stored layout cannot be read | `500`. A failed read is never answered with defaults | + +Every refusal names its reason in `message`. In the browser, the editor adds its own guard. A zone holding content it cannot read keeps **Save** disabled until somebody removes it; see [Visual editor](/docs/dev/widgets/visual-editor#when-the-editor-refuses-to-save). + +## The event + +A save that changed something emits [`core.page-layout.updated`](/docs/dev/events/built-in-events#page-layout-events) with `{ pageId, changedZones }`, after the write. A save that changed nothing stores nothing, keeps `updatedAt` and emits nothing, but still answers `200`. + +## Related + +- [Editable pages](/docs/dev/widgets/editable-pages): declare and render a page. +- [Restrict widgets](/docs/dev/widgets/restrict-widgets): the allowlist each zone checks. diff --git a/apps/web/content/docs/dev/widgets/several-zones.mdx b/apps/web/content/docs/dev/widgets/several-zones.mdx new file mode 100644 index 0000000000..3c1340f87a --- /dev/null +++ b/apps/web/content/docs/dev/widgets/several-zones.mdx @@ -0,0 +1,76 @@ +--- +title: Several zones +description: Put several content zones on one VitNode page, around application UI such as a form, with unique zone ids, page-local names on editable pages and per-zone saving. +icon: Rows3 +--- + +A page can hold any number of [content zones](/docs/dev/widgets/content-zones). Each has its own id, its own widgets and its own rules, and the editor saves each one separately. The usual reason for several is application UI in the middle: a notice above a form, a sidebar beside it, nothing inside it. + +## Before you begin + +- A page that renders [one content zone](/docs/dev/widgets/content-zones#render-a-zone). + +## Put zones around application UI + +Place a zone wherever widgets may go, and keep your own components between them. Here `zones` is what your loader returned, one list of widgets per zone: + +```tsx +<div className="flex flex-col gap-6 lg:flex-row lg:items-start"> + <div className="flex flex-1 flex-col gap-6"> + <ContentZone id="before-profile" blocks={zones.beforeProfile} registry={blocksRegistry} /> + + <ProfileForm /> + + <ContentZone id="after-profile" blocks={zones.afterProfile} registry={blocksRegistry} /> + </div> + + <ContentZone + as="aside" + className="border-border w-full rounded-lg border p-4 lg:w-64" + id="sidebar" + blocks={zones.sidebar} + registry={blocksRegistry} + /> +</div> +``` + +`ProfileForm` is application code, with its own validation and tests. Nobody can drag a widget into it, and in edit mode it gets no overlay, so its inputs keep working and keep their state. + +On an [editable page](/docs/dev/widgets/editable-pages), leave `blocks` out. The page hands every zone its widgets, its allowlist and its limits. + +## Give every zone its own id + +A zone id names one place, so two zones on one page cannot share it. View mode renders both lists, but edit mode refuses as soon as both are on the page: + +```tsx +<ContentZone id="main" /> +<ProfileForm /> +<ContentZone id="main" /> +// BlockError: Two `<ContentZone id="main" />` outlets are on the page at the same time… +``` + +The editor stores and saves widgets by zone id, so with two it could not tell which place a widget belongs to. Moving a zone to another element, or re-rendering it under another branch, is fine. Need the same widgets in two places? Use two ids, such as `main` and `main-aside`, and decide what goes in each. + +## Choose the ids + +| Zone | Id | +| :---------------------------------- | :---------------------------------------------------------------------------------------- | +| A standalone zone | Any valid id. A `scope:` prefix, such as `homepage:hero`, keeps screens apart. | +| A zone declared by an editable page | Page-local, such as `sidebar`. The page id (`newsroom:settings`) already namespaces them. | + +Either way, the id is permanent, because a saved layout addresses the zone by it. + +## How the editor treats several zones + +- **Each zone saves on its own.** A save carries only the zones that changed, so two moderators who edit different zones of one page both succeed. See [Saving layouts](/docs/dev/widgets/saving-layouts#two-moderators-one-page). +- **Each zone keeps its own allowlist.** A widget dragged onto a zone that does not accept it is refused there. See [Restrict widgets](/docs/dev/widgets/restrict-widgets). +- **A zone that leaves the page leaves the editor.** A zone hidden behind a tab while somebody edits drops out of the editor; what is stored for it stays stored. + +## Check the result + +Open `/newsroom/zones` from the newsroom plugin. Four zones sit around a profile form: `before-profile` with three widgets and no wrapper, `after-profile` with two layout areas and a call to action, `sidebar` in an `<aside>`, and `before-footer`, which is empty and renders nothing. In the page source, only the sidebar carries a `data-vitnode-zone` attribute. + +## Related + +- [Editable pages](/docs/dev/widgets/editable-pages): declare these zones once and let moderators arrange them. +- [Layout areas](/docs/dev/widgets/layout-areas): widgets side by side inside one zone. diff --git a/apps/web/content/docs/dev/widgets/visual-editor-edit-mode.webp b/apps/web/content/docs/dev/widgets/visual-editor-edit-mode.webp new file mode 100644 index 0000000000..d0152bc21c Binary files /dev/null and b/apps/web/content/docs/dev/widgets/visual-editor-edit-mode.webp differ diff --git a/apps/web/content/docs/dev/widgets/visual-editor.mdx b/apps/web/content/docs/dev/widgets/visual-editor.mdx index 545db1f66f..2e5084d5fa 100644 --- a/apps/web/content/docs/dev/widgets/visual-editor.mdx +++ b/apps/web/content/docs/dev/widgets/visual-editor.mdx @@ -1,931 +1,108 @@ --- -title: Visual Edit Mode -description: Turn a public page into an editing surface without changing a single ContentZone - and without shipping a byte of the editor to the people who are only reading. +title: Visual editor +description: Arrange widgets on a VitNode page in the browser. Open the visual editor from the user menu, add, move and edit widgets, then save, with keyboard support and a preview. icon: MousePointerClick --- -**Visual Edit Mode** is the part where somebody finally gets to move things around. +import { ImgDocs } from "@/components/fumadocs/img"; +import editMode from "./visual-editor-edit-mode.webp"; -A page renders its [content zones](/docs/dev/widgets/content-zones) the way it always has. An authorized moderator opens the user menu in the header, clicks **Edit widgets**, and the _same page_ - same layout, same fonts, same widgets - grows an outline around each zone, makes every widget draggable where it stands, and slides a sidebar that slides in down the right-hand side while the whole site moves over to make room for it. They rearrange, edit in the sidebar, save, and leave. Nobody was sent to a separate admin screen to guess what the result would look like. +The visual editor lets a moderator rearrange a page's widgets on the page itself. They open **Edit widgets** from the user menu, the page grows outlines around its [content zones](/docs/dev/widgets/content-zones) and a sidebar slides in, and they drag, edit and save without leaving the page. ```text -public page - ↓ Edit widgets ← the user menu, on whatever page you are on -the same page, editable - ↓ Save -public page again +public page → Edit widgets → the same page, editable → Save → public page ``` -{/* Image prompt: A VitNode settings page in Visual Edit Mode. The left three quarters is a normal-looking settings screen with a profile form in the middle; above and below it, two content zones are outlined with dashed borders and small corner labels reading "Before profile" and "After profile". One block is selected with a blue ring and shows a small floating pill of icon buttons (edit, delete) in its top-right corner. The right quarter is a full-height sidebar headed "Widgets" with a search field and a grouped list of draggable block cards under "CORE" and "EXAMPLE", and a footer with a small amber dot beside "Unsaved changes" and a Discard link, above an eye-icon Preview button, a Finish editing button and a wide blue Save button. Dark theme, 1600x900. */} - -## `ContentZone` did not change - -This is the promise the [Content Zones](/docs/dev/widgets/content-zones) page made, and it is worth being specific about how it was kept. - -```tsx -<ContentZone - id="before-profile" - blocks={content.beforeProfile} - registry={blocksRegistry} +<ImgDocs + withoutBackground + className="*:max-w-full" + src={editMode} + alt="The newsroom zones page in edit mode: dashed outlines labelled Before profile and Sidebar, a selected Callout widget with Edit and Remove actions, an Add widget button in the sidebar zone, and the Callout's Body, Title and Tone fields in the editor sidebar above the Finish editing and Save buttons" /> -``` - -That is the zone in view mode. It is also the zone in edit mode. There is no `EditableContentZone`, no `editable` prop, no second component to migrate to - and nothing stored today needs rewriting. - -Internally `ContentZone` asks one question before it renders: _is an edit runtime present?_ In view mode the answer is `null` and the Stage 2 path runs untouched, including the `return null` for an empty zone. In edit mode the runtime answers, and the zone hands it everything it knows - id, blocks, registry, allowlist, wrapper - and lets it render the editing surface instead. - -A visitor pays one `useContext` that answers `null`. That is the entire cost of a feature they will never see. - -## Turning it on - -There are two ways to turn on visual editing, depending on how your page manages layouts. - -### 1. High-level: `<EditablePage>` (Recommended) - -For pages that declare their zones with `defineEditablePage`, wrap your zones in `<EditablePage>` from `@vitnode/core/widgets/page`. It automatically resolves zone data, handles defaults, rebases canonical server responses, and mounts the editor runtime when edit mode is active. - -```tsx title="plugins/example/src/pages/zones-page.tsx" -import { EditablePage } from '@vitnode/core/widgets/page' -import { ContentZone } from '@vitnode/core/widgets/zone' -import { createContentEditorAdapter } from '@vitnode/core/content/editor' - -const SettingsPage = ({ canEdit, layout, search }: SettingsPageProps) => { - const adapter = useMemo( - () => - createContentEditorAdapter({ - page: settingsPage, - save: async (payload) => { - await savePageLayout(payload) - }, - }), - [], - ) - - return ( - // [!code ++:4] - <EditablePage - adapter={adapter} - canEdit={canEdit} - layout={layout} - openEditing={search.edit === true} - page={settingsPage} - > - <ContentZone id="before-profile" /> - <ProfileForm /> - <ContentZone id="after-profile" /> - </EditablePage> - ) -} -``` - -No button and no `editing` state: `<EditablePage>` tells the site shell that an editable page this visitor may edit is on screen, and the shell puts **Edit widgets** in the user menu. `openEditing` is only for arriving with edit mode already asked for, which is what a `?edit=true` link does. -See [Editable Pages](/docs/dev/widgets/editable-pages) for the full breakdown of `defineEditablePage`, defaults, and zone permissions. +## Before you begin -### 2. Low-level: `<ContentEditorRuntime>` +- **A page with editing turned on.** An [editable page](/docs/dev/widgets/editable-pages) gets the editor, storage and saving from core; that is the usual way. A page that stores widgets somewhere else uses a [custom editor host](/docs/dev/widgets/custom-editor-host). +- **The page's moderator permission.** An editable page names it, such as **Widgets → Edit page widgets** for the newsroom plugin. Administrators with full access hold it already. -For custom hosts that manage their own raw zone persistence and do not declare an editable page, wrap your zones in `ContentEditorRuntime` from `@vitnode/core/widgets/edit`: +To try everything below, open `/newsroom/zones` from the newsroom plugin, signed in as an administrator. -```tsx -import { ContentEditorRuntime } from '@vitnode/core/widgets/edit' +## Open the editor -;<ContentEditorRuntime - adapter={adapter} - enabled={editing} - onExit={() => { - setEditing(false) - }} -> - <ContentZone id="before-profile" blocks={content.beforeProfile} /> - <ProfileForm /> - <ContentZone id="after-profile" blocks={content.afterProfile} /> -</ContentEditorRuntime> -``` +Open the user menu in the header and choose **Edit widgets**. The entry only appears on a page you can edit. -This one takes `enabled` from you, so a custom host that is not an editable page owns edit mode itself - and has to draw its own way in, because the user menu only offers one for a declared page. +The whole site moves over to make room for the **Widgets** sidebar, and every zone gets a dashed outline with its name. An empty zone, which renders nothing for visitors, shows **Drag and drop widgets here** and an **Add widget** button. -| Prop | | | -| :--------- | :------- | :---------------------------------------------------------------------------- | -| `enabled` | required | `false` renders the children and nothing else. `true` loads the editor. | -| `children` | required | The page. Every `ContentZone` inside it becomes editable. | -| `adapter` | optional | Where **Save** sends its payload. Without one, Save says so and does nothing. | -| `onExit` | optional | Called by **Finish editing**, after the unsaved-changes prompt. | +Nothing on the page restarts. A half-filled form or a playing video around the zones keeps its state. A link with `?edit=true` can ask for edit mode on arrival, but it never grants it. -### Who gets the button, and where it is +## Add a widget -The entry lives in **the user menu in the site header**, next to My Profile and Settings. That is one place for a site-wide action, it is on every page, and it means a page that happens to be editable does not have to find a corner of its own header to put a button in - which is how three pages end up with it in three different places. +The **Widgets** sidebar lists every widget the page's zones accept, grouped by plugin, with a search field on top. -It draws itself from what the page publishes, so there is nothing to render and nothing to wire: +- **Click** a widget to add it to the targeted zone, or to the first zone that accepts it. +- **Drag** it onto the page. A blue line shows exactly where it will land. +- **Add widget** on a zone targets that zone. The sidebar says _Adding to …_ and lists only what that zone accepts. -```text -<EditablePage canEdit …> → "this page is editable, and this visitor may" -site shell → Edit widgets in the user menu - → the room the sidebar needs, from the whole page -``` +The new widget is selected and its fields open in the sidebar, ready to fill in. The **Layout** section adds an **Area**, which places widgets side by side in up to four columns; see [Layout areas](/docs/dev/widgets/layout-areas). -`canEdit` is still **yours**. VitNode does not decide who may edit a page, because VitNode does not know what your pages are - a plugin's settings screen, a marketing page and a user profile have three different answers. On editable pages it is usually checked against the moderator permission the page declares (e.g. `widgets: ["can_edit"]`); pass `false` and the entry is not in the menu, the runtime is not mounted, and the editor chunk is not requested. +## Move a widget -<Callout title="A hidden button has never stopped anybody" type="warn"> - `/example/zones` reads the visitor's effective moderator permissions in its - loader and only then says `canEdit`. That is a *signal*, not the boundary: the - save endpoint behind the adapter asserts the same permission again and answers - `403` without it. A URL like `?edit=true` is the same kind of thing - it may - *request* edit mode, and it must never grant it. See [Editable - Pages](/docs/dev/widgets/editable-pages#can-i-edit-this). -</Callout> - -## The lazy-loading guarantee - -The editor is not small. Drag and drop, a block catalogue, a form runtime and a properties panel add up, and every one of them is useless to a visitor reading the page. - -So **none of it is in the public page's bundle**. `@vitnode/core/widgets/edit` reaches React and nothing else; the only path from it to the editor is a dynamic `import()` behind `React.lazy`: - -```tsx title="packages/vitnode/src/blocks/edit.tsx" -const EditorRoot = lazy(async () => await import('../editor/root')) -``` - -The chunk is requested when `enabled` first flips to `true` - that is, on the first click of **Edit widgets** - and never before. - -```text -view mode blocks/zone.tsx → react -edit seam blocks/edit.tsx → react -first click editor/root.tsx → @dnd-kit, @tanstack/react-form, sonner, … -``` +Drag a widget by any part of it. Drop it above or below another widget, into another zone, or onto an empty zone. A zone that does not accept the widget turns red and refuses it; nothing moves. -This is enforced, not merely intended. `packages/vitnode/src/editor/boundaries.test.ts` walks the real module graph and fails the build if the arrow ever points the wrong way: +Everything works from the keyboard too: -- `blocks/zone.tsx` and `blocks/edit.tsx` **eagerly** reach `react` and nothing else; -- neither reaches `@dnd-kit/*`, `@tanstack/react-form`, `sonner`, `@tanstack/react-query`, `@tanstack/react-router`, `cmdk` or `zod`; -- `blocks/edit.tsx` **does** reach `@dnd-kit/core` once dynamic imports are followed, which is the proof that the only path is a dynamic one; -- nothing under `src/editor/**` reaches `views/admin/**`, or any database or server package; -- the sidebar is the one panel `editor/root.tsx` mounts, `editor/toolbar/` and `editor/block-picker/dialog.tsx` do not exist, and the editor no longer reaches `cmdk` at all - a revert fails the test instead of quietly shipping a modal again. +| Key | What it does | +| --- | --- | +| <kbd>Tab</kbd> | Moves between widgets. | +| <kbd>Enter</kbd> | Selects the widget and opens its fields. | +| <kbd>Space</kbd> | Picks the widget up, and drops it the second time. | +| <kbd>↑</kbd> <kbd>↓</kbd> | Moves it through the positions of its zone and into the next zone. | +| <kbd>Esc</kbd> | Puts it back where it started. | -Turning that static `import` back on would be a one-line change and a silently 200 kB heavier home page. The test is the thing that notices. - -## Turning it on does not restart your page - -Lazy loading is easy to get right in a way that quietly breaks the page underneath. The obvious shape is this one: - -```tsx -if (!enabled) return <>{children}</> - -return ( - <Suspense fallback={children}> - <EditorRoot>{children}</EditorRoot> - </Suspense> -) -``` - -It looks like a toggle. It is a **remount**. React reconciles by position in the element tree, so the moment a wrapper appears above `children`, everything in them is torn down and built again: `useState` resets, uncontrolled inputs empty themselves, focus is lost, a playing video restarts. Reusing the identical `children` element object does not help - position is what counts, and `<Suspense>` alone is enough to do it. - -That is not acceptable on a page somebody is already using. So the runtime is built the other way round: - -```text -site shell <div> ← above the header, padding is the only thing on it - ContentEditorRuntime ← public, always mounted - ContentEditContext.Provider ← always mounted, value is null in view mode - {children} ← one fixed slot, never moves - {enabled && <Suspense><EditorRoot/></Suspense>} ← a SIBLING -``` - -The editor is never an ancestor of your page. Turning edit mode on changes one context value and mounts a sibling; nothing above `children` changes, so nothing in them remounts. - -The site shell above it all is a plain block `<div>` at the document root, and it stays one whether or not anybody is editing. That is deliberate rather than incidental: **padding only transitions between two values of an element that already has a box**, so a wrapper that appeared when editing started - or went back to `display: contents` when it ended - would snap to its new width instead of gliding there, in whichever direction it was going. It carries the transition and the two custom properties and nothing else. - -### Then how do zones become editable? - -`@dnd-kit` needs its `DndContext` above every droppable, and the zones are inside your page. Since the editor may not wrap your page, the zones come **out** to it instead. - -In edit mode `ContentZone` renders a bare host element - your `as`, your `className`, the usual `data-vitnode-zone` attributes - and registers that DOM node with the runtime. The editor, which owns the `DndContext`, renders the real `EditableZone` into each registered node with `createPortal`: - -```text -page editor -──── ────── -<aside ref> ← createPortal(<EditableZone/>, node) - inside <DndContext>, inside the editor's own providers -``` +Screen readers hear each step with the widget's name and the zone, such as _Callout was dropped into the after-profile zone, position 1 of 2._ On a touchscreen, press and hold a widget to pick it up. -The markup lands where you put the zone; the React tree it belongs to is the editor's. One consequence is worth knowing: a block rendered in a zone reads React context from the **editor**, not from inside your page subtree. Context from your app root reaches it as usual - the router, the theme, the intl provider - but a provider you render _between_ your page and a `ContentZone` will not. No built-in or plugin block relies on that today, and if yours does, pass the value through the block's own data instead. +## Edit a widget -## The interaction model +Click a widget to select it. Its **Edit** and **Remove** actions appear on the widget, and its fields open in the sidebar under **Properties**, with **Duplicate** and **Remove**. The widget on the page changes as you type, so the page is the preview. -A block is a real component. It contains links, buttons, forms, embedded video - things that do something when you click them. In edit mode, clicking one has to mean _select this block_, not _follow this link_. +A widget with [variants](/docs/dev/widgets/block-variants) shows a **Variant** picker above its fields. Changing it changes the look and keeps the content. -VitNode solves that with three layers on every block, and one escape hatch: +**Back to widgets** at the top of the sidebar returns to the list and clears the selection. -| | | -| :---------------------- | :------------------------------------------------------------------------------------------------------------------------ | -| **`inert` container** | The block's own component renders inside an `inert` element, so nothing in it can be clicked, focused or tabbed to. | -| **Transparent overlay** | A full-size `<button>` above it. Clicking anywhere on the block selects it; screen readers get _"Select the Hero block"_. | -| **Floating actions** | Edit and Delete appear on hover, on focus, and while selected. There is no move button: the widget itself is the handle. | -| **Preview toggle** | Removes all three. The block becomes the real thing again, exactly as a visitor sees it. | - -The alternative - asking every block author to make their component "editor-aware" - was never on the table. A block's component is public rendering code, it is written by plugin authors who have never read the editor's source, and it must not grow a second mode. `inert` does the whole job in one attribute, from the outside. - -Preview is there because the illusion has to break sometimes. Does the video play? Does the accordion open? Is the call-to-action button the right size once it is actually hoverable? Toggle Preview, use the page like a visitor, toggle back. - -## Empty zones become visible - -An empty zone [renders nothing at all](/docs/dev/widgets/content-zones#empty-zones) in view mode - no wrapper, no placeholder, no gap. That is deliberate, and it stays true. - -In edit mode the same zone renders a dashed drop target with its name and an **Add block** button, because now somebody _is_ looking for a place to put something. - -That name is the zone's id made readable, so nobody has to decode your naming scheme: hyphens become spaces, the first letter is capitalised, and each `:` scope becomes its own part. - -| Zone id | Shown as | -| :------------------------ | :-------------------------- | -| `header` | Header | -| `before-profile` | Before profile | -| `settings:before-profile` | Settings · Before profile | - -The same name is used in the sidebar (_Adding to Before profile_) and in what screen readers announce during a drag. The id itself is untouched - it is still what gets stored and what you write in code. - -```text -view mode before-footer (nothing) -edit mode before-footer ┌ dashed box ┐ - │ + Add block │ - └────────────┘ -``` - -Finish editing and it disappears again. A page with eight placement points and two filled ones costs two in public, and shows all eight to the person arranging them. - -## The editing sidebar - -Everything the editor asks of you lives in one panel down the right-hand side: the block catalogue, the properties of whatever is selected, and the **Preview** / **Discard** / **Save** / **Finish editing** row at the bottom. There is no modal to open and dismiss, and no second panel competing for the same corner of the screen. - -The **whole site** is padded, not covered - the header, the page and the footer all move over together, the way the AdminCP dashboard moves over for its widget panel. So the sidebar never sits on top of anything: not the zone you are arranging, and not the user menu you opened it from. Below `md` the same sidebar becomes a bottom sheet, because 360px of a phone is not a sidebar, and the padding moves underneath the site instead. - -Both halves of that take 200ms and start on the same frame, in both directions: the page's edge and the sidebar's edge are the same number on every frame of it. **Finish editing** plays the whole thing backwards - the editing chrome goes at once, so the page is public again immediately, and the editor stays mounted just long enough for the sidebar to slide back off screen while the padding shrinks under it. - -Which frame that is comes from the **editor**, not from the seam that loads it. On a first open the editor chunk and the sidebar's own messages take a moment to arrive, and a shell that started widening the moment edit mode was _requested_ would slide the page over to make room for a sidebar that had not loaded yet - the page moving first and the panel catching up, once, on the first click only. So `editor/root.tsx` is what asks the shell for its room, and it cannot ask before it exists. Nothing moves until the sidebar is ready; then everything moves together. - -### One panel, three modes - -| | | -| :------------------ | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| **Widgets** | A search field, a **Layout** section holding the [Area](/docs/dev/widgets/layout-areas) entry, and every block this location accepts, grouped by the namespace that provides it. | -| **Properties** | The selected block's name and type in the header, next to Duplicate and Delete, then its [variant](/docs/dev/widgets/block-variants) picker and its fields. | -| **Area properties** | Duplicate, Ungroup and Delete area in the header, then the Area's **Layout** (Columns, Alignment, Distribution) and **Spacing** (Gap and the space around it). | - -Selecting a block switches to **Properties**; selecting an Area switches to **Area properties**. Each panel keeps its header pinned while its content scrolls, so the actions never scroll out of reach. The **←** button at the top (_Back to widgets_) switches back from either _and_ clears the selection, so the page stops ringing something you are no longer editing. Deleting the selection does the same thing on its own. - -The footer keeps the state of the page in one glance: a coloured dot and a word (**Saved**, **Unsaved changes**, **Saving…**, **Save failed**), **Discard changes** beside it, and underneath **Preview**, **Finish editing** and **Save** - the only blue button in the panel, lit up only when there is something to save. - -In **Preview** the sidebar collapses to a slim bar holding **Back to editing** and **Finish editing**. It never collapses to nothing - a preview you cannot leave is a trap - and the editor stays mounted behind it with every unsaved change intact. - -## Two ways to add a block - -A catalogue entry is a drag source _and_ a button, because those answer two different questions. - -| | | -| :----------- | :------------------------------------------------------------------------------------------------------------------------------- | -| **Drag it** | You care where it goes. An insertion line shows the exact position while you are still holding it. | -| **Click it** | You do not, or you already said where. It goes to the targeted zone - or, with nothing targeted, the first zone that accepts it. | - -**Add widget** on a zone is what sets that target: the sidebar switches to **Widgets**, its header shows _Adding to Sidebar_, and the zone stays lit until a block lands or you clear it. **Add widget** on an Area targets the Area instead, and the header says so - _Adding to an area in After profile_ - because naming the zone alone would be a lie about where the block is going. - -The catalogue's own **Layout** section sits above the plugin groups and holds one entry: **Area**. It works exactly like a block entry: click it and an empty Area drops into the target, or drag it and it lands where the insertion line says. An Area is not a registered block and has no type, so what travels with the drag is the layout itself rather than a block id - and dropping one onto an Area is refused, because [an Area cannot hold another Area](/docs/dev/widgets/layout-areas#areas-cannot-contain-areas). While an Area is the insertion target the Layout section disappears for the same reason. - -Either gesture inserts the block, selects it and opens **Properties** in one step, so the next thing you do is fill it in. The new block fades in where it landed and scrolls into view if it landed off screen - handy when a click sent it to the bottom of a long page. - -### How a drag feels - -A drag is meant to feel like picking something up, not like watching a diagram. - -| | | -| :----------------- | :----------------------------------------------------------------------------------------------------------------------------------------------------------- | -| **Picking up** | The card lifts a touch (2% larger, deeper shadow). The place it came from turns into a dashed slot, so you can see the gap you are about to leave. | -| **Moving** | Every zone that accepts what you hold lights up softly; the one under the pointer lights up fully. Zones that refuse it dim instead of shouting in red. | -| **Dropping** | The card settles into its new slot in 200ms, shrinking back to its real size on the way. Drop a catalogue card nowhere useful and it glides back home. | -| **Reduced motion** | With _reduce motion_ switched on in the operating system, all of the above happens instantly - same feedback, no movement. | - -### The catalogue follows the target - -`allowedBlocks` on a zone is what the catalogue offers. Not everything installed - everything **allowed** in that location. - -```tsx -<ContentZone - id="sidebar" - allowedBlocks={['core:text']} - blocks={content.sidebar} -/> -``` +## Preview, save and finish -Target that sidebar and the catalogue shows exactly one block. Clear the target and it shows the union of what the page's zones accept, because now any of them could be the destination. Same `allowedBlocks` value, same format as a [`blocks()` field's `allowed`](/docs/dev/widgets/blocks-field#restricting-what-may-go-in), now doing a third job. +The sidebar footer shows the page's state (**Saved**, **Unsaved changes**, **Saving…**, **Save failed**) and these buttons: -A zone that allows nothing the installation provides says so, rather than offering an empty list. +| Button | What it does | +| --- | --- | +| **Preview** | Hides outlines and handles, so links and buttons inside widgets work. **Back to editing** returns. | +| **Discard changes** | Puts every zone back to how the page loaded. | +| **Save** | Stores the changes and stays in edit mode. A **Page saved** toast confirms it. | +| **Finish editing** | Leaves edit mode. With unsaved changes it asks first: **Save and finish**, **Leave without saving** or **Keep editing**. | -<Callout title="The catalogue is not the write boundary"> - The allowlist on a zone describes a location. The one on the `blocks()` field - **enforces**, on every create and update. The catalogue filtering by the - zone's copy is a courtesy to the person editing - the field is what refuses a - block that should never have been stored. -</Callout> - -## Drag and drop - -Grab a block anywhere on it, or a catalogue entry anywhere on its card, and move it. A click without moving still just selects the block, and an Area is dragged by its own frame - its header or the space around its columns - so grabbing a widget inside an Area moves that widget, not the Area. On a touchscreen a swipe keeps scrolling the page; press and hold to pick a widget up. - -- **Reorder** inside a zone: drop it above or below a sibling. -- **Move across zones**: drop it into another zone, or onto one of that zone's blocks to land at that position. -- **Into an empty zone**: drop it anywhere on the dashed placeholder. - -Identity is the block instance id throughout - never an array index - so a block that crosses a zone keeps being the same block, with the same data, and the properties panel does not blink. - -### Before or after, never into a block - -Dropping onto a block inserts **before** it or **after** it, decided by which half of it the pointer is over. That blue line is the answer, shown before you let go. - -Nothing ever lands _inside a block_. Dropping **into** something is the Area's gesture: drag a block onto an [Area](/docs/dev/widgets/layout-areas) and it becomes one of that Area's children, at the position the line shows. Drag a child back out and the Area keeps the rest. An Area dropped onto another Area is refused out loud - _An area cannot hold another area_ - rather than silently landing beside it. - -### The keyboard does all of it too - -Drag and drop that needs a mouse is drag and drop half the people cannot use, so every block is a real button and every gesture has a key. - -| Key | What it does | -| :------------------------ | :-------------------------------------------------------------------- | -| <kbd>Tab</kbd> | Reaches each block (and each Area's header), like any other button. | -| <kbd>Enter</kbd> | Selects the block and opens its properties. | -| <kbd>Space</kbd> | Picks the block up - and, the second time, drops it. | -| <kbd>↑</kbd> <kbd>↓</kbd> | Moves it through the positions of its zone and on into the next zone. | -| <kbd>Esc</kbd> | Puts it back where it started. | - -The blue insertion line follows a keyboard drag exactly as it follows a pointer, because both are the same line: the editor asks its drop resolver where this block _would_ land right now and draws the answer. Nothing is measured twice, so the line cannot disagree with the drop - and when the answer is "nowhere" (the zone refuses the block, or it already sits in that spot) there is no line, which is the honest thing to draw. - -Screen readers get the same story in words. The editor localises dnd-kit's announcements through `core.editor.dnd.*`, so picking a block up, passing over a zone, being refused by one, and landing all read out with the **block's name and the zone's id** rather than the instance ids dnd-kit would otherwise spell aloud. - -```text -Picked up Callout. -Callout is over the after-profile zone, position 1 of 2. -The sidebar zone does not accept Callout. -Callout was dropped into the after-profile zone, position 1 of 2. -``` - -### An incompatible target refuses - -Drag an `example:callout` toward a zone that allows `core:text` only and the zone turns red and says it does not accept what you are dragging. Let go and nothing happens: a block stays where it was, a catalogue entry is simply not inserted. - -The refusal is computed from the same `isBlockAllowed` the field and the catalogue use. One allowlist format, one function that evaluates it, three places that ask. - -## The properties panel - -Select a block and its fields appear in the sidebar: text, textarea, number, boolean, enum, date-time, and groups of those - the [field kinds a block may hold](/docs/dev/widgets/defining-a-block#which-fields-a-block-may-hold), as declared by `defineBlock`. The panel builds itself from the block definition, so a block that adds a field gets a control for it with no editor change at all. - -Typing updates the block on the page **as you type**. There is no Apply button, and no preview pane to compare against, because the page _is_ the preview. - -A block that declares more than one [variant](/docs/dev/widgets/block-variants) gets a **Variant** picker above those fields. Picking one is a separate action from editing content: it keeps the instance's id, leaves `data` untouched, and does not reset a field you were halfway through. A block with one variant, or none, shows no picker - there is nothing to decide. A stored variant the block no longer offers is named in the panel, with the declared ones still offered beside it. - -### Area properties - -Select an [Area](/docs/dev/widgets/layout-areas) rather than one of its children and the same sidebar shows its layout in two groups - **Layout** (**Columns** as a one-click 1–4 switch, **Alignment**, **Distribution**) and **Spacing** (**Gap** and the space around the Area) - and re-lays the page out as you pick them. **Duplicate area**, **Ungroup** and **Delete area** sit in the panel's header. - -Ungroup and Delete are deliberately different buttons. Ungroup removes the container and leaves the blocks where the Area was; Delete takes them with it, and asks first, offering _Ungroup, keep blocks_ in the same dialog. An Area holding nothing is deleted without the interrogation, because there is nothing to lose. - -A block whose stored data does not match what its definition declares is not editable here - it is flagged on the page instead. Editing it would mean guessing which half is wrong, and guessing is how content gets quietly destroyed. - -## Content the editor cannot read - -A zone is handed whatever is stored in it, and storage is older than any editor. A field can hold a value from a migration that never finished, from a plugin that wrote its own shape, or from a hand-edited row. The editor has to have an answer for that, and the answer is **never delete it**. - -Every value a zone mounts with is sorted into one of two piles: - -| | | -| :-------------------------------- | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| **A block with bad data** | `{ id, type, data }` is intact and `type` is registered, but `data` does not match the fields. It stays a block: selectable, movable, deletable. | -| **Something that is not a block** | No usable `id`, no `type`, an id already used **by an earlier block in the same zone**, or not an object at all. Nothing can be inferred from it, so nothing pretends to. | - -The first pile keeps working exactly as before - the block renders an [`InvalidBlock`](#the-properties-panel) placeholder in place of its component, and its own shell stays draggable and keeps its delete button. Its data is bad; its identity is not. - -The second pile never becomes a content node. It is kept beside them, not among them: - -```ts -interface EditorZoneState { - nodes: readonly ContentNode[] - invalid: readonly { index: number; value: unknown }[] - // … -} -``` - -A `ContentNode` is a block instance or an [Area](/docs/dev/widgets/layout-areas). An Area whose stored `layout` is not one the editor recognises, or which holds an Area of its own, lands in the second pile with everything else: shown as-is, never rewritten. - -Nothing in the editor - no reducer action, no drop resolver, no block component - is ever handed one of those values. The zone shows them at the top, prints what each one looks like, and offers exactly one action per entry: **Remove invalid entry**. - -### One id per zone, not one id per page - -An instance id has to be unique **within its zone**, and only within its zone. Two blocks sharing an id in one zone are indistinguishable there, so the second one goes in the opaque pile above. Two blocks sharing an id in two _different_ zones are perfectly valid - the Content Engine validates a blocks field on its own, so nothing has ever promised otherwise, and a zone copied from one field into another would trip over any rule that did. - -The editor therefore never identifies a block by its id alone. Selection, updates, deletion, duplication, moves and drag-and-drop all carry the zone: - -```ts -interface EditorBlockRef { - blockId: string - zoneId: string -} -``` - -Drag-and-drop ids are built the same way - `blockDraggableId({ zoneId, blockId })` percent-encodes both halves, so an id survives a round trip whatever characters it contains - and the drag payload carries the ref structurally rather than being parsed back out of a string. Selecting the copy of `X` in the sidebar zone can never edit the copy of `X` above the profile form. - -Zone ids run the other way: a zone id is a _place_, so one page may only have one of each. Two `<ContentZone>`s claiming the same id while the editor is open is a [developer error](/docs/dev/widgets/content-zones#one-id-one-place-on-the-page), not a merge. - -### Saving is blocked until you decide - -While a zone still holds one, the whole editor refuses to save. The **Save** button is disabled, the sidebar footer explains why, and the adapter is not called even from **Save and finish**: - -> This zone contains invalid or legacy content that the Visual Editor cannot safely save. Resolve or explicitly remove the invalid content before saving. - -That is deliberate, and it is the whole point of the pile. The save payload is a **replacement** array per zone - the editor hands over the blocks it understands, and the API stores exactly that. If a value the editor could not read were quietly left out of that array, editing an unrelated block in the same zone would delete it. Storing `[A, malformed, B]` and saving after a change to `A` would write `[A2, B]`, and nobody would ever be told. - -So removal is a decision you make, one entry at a time, and it is an edit like any other: **Discard** brings the entries back, and the page is dirty until you save. - -## Zones that leave the page - -A page is allowed to change its mind. A tab, a feature flag, a route transition - any of them can stop rendering a `ContentZone` while edit mode is still on, and the editor has to have an answer that is not "persist it anyway". - -When a zone's outlet unmounts it is **removed from the document**: out of `zones`, out of `order`, and therefore out of the next save payload entirely. A stale zone can never be written back. If it owned the selection, the selection clears and the sidebar returns to **Widgets**; if it was the target of **Add widget**, that target clears too. - -What happens to unsaved work in it depends on whether there was any: - -| | | -| :--------------------- | :----------------------------------------------------------------------------------------------------------------------------------- | -| **No unsaved changes** | It is dropped silently. There was nothing to lose. | -| **Unsaved changes** | It is still dropped - the zone is gone, there is nowhere to put them back - but the sidebar footer names it, so it is never _quiet_. | - -That is the rule: an unmounted zone is out of the editable document, and losing work to it is reported rather than blocked. Blocking **Save** would trap you on a page whose zone no longer exists, and keeping the zone would mean writing content the page has stopped showing. Bring the zone back and it re-mounts from whatever the page hands it, with a fresh baseline. - -What the _host_ does with the gap is the host's call, and it matters. The save payload only carries zones the editor currently holds, so a save must write **only what it names** - never a whole field set composed on top of a fallback, which is how a zone that is off the page gets reset to something nobody chose. [Editable Pages](/docs/dev/widgets/editable-pages#the-two-routes) does exactly that: a zone the payload does not mention is not written, because each zone is its own row. - -## Saving is the host's job - -The editor never fetches. Not once. The only way anything leaves it is the adapter you passed: - -```ts -type VisualEditorSnapshot = Readonly<Record<string, readonly ContentNode[]>> - -interface VisualEditorSaveInput { - changedZoneIds: readonly string[] - expectedZones: VisualEditorSnapshot - zones: VisualEditorSnapshot -} - -interface VisualEditorSaveResult { - revision?: string - zones?: VisualEditorSnapshot -} - -interface VisualEditorAdapter { - save: ( - input: VisualEditorSaveInput, - ) => Promise<VisualEditorSaveResult | void> | VisualEditorSaveResult | void -} -``` - -`zones` holds every zone on the page keyed by its id; `changedZoneIds` names the ones that actually differ from what loaded; `expectedZones` holds, under the same keys, the **baseline** each zone started from - what was there before anybody dragged anything. Resolve and the editor marks itself clean and toasts. Throw or reject and it shows an error and keeps the changes, so nothing is lost to a failed request. - -Send the baseline with the write and your API can answer the only question that matters when two people are arranging one page: _is the thing I am replacing still the thing I was given?_ Zones are independent, so two moderators editing different zones both succeed, while a write to a zone that moved underneath somebody is refused instead of silently winning. [Editable Pages](/docs/dev/widgets/editable-pages#two-moderators-one-page) does exactly that with a `409`, and the editor says so - _Somebody else saved this page first_ - while keeping every change you made. - -The one rule the contract asks of you: **save what you were handed**. The editor rebases its idea of "unchanged" onto the snapshot it sent, not onto the state it is in when the promise resolves - so a word typed while the request was in flight is still unsaved afterwards, exactly as it should be. - -`zones` is a **complete** picture of the page, not a patch: every zone the editor mounted is in it, emptied ones included. `changedZoneIds` names the ones that differ from what loaded - send those, and leave everything else alone at the other end. What you must not do is send a partial write and let the rest fall back to defaults. - -<Callout title="You probably do not write this by hand"> - If the page declares its zones - and it usually should - - `createContentEditorAdapter` builds this payload for you, from the same page - definition the server reads. See [Editable - Pages](/docs/dev/widgets/editable-pages). The rest of this section is the - contract underneath it, and what to honour if you are writing an adapter for - somewhere else. -</Callout> - -```tsx -const adapter = useMemo<VisualEditorAdapter>( - () => ({ - save: async ({ changedZoneIds, expectedZones, zones }) => { - await fetcher({ - plugin: '@vitnode/example', - method: 'patch', - module: 'pages', - path: '/{id}/zones', - args: { - param: { id: page.id }, - json: { changedZoneIds, expectedZones, zones }, - }, - }) - }, - }), - [page.id], -) -``` - -### Answer with what you stored - -`save` may resolve with nothing, and the editor is happy either way - but it is better if it does not. What the client sent and what the server kept are not always the same thing: an API is allowed to reorder, normalise, drop or rewrite what it is given. If the editor rebases onto its own input, it goes clean against a version that never existed, and the next save pushes the stale copy back. - -So return what you stored: - -```tsx -const [zones, setZones] = useState(loaderData.zones) - -const save = async (input: VisualEditorSaveInput) => { - const payload = await writeChangedZones(input) - - setZones((current) => ({ ...current, ...payload.zones })) - - return { zones: payload.zones } -} -``` +The browser asks the same question when you navigate away with unsaved changes. If somebody else saved the same zone since you opened the editor, the server refuses the save with **Somebody else saved this page first**, and your changes stay in the editor. Two people editing different zones of one page both succeed. -The editor then adopts that snapshot as its baseline **and** as what you are looking at - unless you edited during the request, in which case your edit stays and the zone remains dirty against the new baseline. Both halves matter: the first stops the stale re-send, the second is the [save race](#save-stays-finish-editing-leaves) rule that has always applied. +## When the editor refuses to save -Updating the page's own state does the other half of the job: **Finish editing** shows the saved page immediately, with no reload, because the page underneath is already the new one. +- **A zone holds content the editor cannot read**, such as an entry without a widget type. The zone says _This zone holds content the editor cannot read_ and **Save** is disabled. Choose **Remove invalid entry** for each one. The editor never drops content silently. +- **A widget's stored data no longer matches its fields**, for example after a plugin update. The widget shows _Widget data needs fixing_. You can move or remove it, but not edit it. +- **A zone leaves the page while you edit**, for example behind a tab. It leaves the editor too, and the footer names it if it had unsaved changes. -This belongs to the page, not to the editor. The editor owns no layout state and never will: it is handed blocks, it hands back blocks, and the content's owner decides what is true. +## What the editor does not do -### Content that changes underneath the editor +- No areas inside areas, and no widgets that contain zones. +- No drafts or revisions. **Save** writes the live layout. +- No per-breakpoint layouts. Widgets are responsive on their own. +- No images, files, relations or rich text in widget fields yet. +- No live collaboration, so nobody sees anybody else editing. +- No dimming of the parts of the page that cannot be edited. -The same problem arrives from the other direction. A router invalidation, a refetch, or the `setZones` above all re-render your page with different `blocks`, and a zone that is already mounted has to decide what to do with them. +## Visitors never download it -The rule is the one you would write yourself: +The editor's code loads on the first click of **Edit widgets**, never before. A visitor who only reads the page downloads none of it; a test in `@vitnode/core` fails the build if the editor ever reaches the public bundle. -| | | -| :---------------- | :------------------------------------------------------------------------------------------------------------------------------------------------ | -| **Zone is clean** | The incoming content replaces both the working copy and the baseline. The zone adopts it and stays clean. A selection whose block is gone clears. | -| **Zone is dirty** | Nothing about its content moves. Local edits win until **Save** or **Discard** resolves them. Only the allowlist and registry still refresh. | - -There is deliberately no merge. Two block arrays cannot be reconciled without inventing an answer, and inventing one is how an editor loses a paragraph somebody typed. - -### A failed load is not an empty page - -Loading has a trap that is easy to walk into and expensive to walk out of. A route answers three different "there is nothing here" shapes, and they mean three different things: - -| | | -| :-------------------------- | :--------------------------------------------------------------------------------------------- | -| **200, an empty zone** | Nothing is in that zone. Editing and **Save** are fine. | -| **200, nothing stored yet** | The page has never been rearranged, so its declared defaults come back with `updatedAt: null`. | -| **Anything non-2xx** | What is stored is **unknown** - an auth failure, a database error, a dropped connection. | - -Treating the third as the first is how a page overwrites content nobody meant to touch: the load fails, something plausible renders as if it had been read, somebody tidies a heading, presses **Save**, and the real stored layout is gone. The editor cannot catch this, because by then it was handed a perfectly valid set of blocks. - -So the playground loader refuses: - -```ts -if (!stored.ok) { - throw new Error(`Reading this page's layout answered ${stored.status}…`) -} -``` - -The router's `defaultErrorComponent` takes it from there, and **Edit widgets** is never reached. The API holds the same line from the other side: a stored row that cannot be read throws rather than answering with an empty layout. - -The second row is a _success_, and the distinction is the whole reason a page declares its defaults rather than inventing them at render time. Defaults that came from the declaration are content somebody shipped on purpose, so the first **Save** is entitled to replace them; defaults invented because a read failed are content nobody chose. [Editable Pages](/docs/dev/widgets/editable-pages#the-two-routes) has the longer version. - -If throwing does not suit your route, return an explicit error state and disable editing on it. What you must not do is hand the editor a writable copy of something you did not actually read. - -### Save stays, Finish editing leaves - -Two buttons, two different jobs, and it is worth being blunt about which is which. - -| | | -| :--------------------- | :------------------------------------------------------------------------------------------------------------------------------------- | -| **Save** | Runs the adapter and **stays in edit mode**. The footer goes from _Unsaved changes_ to _Saved_ and you carry on arranging. | -| **Finish editing** | Leaves edit mode. With nothing unsaved it just leaves. | -| **Finish while dirty** | Asks first: **Save and finish** (saves, then leaves - and stays put if the save fails), **Leave without saving**, or **Keep editing**. | - -Saving is not an exit, because the most common thing anybody does after saving is keep going. And leaving is not a save, because an editor that silently writes on the way out is an editor nobody trusts. **Discard** is the third option and the only one that throws work away - it puts every zone back to what loaded. - -The same question is asked by the browser: navigate away with unsaved changes and the guard prompts before the page goes. - -### Why there is no zones table - -The obvious alternative is a `core_content_zones` table that stores `(zoneId, blocks)` for every page in the installation, and a generic save endpoint that writes to it. It was not built, on purpose. - -Block content already lives somewhere: in a [`blocks()` field](/docs/dev/widgets/blocks-field) on a content type, in a plugin's own settings row, in whatever column the page loads it from. A generic zones table would be a **second** home for the same data, and then two places to migrate, two permission models, two revision histories, and a stubborn class of bug where the page renders one copy and the editor saves the other. - -The adapter is the alternative: the page that knew how to _load_ its zones is the page that knows how to _save_ them. It writes to its own column, through its own API, behind its own permission check. - -What is generic is the _wiring_, not the storage. [Editable Pages](/docs/dev/widgets/editable-pages) is the one exception, and it earns it: a settings screen's zones belong to no record, so core keeps them in an internal table keyed by the page id - one row per page, holding only the zones somebody customised - and the page declares its zones instead of mapping them. A zone whose blocks _do_ live on a content type stays where it always lived, and the page that knew how to load it is still the page that knows how to save it. - -## What the editor deliberately does not do - -Being clear about the edges is more useful than a roadmap. - -| | | -| :-------------------------------- | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| **Nested areas** | An [Area](/docs/dev/widgets/layout-areas) holds blocks, one level deep. No areas inside areas, and no block that contains a zone of its own. | -| **Drafts and revisions** | Save writes. There is no "unpublished version of this page" concept in the editor. | -| **Permission UI** | No roles screen. Who may edit is your page's answer, and the save route's; an [editable page](/docs/dev/widgets/editable-pages#can-i-edit-this) names the moderator permission it wants, but granting it is the ordinary staff screen's job. | -| **Layout administration** | No AdminCP section listing which pages have been rearranged, no reset-to-default, no export or import, no copying one page's layout onto another. [Deliberately left to a possible V2](/docs/dev/widgets/editable-pages#where-a-layout-is-stored) - the storage shape makes each of them a query on one table, but none is built. | -| **Live collaboration** | No shared cursors, no presence, no merging two people's edits to **one** zone. Different zones of one page do merge, and a stale write to the same zone is refused rather than applied - see [two moderators, one page](/docs/dev/widgets/editable-pages#two-moderators-one-page) - but nobody watches anybody else arrange anything in real time. | -| **File and relation fields** | The properties panel edits the [field kinds a block may hold](/docs/dev/widgets/defining-a-block#which-fields-a-block-may-hold). Blocks cannot hold uploads or references yet, so neither can the panel. | -| **Responsive breakpoint editing** | One arrangement, rendered responsively by the blocks themselves. No per-breakpoint layouts. | -| **Rich text** | A `text` or `textarea` field is a plain string, and it stays one. You type into it in the properties panel, and no block field holds bold, links or lists. Tiptap ships in this repository and is deliberately nowhere near a block. | - -Everything in that table is a _later_ decision rather than a closed door - but none of it is built, and none of it is implied by the APIs above. - -## Known limitations - -The section above is about scope. This one is about rough edges in the part that _is_ built - the things you will notice, written down so you do not have to discover them. - -**A pointer drag's spoken position lags the line it draws.** The insertion line updates continuously, but the screen-reader announcement is only refreshed when the block underneath the pointer changes - move from a block's top half to its bottom half and the line jumps, the sentence does not. That is deliberate: the alternative is announcing on every pixel of movement, which floods a live region until it is useless. Keyboard dragging is unaffected, because every arrow key changes the target. - -**Locked application UI is not dimmed.** In edit mode the zones are outlined and labelled, and everything else on the page - your forms, your navigation, your buttons - looks exactly as it did. Nothing says "this part is not editable" except the absence of an outline. - -Dimming it would mean fading the page and un-fading the zones, and a descendant cannot undo an ancestor's `opacity` or `filter`: those create a new compositing context that the zone inside it is stuck in. The only other route is selector-based - "dim everything that does not contain a zone" - which would have to make assumptions about a page's DOM that VitNode does not get to make, since the page is yours. So the editor emphasises the zones rather than de-emphasising the page, and a host that wants more can style `[data-vitnode-zone]` and its surroundings itself. - -**Mobile is a bottom sheet, and only that.** Below `md` the sidebar becomes a sheet so it does not eat a phone screen. The page reserves room for it - `--editor-sheet-height` is padding on the canvas below `md` and the width of the sidebar above it, so the last zone always scrolls clear of the sheet and Preview takes both away - but nothing is _tuned_ for touch: a drag competes with page scrolling, and press-and-hold is the only way to tell a drag from a scroll. Arranging a page on a phone is possible. Enjoyable is a later stage. - -**Blocks themselves do remount when you enter edit mode.** The page around them does not - that is the whole point of the [stable boundary](#turning-it-on-does-not-restart-your-page) - but a `ContentZone` genuinely swaps what it renders, from `ContentRenderer` output to the editor's portal, so each block component is built again. A block holding transient state of its own restarts with it: a playing video, an open accordion, a carousel's position, a `useState` counter. Content survives, because content lives in the block's data; ephemeral UI state does not. Closing that gap means hoisting block instances above the view/edit split, which is a bigger change than this stage is willing to make. - -**A zone's blocks render in the editor's React tree, not your page's.** The consequence of the portal described [above](#then-how-do-zones-become-editable): context from your app root reaches a block as usual, context you provide between your page and a `ContentZone` does not. Nothing ships today that needs it, and the fix if you do is to put the value in the block's data. - -**There is no end-to-end test harness.** The pure logic is covered by vitest - the reducer, the drop resolver and its insertion placement, zone drop states, the save input, the catalogue, and the module-graph boundary test that keeps the editor out of public bundles. What no test covers is a real browser actually dragging something, so the [manual checklist](#verifying-it-yourself) is the regression suite for gestures. `apps/web` has no test runner configured at all, which is why the checklist lives in this page rather than in a spec file. - -## What used to be an extension point - -Two rows of that table were designed _around_ rather than merely skipped, and both have since landed. They are worth knowing as a pair, because they answer two halves of one question - and because keeping them apart is what stops a block definition turning into a settings screen. - -### Presentation variants - -How a block looks, as opposed to what it says. A wide hero and a narrow hero used to be either two block types or a `width` field sitting in the middle of the copy. A [variant](/docs/dev/widgets/block-variants) pulls them apart: - -```ts -interface BlockInstance { - data: BlockData - id: string - type: string - variant?: string -} -``` - -`variant` is optional, and nothing was migrated: an instance stored without one takes the block's `defaultVariant`, exactly as it rendered before. The workaround this replaces - a `variant`, `style` or `size` field declared on the block and stored in `data` - now has a real home, and moving one there is a data migration you write once and never again. - -### Layout areas - -How blocks sit next to each other, which was never one block's business. An [Area](/docs/dev/widgets/layout-areas) is a node in the zone's own list: - -```ts -interface BlockAreaInstance { - children: readonly BlockInstance[] - id: string - kind: 'area' - layout: { - columns: 1 | 2 | 3 | 4 - gap?: string - align?: string - justify?: string - } -} -``` - -It is not a block: no registry entry, no `allowedBlocks` entry, no fields of its own. It claims the drop-into gesture that was held back for exactly this, and it stops at one level - **an Area cannot contain an Area** - because nesting needs depth limits, cycle detection and an allowlist story that one level does not. - -Three concerns, kept separate: **data** is what the block says, **variant** is how this instance presents it, and **an Area's layout** is how a container arranges its children. Mixing them is what turns a block definition into a settings screen. - -## Verifying it yourself - -`/example/zones` is the manual test page. It has four zones - two around a locked profile form, one narrower sidebar, one empty - blocks from two plugins, a block stored with a variant, an Area holding two blocks, and a second Area left empty. It draws no button of its own: **Edit widgets** is in the user menu, for anybody holding the page's own moderator permission. It is an [editable page](/docs/dev/widgets/editable-pages), so your changes survive a reload; before anybody saves, it renders the layout the page declares. - -Sign in first: the action is gated on the moderator permission **Widgets → can edit** from the example plugin, and a root role holds it without being granted it. - -<Steps> -<Step> - -**Load the page** - -```bash -pnpm dev -``` - -Open [http://localhost:3000/example/zones](http://localhost:3000/example/zones) with DevTools → Network open, filtered to **JS**. - -</Step> -<Step> - -**Confirm no editor chunk was loaded** - -Reload and filter the request list to `/src/editor/`. **Nothing.** Not one editor module is requested for a page nobody is editing. - -<Callout title="One dev-server caveat" type="warn"> - In this repository you will also see `@dnd-kit` on *every* route, `/` - included. Those come from the AdminCP dashboard grid, which the development - module graph reaches whatever page you open - not from the zones. The honest - measurement is the production build [at the end of this - page](#against-a-production-build). -</Callout> - -</Step> -<Step> - -**Open the user menu and click Edit widgets** - -It is in the avatar menu in the header, under your name - not on the page. Walk to `/discover` first and look again: it is gone, because no editable page is on screen there. - -| | Expected | -| :--------- | :---------------------------------------------------------------------------------- | -| Network | Exactly one new chunk, on this click and not before. | -| Zones | Dashed outlines with the zone id in the corner. | -| Layout | Nothing jumped: the whole site slid over while the sidebar slid in. | -| Page state | The profile form still holds what you typed, and the counter still holds its count. | - -Click **Finish editing**, then **Edit widgets** again: the second time, **no** request is made. The chunk is already in memory. - -</Step> -<Step> - -**The sidebar appears on the right** - -| | Expected | -| :----- | :------------------------------------------------------------------------------------------------------------------------------------------ | -| Header | _Available Widgets_. | -| Body | A search field and the installed blocks, grouped by namespace. | -| Footer | _No changes yet_, and the Preview, Discard, Save and Finish editing buttons. | -| Canvas | The page, the header and the footer are all beside the sidebar, not under it. Narrow the window below `md` and the sidebar becomes a sheet. | - -</Step> -<Step> - -**Search the available widgets** - -Type `call` into the search field: the list narrows to `example:callout`. Clear it and everything comes back. Nothing about the page or your changes moves while you search. - -</Step> -<Step> - -**Drag a block from the sidebar into the empty zone** - -`before-footer` is empty, so in edit mode it is a large dashed placeholder. Drag `core:text` from the sidebar onto it. - -It lands, it is selected, and the sidebar is already showing its **Properties**. - -</Step> -<Step> - -**Drag a block between two existing blocks** - -Go **← Available Widgets**, then drag `core:cta` over `before-profile` and hover the _top half_ of its second block. A blue insertion line appears above that block; hover the bottom half and it moves below. - -Drop on the top half and the new block is second, not last. - -</Step> -<Step> - -**Move an existing block between compatible zones** - -Drag the `core:text` block out of `before-profile` and into `after-profile`. It keeps its content and its instance id - the properties panel does not flicker on the way. - -</Step> -<Step> - -**Move a block with the keyboard only** - -Put the mouse down. <kbd>Tab</kbd> to a block, press <kbd>Space</kbd>, then <kbd>↓</kbd>. - -The blue insertion line appears under the block below, exactly as it does for a pointer - keep pressing <kbd>↓</kbd> and it walks into the next zone. <kbd>Space</kbd> drops the block where the line is; <kbd>Esc</kbd> puts it back. Whatever the line said, that is where the block lands. - -</Step> -<Step> - -**Watch an incompatible zone refuse** - -Drag `example:callout` - from the sidebar, or from a zone that holds one - toward `sidebar`, which allows `core:text` only. - -The zone turns red and says it does not accept the block. Release: nothing moves and nothing is inserted. Now press **Add block** inside that sidebar zone and confirm the catalogue offers `core:text` and nothing else. - -</Step> -<Step> - -**Select a block** - -Click any block on the page. Expect a ring around it and a floating row of actions - Edit, Delete - in its corner. - -</Step> -<Step> - -**The sidebar switches to Properties** - -Same panel, different mode. Expect the block's name, its type underneath, its fields, a Duplicate and a Delete at the bottom, and a **← Available Widgets** at the top. - -</Step> -<Step> - -**Edit a field** - -Type into the heading or body field of the selected block. - -</Step> -<Step> - -**The block updates immediately** - -The block on the page changes **while you type** - no Apply button, no delay - and the sidebar footer switches to _Unsaved changes_. - -</Step> -<Step> - -**Go back to Available Widgets** - -Press **← Available Widgets**. The catalogue returns and the ring on the page disappears, because that action clears the selection as well as the panel. - -</Step> -<Step> - -**Switch a variant** - -Select the `example:features` block in `before-profile`. Above its fields sits a **Variant** picker holding _Grid_, _List_ and _Compact_. - -Move through all three. The block re-renders each time, and every word in the fields underneath stays exactly as it was - the variant is not content, so nothing about the content is touched. The footer says _Unsaved changes_, because the page did change. - -</Step> -<Step> - -**Add an Area, and move blocks in and out of it** - -Go **← Available Widgets**. Above the plugin groups is a **Layout** section holding one entry: **Area**. Click it - an empty two-column Area lands in the page. - -Now drag a root block onto it: it becomes a child, and the Area re-flows around it. Drag it back out to the zone and the Area keeps the rest. Reorder two children inside one Area and only their order changes. Try to drag an Area onto another Area: it is refused, out loud. - -</Step> -<Step> - -**Area properties** - -Click the Area's own shell rather than one of its children. The sidebar shows **Area properties**: Columns, Gap, the space above/below and left/right, Alignment and Distribution - each re-laying the page out as you pick it - with **Duplicate area**, **Ungroup** and **Delete area** under them. - -Gap and the two spacing controls are sliders measured in **pixels, 0 to 100**. Dragging one moves the page under the thumb. Gap is the room between the Area's own children; the other two are its outer margin, stored beside its columns as `marginX` and `marginY`. Pixels a person picked are not design tokens, so all three are written as an inline `style` rather than a utility class - and an Area asking for no margin renders exactly the markup Areas always have, so nothing on an existing page moves. - -An Area laid out before this - one storing `gap: "md"` rather than `gap: 16` - still reads. Every door into a layout resolves the old names (`none` 0, `sm` 8, `md` 16, `lg` 32) to their number, and the next save writes the number down, so nothing has to migrate the table. - -An empty Area draws one **Drop widgets here** cell per column, so a four-column Area shows the four places a widget can go before anything is in it. - -Press **Ungroup**: the container goes and its blocks stay, at the position the Area held. Undo that by adding an Area back and filling it, then press **Delete area**: it asks first, and offers _Ungroup, keep widgets_ in the same dialog. Delete the empty Area the page ships with and it does not interrogate you - there is nothing inside to lose. - -</Step> -<Step> - -**Preview** - -Press **Preview**. Outlines, handles and overlays go; the sidebar collapses to a slim bar with **Back to editing** and **Finish editing**. Click a link inside a block - it works now, because the `inert` wrapper is gone. - -**Back to editing** restores everything, unsaved changes included. - -</Step> -<Step> - -**Save** - -Press **Save** in the sidebar footer - not the profile form's own Save button, which is there to prove the form still works. - -| | Expected | -| :------ | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| Toast | _Page saved_. | -| Footer | Switches from _Unsaved changes_ to _Saved_. | -| Header | The line under the title stops saying the page ships these widgets and names the time it was rearranged. | -| Payload | A **Last save payload** block appears at the bottom of the page - open it and read the JSON that went to `PUT /pages/layout`. Edit one zone and it names that zone alone. | - -Check that `changedZoneIds` lists only the zones you actually touched. - -</Step> -<Step> - -**Finish editing, without reloading** - -Press **Finish editing**. The public page is back: no outlines, no sidebar, and `before-footer` is invisible again if you left it empty. - -The part worth checking is that **the page you are looking at is the page you saved** - the block you added, the one you moved and the text you typed, all in view mode, with no refresh anywhere. Then reload, and confirm you get the same thing a second time. The first comes from the API's answer to the save; the second from the route loader. - -Now do it the other way round: click **Edit widgets**, change something, and press **Finish editing** without saving. The dialog offers **Save and finish**, **Leave without saving** and **Keep editing** - and note that **Save** on its own, back in the footer, would have kept you in edit mode instead. - -</Step> -</Steps> - -### While you are in there - -- **Nothing on the page restarts.** Type a name into the profile form, press the **Local state** counter a few times, then **Edit widgets** and **Finish editing**. Both survive, both directions, because the editor mounts beside your page rather than around it. -- **Take the sidebar zone off the page** with the button above the zones, while the editor is open. It leaves the editor with it: the outline goes, an **Add widget** target pointing at it clears, and a selected block inside it returns the sidebar to _Available Widgets_. Save afterwards and the stored sidebar keeps what it had - the editor's copy is not written back, and it is not reset to defaults either. Edit it first and the footer names it rather than losing the change quietly. Put it back and it returns with a fresh baseline. -- **Discard** puts every zone back to what loaded, and the footer stops saying _Unsaved changes_. -- **An empty Area is edit-mode only.** The page ships one. In edit mode it is a two-column drop target; press **Finish editing** and it leaves no markup behind at all, exactly like an empty zone. -- **Add widget inside an Area** targets the Area, not the zone: the sidebar header reads _For: an area in after-profile_, and the **Layout** section disappears while it does, because an Area cannot hold another Area. -- **Duplicate** drops a copy directly below the original with its own instance id; **Delete** takes it away and returns the sidebar to the catalogue. -- The profile form in the middle must have **no** outline, **no** drag surface and **no** overlay, and its input must still take focus and type. It is application code. -- Navigating away with unsaved changes - a header link, or the back button - prompts, with **Keep editing** and **Leave without saving**. -- **Below `md`** the sidebar becomes a bottom sheet and the canvas gains padding to match: scroll to the end and the last zone, its **Add widget** button included, clears the sheet completely. **Preview** takes the padding away with the sheet, so the page is exactly as a visitor sees it. -- **Seed something unreadable** and the refusal is easy to see. Save the zone once first, so there is a row to corrupt: - - ```bash - psql "$POSTGRES_URL" -c 'update core_page_layouts set "zones" = \'{"before-profile":[{"foo":"bar"}]}\' where "pageId" = \'example:settings\';' - ``` - - Reload, press **Edit widgets**: `before-profile` shows _This zone holds content the editor cannot read_ with `{"foo":"bar"}` printed under it, **Save** is disabled whatever else you change, and the footer says why. Press **Remove invalid entry**, and Save comes back. **Discard** puts it straight back. - -### Against a production build - -```bash -pnpm build -pnpm start -``` - -Then confirm the editor never reached a visitor: - -```bash -curl -s http://localhost:3000/example/zones | grep -cE 'dnd-kit|editor/root' -``` +## Related -`0`. The [boundary test](#the-lazy-loading-guarantee) is the standing guarantee; this is the spot check that the guarantee survived bundling. +- [Editable pages](/docs/dev/widgets/editable-pages): make a page editable, with storage and permissions from core. +- [Custom editor host](/docs/dev/widgets/custom-editor-host): edit widgets stored anywhere else, with your own save route. +- [Content zones](/docs/dev/widgets/content-zones): where widgets render on a page. diff --git a/apps/web/content/docs/dev/working-with-users/admin-users.mdx b/apps/web/content/docs/dev/working-with-users/admin-users.mdx new file mode 100644 index 0000000000..9218363fab --- /dev/null +++ b/apps/web/content/docs/dev/working-with-users/admin-users.mdx @@ -0,0 +1,83 @@ +--- +title: Manage users +description: Find, create and edit VitNode member accounts in the AdminCP, the rules that protect staff accounts, and the admin user API routes with their permissions. +icon: UserCog +--- + +The AdminCP user screens let staff find accounts, create one by hand, and change everything a member can change in their own settings, plus a few things only staff can. They live at **AdminCP → Core → Users → User List** (`/admin/core/users`). + +## The user list + +{/* Image prompt: VitNode AdminCP user list at /admin/core/users. Data table with a search field ("Search users by email or username...") and a Roles filter above it, an "Add User" button at the top right, and columns User (avatar and name), Email, Roles (badges) and Created At, with a pencil "Edit profile" button at the end of each row and a "Verify Email" button on unverified rows. Dark theme, 1440x900. */} + +The table shows **User**, **Email**, **Roles** and **Created At**. Search by email or username, or filter by role. Each row has an **Edit profile** button that opens the user, and accounts with an unconfirmed email also get **Verify Email**. + +**Add User** opens a dialog that creates an account from a **Username** (3 to 32 characters), an **Email** and a **Password** (at least 8 characters). It needs `users:can_create`. + +There is no ban or delete action for accounts. + +## Edit a user + +{/* Image prompt: VitNode AdminCP user page at /admin/core/users/1. Left column: profile card with cover, round avatar, name, @handle, Staff and Verified badges, then Personal information, Preferences, Roles, Connected accounts and Devices cards. Right column: segmented tabs Activity, Notifications, Password & passkeys above a vertical activity timeline. Dark theme, 1440x1000. */} + +Open a user at `/admin/core/users/[id]`. Everything the member can change in their own `/settings` is editable here too: + +| Area | What staff can change | +| :-------------------------- | :----------------------------------------------------------------------------------------------------- | +| Profile card | Display name, name code, email, [avatar and cover](/docs/dev/working-with-users/avatars), and **Mark email as verified** | +| Personal information | First name, last name, headline, phone, birthday, **Show real name** | +| Preferences | Language, time zone (or automatic), newsletter | +| Roles | Primary role and secondary roles | +| Connected accounts | Disconnect an SSO account, choose where the avatar and name come from, turn **Sync on sign-in** on or off | +| Devices | Sign out one device, or every device at once | +| **Activity** tab | The account's timeline (read-only) | +| **Notifications** tab | In-app, push and email (with frequency) per notification type | +| **Password & passkeys** tab | Set a new password, rename or delete passkeys | + +A few rules differ from the member's own settings: + +- **Staff can edit locked fields.** The role's **Allow editing personal information** and the install's `users.personalInformation` switches ([Profile fields](/docs/dev/working-with-users/profile-fields#who-may-edit)) limit members, not staff. +- **Notification locks still apply.** Mandatory types, and types the notification settings stop members from changing, are read-only here too, because delivery ignores stored choices for them. +- **The last sign-in method can't be removed.** Disconnecting the only SSO account or deleting the only passkey answers `409`. +- **Setting a password signs the member out everywhere**, AdminCP sessions included. +- **Sync now isn't offered.** It needs the member to sign in with the provider themselves. + +## Who may do it + +Reads need `users:can_view`, creating an account needs `users:can_create`, and every write needs `users:can_edit`, plus `users:can_edit_admin` when the target is staff. A staff target must also sit within your own access. You need every admin and moderator permission they hold, and only a root administrator can edit a root or unrestricted account. So nobody can reset the password of, or demote, someone above them. [Permissions](/docs/dev/working-with-users/staff-permissions) shows how admins grant these. + +## API + +All routes are under `/api/@vitnode/core/admin/users` and need an AdminCP session. + +| Method | Path | Permission | Does | +| :------- | :--------------------------------------- | :------------- | :--------------------------------------------------- | +| `GET` | `/list` | `can_view` | The user list, with search and role filter | +| `POST` | `/create` | `can_create` | Creates an account from `{ email, name, password }` | +| `GET` | `/{id}` | `can_view` | One user | +| `PATCH` | `/{id}` | `can_edit` | Account, personal information and preferences | +| `GET` | `/{id}/timeline` | `can_view` | The activity timeline | +| `POST` | `/{id}/verify-email` | `can_edit` | Marks the email as verified | +| `PUT` | `/{id}/password` | `can_edit` | Sets a new password and revokes every session | +| `GET` | `/{id}/devices` | `can_view` | Devices with an active session | +| `DELETE` | `/{id}/devices/{publicId}` | `can_edit` | Signs one device out | +| `DELETE` | `/{id}/devices` | `can_edit` | Signs every device out | +| `GET` | `/{id}/passkeys` | `can_view` | Passkeys | +| `PATCH` | `/{id}/passkeys/{passkeyId}` | `can_edit` | Renames a passkey | +| `DELETE` | `/{id}/passkeys/{passkeyId}` | `can_edit` | Deletes a passkey | +| `GET` | `/{id}/sso` | `can_view` | Connected accounts, sign-in summary, profile sources | +| `DELETE` | `/{id}/sso/{providerId}` | `can_edit` | Disconnects an account | +| `PUT` | `/{id}/sso/preferences` | `can_edit` | Profile sources and sync on sign-in | +| `GET` | `/{id}/notification-preferences` | `can_view` | Notification preferences per type | +| `PUT` | `/{id}/notification-preferences` | `can_edit` | Changes notification preferences | +| `POST` | `/{id}/images/{kind}` | `can_edit` | Uploads an avatar or cover | +| `DELETE` | `/{id}/images/{kind}` | `can_edit` | Removes an avatar or cover | + +Every `can_edit` route also checks `can_edit_admin` and the access rule above when the target is staff. + +The `PATCH /{id}` body takes any of `email`, `name`, `nameCode`, `roleId`, `secondaryRoleIds`, `firstName`, `lastName`, `headline`, `phone`, `showRealName`, `birthday` (`YYYY-MM-DD` or `null`), `language`, `timeZone` (`null` for automatic) and `newsletter`. At least one field is required. + +## Related + +- [Roles](/docs/dev/working-with-users/roles) explains what the primary and secondary roles do. +- [Email verification](/docs/dev/working-with-users/email-verification) explains what **Mark email as verified** unlocks. diff --git a/apps/web/content/docs/dev/working-with-users/avatars.mdx b/apps/web/content/docs/dev/working-with-users/avatars.mdx new file mode 100644 index 0000000000..f3e21559fc --- /dev/null +++ b/apps/web/content/docs/dev/working-with-users/avatars.mdx @@ -0,0 +1,45 @@ +--- +title: Avatars and covers +description: Where VitNode members and staff upload or remove avatar and cover images, how role settings cap them, and the image upload routes and events. +icon: CircleUserRound +--- + +Every member can have an uploaded avatar and a profile cover. Without one, VitNode draws a letter avatar or a colour gradient. This page covers where people edit the images, the role settings that allow and cap them, and the routes behind the upload dialog. + +## Where images are edited + +- On the **profile page** (`/users/[nameCode]`), the owner sees a camera button on the cover and on the avatar. It opens one dialog that uploads and removes. With a picture already set, two radio buttons choose between the two actions and the confirm button reads **Upload** or **Delete** to match; with nothing set yet, the dialog goes straight to the file picker. +- On **Settings → Overview** (`/settings`), only the avatar and its camera button appear. The cover stays on the profile page. +- In the **AdminCP** (`/admin/core/users/[id]`), staff holding `users:can_edit`, plus `users:can_edit_admin` when the target is staff, get the same two buttons and the same dialog. Staff can set an image even when the user's own roles do not allow it; the role's size cap still applies. + +## Role settings + +Each role sets these on its **Profile** tab at **AdminCP → Core → Users → Roles**: + +| Setting | Column | Default | +| :----------------------- | :------------------ | :------ | +| Allow avatar upload | `allowUploadAvatar` | `true` | +| Maximum avatar size (kB) | `maxAvatarSize` | `2048` | +| Allow cover image upload | `allowUploadCover` | `true` | +| Maximum cover size (kB) | `maxCoverSize` | `5120` | + +A user with several roles may upload as soon as one role allows it, and gets the largest cap among the roles that allow it. The dialog shows the cap before you pick a file, and the server checks it again. + +## API + +| Method | Path | Who | +| :------- | :-------------------------------------------------- | :--------------------------------------------------------------- | +| `GET` | `/api/@vitnode/core/users/images/policy` | Signed-in user: what they may upload, and the caps in bytes | +| `POST` | `/api/@vitnode/core/users/images/{kind}` | Signed-in user: multipart `file`, `kind` is `avatar` or `cover` | +| `DELETE` | `/api/@vitnode/core/users/images/{kind}` | Signed-in user | +| `POST` | `/api/@vitnode/core/admin/users/{id}/images/{kind}` | Staff with `users:can_edit` | +| `DELETE` | `/api/@vitnode/core/admin/users/{id}/images/{kind}` | Staff with `users:can_edit` | + +Accepted types are JPEG, PNG and WebP. The browser crops an avatar to `512×512` and a cover to `1800×600` and sends WebP where it can encode it, so uploads usually land far under the cap. A refused file answers `400` with an `{ error }` message meant for the person who picked it, and a role that does not allow the image answers `403`. + +Both writes emit [`user.avatar.updated` or `user.cover.updated`](/docs/dev/events/built-in-events) with the new `core_files` id, or `null` when the image was removed. + +## Related + +- [Current user](/docs/dev/working-with-users/users) covers `avatarUrl` and `coverUrl` on every user payload. +- [Storage](/docs/dev/storage) explains where VitNode keeps the uploaded files. diff --git a/apps/web/content/docs/dev/working-with-users/email-verification.mdx b/apps/web/content/docs/dev/working-with-users/email-verification.mdx index f82b5ac455..fb2d5ce1ac 100644 --- a/apps/web/content/docs/dev/working-with-users/email-verification.mdx +++ b/apps/web/content/docs/dev/working-with-users/email-verification.mdx @@ -1,5 +1,5 @@ --- -title: Email Verification +title: Email verification description: How VitNode confirms a new member's email address before they can sign in, and the API routes and events behind it. icon: MailCheck --- @@ -33,7 +33,7 @@ If they try to sign in first, the sign-in form says **Confirm your email first** - **AdminCP sign-in** at `/admin` is not blocked. Only staff get in, and staff are picked by an administrator, so an install that just added an email adapter can't lock its admins out. - **SSO** sign-ups are confirmed already, since the provider vouches for the address. - **Completing a password reset** also confirms the address - the reset link went to the same mailbox. -- **Staff** can confirm anyone by hand from the AdminCP user page. +- **Staff** can confirm anyone by hand with **Verify Email** in the AdminCP user list or **Mark email as verified** on the user's page ([Manage users](/docs/dev/working-with-users/admin-users)). <Callout type="warn" title="Adding an email adapter to an existing install"> Members who registered while an adapter was configured but before this feature @@ -58,6 +58,7 @@ Call them through the [fetcher](/docs/dev/fetcher) like any other core route: ```ts title="src/lib/confirm-email.ts" import { fetcher } from '@vitnode/core/tanstack/fetcher' +// `token` is the `?token=` value from the confirmation link const response = await fetcher({ plugin: '@vitnode/core', method: 'post', diff --git a/apps/web/content/docs/dev/working-with-users/meta.json b/apps/web/content/docs/dev/working-with-users/meta.json index e79ec0b403..ebbdf2888e 100644 --- a/apps/web/content/docs/dev/working-with-users/meta.json +++ b/apps/web/content/docs/dev/working-with-users/meta.json @@ -1,6 +1,15 @@ { - "title": "Working with Users", + "title": "Users", "description": "The user model, roles, and the staff permission system - how VitNode decides who somebody is and what they are allowed to do.", "icon": "Users", - "pages": ["users", "email-verification", "roles", "staff-permissions", "..."] + "pages": [ + "users", + "profile-fields", + "avatars", + "admin-users", + "email-verification", + "roles", + "staff-permissions", + "..." + ] } diff --git a/apps/web/content/docs/dev/working-with-users/profile-fields.mdx b/apps/web/content/docs/dev/working-with-users/profile-fields.mdx new file mode 100644 index 0000000000..ee275411f8 --- /dev/null +++ b/apps/web/content/docs/dev/working-with-users/profile-fields.mdx @@ -0,0 +1,82 @@ +--- +title: Profile fields +description: How VitNode members set their personal information and time zone, which name the public sees, what the profile header shows, and how roles and the API config limit editing. +icon: IdCard +--- + +Members fill in a few optional profile fields themselves: first name, last name, phone, headline, the **Show my real name** switch and a time zone. This page covers where they edit them, the routes behind that, which name other people see, and the two controls you have. Role settings decide _who_ may edit, and the API config decides _which fields exist_. + +## Personal information + +Members set `firstName`, `lastName`, `phone` and `headline` at **Settings → Overview** (`/settings`). The **Profile** card lists them read-only; **Edit profile** opens one sheet with every field, the **Show my real name** switch and the time zone, saved together with **Save changes**. Every field is optional and stored as `null` when cleared. The session payload carries them, so anything that already reads the session can show them without another request. + +| Method | Path | Who | +| :------ | :----------------------------------- | :----------------------------------------- | +| `GET` | `/api/@vitnode/core/users/me/policy` | The signed-in user: which fields may they edit? | +| `PATCH` | `/api/@vitnode/core/users/me` | The signed-in user, on their own row | + +The `PATCH` body takes any subset of `{ firstName, lastName, phone, headline, showRealName }`. A field left out stays as it is, and a text field sent as `""` or whitespace is cleared. Saving clears the user's cached session and emits [`user.updated`](/docs/dev/events/built-in-events). + +`phone` is the one field with a format: digits plus `+`, spaces, `-`, `(` and `)`, up to 32 characters. The form and the route both reject anything else. It is **not** on the public profile; only the account's own session carries it. + +## Time zone + +`timeZone` sits under **Region** in the same **Edit profile** sheet. It takes any IANA name, like `Europe/Warsaw`, and **Use this device's time zone** fills it from the browser (it still waits for **Save changes**). `null` means "follow my language", then UTC. Notification digests go out in this time zone, and `c.get("user")?.timeZone` is there for anything else that needs local time. + +| Method | Path | Who | +| :----- | :-------------------------------------- | :----------------------------------- | +| `PUT` | `/api/@vitnode/core/users/me/time-zone` | The signed-in user, on their own row | + +The body is `{ timeZone: string | null }`. An unknown name answers `400`. Saving clears the cached session and emits [`user.updated`](/docs/dev/events/built-in-events). + +## Which name the public sees + +`showRealName` decides whether a profile is headed by the nickname (`name`) or by `firstName lastName`. The public profile route resolves this **on the server** and returns the result as `name`, so a real name the user has not published never leaves the API. When the switch is on but both halves are empty, the profile falls back to the nickname. + +People shown in content, such as a post's authors, carry the same answer as `fullName`, which is the real name when it is published and `null` otherwise. [`UserFormat`](/docs/ui/user-format) shows it in place of the nickname. To publish a real name from your own route, use `publicFullNameOf` from `@vitnode/core/lib/user-personal-information`, and never send `firstName` or `lastName` to other people. + +The AdminCP and search always show `name`, because staff need the name the account is filed under. + +## What the profile header shows + +{/* Image prompt: VitNode public profile header at /users/aXen. Wide cover image with a rounded-square avatar overlapping its bottom-left corner, the display name and @nameCode beside it, and a single row of muted meta items with small icons - a briefcase for the headline, a shield for the primary role, a calendar for the join date - with an "Edit profile" outline button at the far right. Light theme, 1440x400. */} + +`GET /api/@vitnode/core/users/profile/{nameCode}` returns everything the header needs: `id`, `name` (already resolved), `nameCode`, `headline`, `avatarUrl`, `coverUrl`, `avatarColor`, `createdAt`, the primary `role` and any `secondaryRoles`. The header shows the headline, the role and the join date as one row with an icon each, stacked under the avatar on a phone. + +With `users.personalInformation.headline` set to `false` (see [below](#turn-fields-off-for-the-whole-install)), the route returns `headline: null` for everyone, so turning the field off removes it from public profiles without touching what members wrote. + +The **Edit profile** button is the header's action slot and shows only on the account's own profile. A host that renders `ProfileContent` itself can pass any `action`: a follow button, a message link, or nothing. + +## Who may edit + +Each role has **Allow editing personal information** on its **Profile** tab at **AdminCP → Core → Users → Roles** (`allowEditPersonalInfo`, default `true`). A user may edit only when **every** role they hold allows it; one role that switches it off locks the fields. + +That is the opposite of the [image allowances](/docs/dev/working-with-users/avatars#role-settings), which any single role can grant. Since this setting defaults to `true`, "any role grants" would let every other role a member holds quietly undo an administrator who switched it off on one role. + +A locked member's **Edit profile** sheet shows only the time zone, which everyone may change, and `PATCH /users/me` answers `403` whatever the UI showed. Staff can still edit locked fields from [the AdminCP](/docs/dev/working-with-users/admin-users). + +## Turn fields off for the whole install + +Every field is on unless you set it to `false` under `users.personalInformation` in the API config: + +```ts title="apps/api/src/vitnode.api.config.ts" +export const vitNodeApiConfig = buildApiConfig({ + // ... + users: { + personalInformation: { + phone: false, + headline: false, + showRealName: false, + }, + }, +}); +``` + +The keys are `firstName`, `lastName`, `phone`, `headline` and `showRealName`. A field switched off disappears from the settings card and its form, and `PATCH /users/me` refuses to write it even when a request sends it, so a stale browser tab cannot get around the switch. + +Switching a field off never deletes data. The column keeps what members had set, and turning it back on restores it. With `showRealName` off, every profile is headed by the nickname. With **every** field off there is nothing left to edit, so the **Edit profile** button disappears too. + +## Related + +- [Current user](/docs/dev/working-with-users/users) shows the full user record and how to read it. +- [Avatars and covers](/docs/dev/working-with-users/avatars) covers the profile images. diff --git a/apps/web/content/docs/dev/working-with-users/roles-list.webp b/apps/web/content/docs/dev/working-with-users/roles-list.webp new file mode 100644 index 0000000000..940119b56e Binary files /dev/null and b/apps/web/content/docs/dev/working-with-users/roles-list.webp differ diff --git a/apps/web/content/docs/dev/working-with-users/roles.mdx b/apps/web/content/docs/dev/working-with-users/roles.mdx index 64defab0f4..2e978d87ab 100644 --- a/apps/web/content/docs/dev/working-with-users/roles.mdx +++ b/apps/web/content/docs/dev/working-with-users/roles.mdx @@ -1,80 +1,81 @@ --- title: Roles -description: Manage user roles, primary and secondary group memberships, role colors, and default seeded groups. +description: How VitNode roles group users for coloured name badges, upload and profile allowances, and staff permissions, with the seeded default roles and primary and secondary roles. icon: UsersRound --- -In VitNode, a role groups users together for display styles (e.g. colored username badges) and staff permission grants. +import { ImgDocs } from "@/components/fumadocs/img"; +import rolesList from "./roles-list.webp"; -## Quick start +A role groups users. It decides how their name looks (colour and prefix), what they may upload and edit on their profile, and, through [staff permissions](/docs/dev/working-with-users/staff-permissions), whether they are administrators or moderators. -Resolve a user's complete set of role IDs (primary + secondary) on the API: +## Read a user's roles in an API route + +`getUserRoleIds` returns every role id a user holds, primary and secondary, without duplicates. It reads the database on each call: ```ts -import { getUserRoleIds } from '@vitnode/core/api/lib/check-staff-permission' +import { getUserRoleIds } from "@vitnode/core/api/lib/check-staff-permission"; -const user = c.get('user') -// [!code ++:1] -const roleIds = user ? await getUserRoleIds(c, user) : [] +// inside a route handler, where `c` is the Hono context +const user = c.get("user"); +const roleIds = user ? await getUserRoleIds(c, user) : []; ``` ---- - -## Seeded Default Roles +## Seeded roles -VitNode automatically seeds four protected system roles upon installation: +A fresh install gets four protected roles: -| Role | Flags | Default Color | Staff Privileges | -| :---------------- | :-------- | :--------------------------- | :----------------------------------------------- | -| **Guest** | `guest` | None | None (applies to unauthenticated visitors) | -| **Member** | `default` | None | Standard registered member role | -| **Moderator** | None | Green (`hsl(122, 80%, 45%)`) | Unrestricted moderation staff | -| **Administrator** | `root` | Red (`hsl(0, 100%, 50%)`) | Unrestricted administrative staff (`root: true`) | +| Role | Flags | Default colour | Staff access | +| :---------------- | :-------- | :--------------------------- | :---------------------------------------------------- | +| **Guest** | `guest` | None | None (applies to visitors who are not signed in) | +| **Member** | `default` | None | None (the role new accounts get) | +| **Moderator** | None | Green (`hsl(122, 80%, 45%)`) | Unrestricted moderator | +| **Administrator** | `root` | Red (`hsl(0, 100%, 50%)`) | Unrestricted administrator and moderator (`root`) | -<Callout type="info" title="First Account is Root"> - The first user to register during site initialization is automatically - assigned the Administrator role. +<Callout type="info" title="The first account is the administrator"> + The first user to register gets the Administrator role. </Callout> ---- - -## Role Model Attributes - -`core_roles` defines group styling and properties: +## Role fields -- `color`: CSS color string (e.g. `#ff0000`, `hsl(210, 100%, 50%)`) applied to member usernames across the UI. -- `prefix`: An emoji or a Lucide icon rendered in front of the role name and in front of every member's username. Stored as `emoji:🚀` or `icon:rocket` - see the [Emoji & Icon Picker](/docs/ui/emoji-icon-picker) for the codec and the components that read it. -- `root`: When `true`, user bypasses all staff permission checks. -- `default`: The role automatically assigned to newly registered members. -- `guest`: Reserved for unauthenticated requests. +`core_roles` holds: ---- +- `color`: a CSS colour (`#ff0000`, `hsl(210, 100%, 50%)`) applied to members' names across the UI. +- `prefix`: an emoji or a Lucide icon shown before the role name and every member's name. Stored as `emoji:🚀` or `icon:rocket`; the [Emoji & Icon Picker](/docs/ui/emoji-icon-picker) covers the format and the components that read it. +- `root`: when `true`, members pass every staff permission check. +- `default`: the role new members get. +- `guest`: the role used for visitors who are not signed in. -## Primary vs Secondary Roles +Roles also carry allowances: file uploads on the **Content** tab, and personal information plus avatar and cover limits on the **Profile** tab. [Profile fields](/docs/dev/working-with-users/profile-fields#who-may-edit) and [Avatars and covers](/docs/dev/working-with-users/avatars#role-settings) explain how several roles combine. -Every user has exactly one **primary role** (stored in `core_users.roleId`). Users may additionally hold multiple **secondary roles** (stored in `core_users_secondary_roles`), inheriting combined staff permissions across all assigned groups. +## Primary and secondary roles ---- +Every user has exactly one **primary role** (`core_users.roleId`) and any number of **secondary roles** (`core_users_secondary_roles`). VitNode combines staff permissions across all of them. Staff change both in the **Roles** card on the user's AdminCP page ([Manage users](/docs/dev/working-with-users/admin-users)). -## AdminCP Role Management +## Manage roles in the AdminCP -{/* Image prompt: VitNode AdminCP Roles management page at /admin/core/roles. Table showing custom roles with colored name badges, member counts, and action buttons to reorder, edit, or delete roles. Dark theme, 1440x900. */} +<ImgDocs + withoutBackground + className="*:max-w-full" + src={rolesList} + alt="The AdminCP Roles page: a search field and Create role button above a table of the Administrator, Moderator, Member and Guest roles with their colours, number of users, last update and an edit button" +/> -Create, color-code, and organize user groups under **Core → Roles** (`/admin/core/roles`). +Roles live at **AdminCP → Core → Users → Roles** (`/admin/core/users/roles`). The table shows each **Role**, its **Number of users** and when it was last updated. **Create role** and the edit button open a dialog with **General**, **Content** and **Profile** tabs, plus **Artificial intelligence (AI)** when AI is configured. **General** holds the **Name**, the **Color** that tints member names, and the **Prefix**. -The **General** tab of the create/edit dialog carries both display fields: **Color**, which tints member usernames, and **Prefix**, which puts an emoji or an icon in front of them. +Deleting a role that still has users asks which role to move them to first. -## Learn More +## Related <Cards> <Card - title="Staff Permissions" - description="Grant granular admin and moderator privileges to roles" + title="Permissions" + description="Grant admin and moderator permissions to roles" href="/docs/dev/working-with-users/staff-permissions" /> <Card - title="Users" - description="Managing user accounts and session context" + title="Current user" + description="Read the signed-in user and their role in routes and components" href="/docs/dev/working-with-users/users" /> </Cards> diff --git a/apps/web/content/docs/dev/working-with-users/staff-permissions-editor.webp b/apps/web/content/docs/dev/working-with-users/staff-permissions-editor.webp new file mode 100644 index 0000000000..198cf87b81 Binary files /dev/null and b/apps/web/content/docs/dev/working-with-users/staff-permissions-editor.webp differ diff --git a/apps/web/content/docs/dev/working-with-users/staff-permissions.mdx b/apps/web/content/docs/dev/working-with-users/staff-permissions.mdx index e920b0838d..7ad85ebba1 100644 --- a/apps/web/content/docs/dev/working-with-users/staff-permissions.mdx +++ b/apps/web/content/docs/dev/working-with-users/staff-permissions.mdx @@ -1,197 +1,442 @@ --- -title: Staff Permissions -description: Declare granular staff permissions in VitNode plugins, enforce them on Hono routes, and gate AdminCP UI controls. -icon: ShieldUser +title: Permissions +description: Declare a VitNode staff permission in the site-notes plugin, guard a create endpoint with it, and check that VitNode refuses a restricted administrator until you grant the permission. +icon: ShieldCheck --- -VitNode separates staff into two groups: **Moderators** and **Administrators**. Plugins declare granular permissions that restrict what staff members or roles can access. +import { ImgDocs } from "@/components/fumadocs/img"; +import permissionEditor from "./staff-permissions-editor.webp"; -<Callout type="warn" title="New Admin APIs Require Staff Permissions"> - Routes under `/admin/` are automatically gated by the admin session, but you must declare an explicit staff permission tuple to restrict specific actions. -</Callout> +Staff permissions decide what each AdminCP administrator or moderator may do. A plugin declares its own, the AdminCP lets you grant them, and routes check them on the server. In this lesson you declare **Create notes** for `site-notes` and add `POST /api/site-notes/admin/notes`, which only administrators holding it can call. -## Quick start +## Before you begin -### 1. Declare Permissions in Plugin API Config +- The `site_notes` table from [Database](/docs/dev/database), with `pnpm dev` running. +- A second account to restrict. Register one at `/register` in a private window; only the first account became the administrator. -```ts title="plugins/blog/src/config.api.ts" -import { buildApiPlugin } from "@vitnode/core/api/lib/plugin" +<Steps> + +<Step> + +### Declare the permission -import { manifest } from "@/manifest.gen" +Add `permissionStaff` to `buildApiPlugin` in `src/config.api.ts`. This is an excerpt; keep the rest of the file: -export const blogApiPlugin = () => +```ts title="plugins/site-notes/src/config.api.ts" +export const siteNotesApiPlugin = () => buildApiPlugin({ manifest, - // [!code ++:8] + modules: [helloModule, notesModule], + // [!code ++:3] permissionStaff: { - admin: { - posts: [ - "can_view", - { permission: "can_delete", dependsOn: ["can_view"] }, - ], - }, - }, - }) + admin: { notes: ["can_create"] } + } + }); ``` -### 2. Enforce on Hono Route +`admin` holds permissions for AdminCP administrators, `notes` is the permission module, and `can_create` the permission. Together with the plugin id they form `site-notes:notes:can_create`. Nobody can grant a permission that is not declared here, so only unrestricted administrators would ever pass its check. -Add `adminStaffPermission` to automatically return `403 Forbidden` if unauthorized: +Moderators work on the site, not in the AdminCP, so their permissions go under `moderator`. A plugin can declare both, for example letting moderators delete notes they find on the site: -```ts title="plugins/blog/src/api/modules/admin/posts/routes/delete.route.ts" -export const deletePostRoute = buildRoute({ - // [!code ++:1] - adminStaffPermission: { module: "posts", permission: "can_delete" }, - route: { - method: "delete", - path: "/{id}", - responses: { - 200: { description: "Post deleted" }, - 403: { description: "Forbidden" }, - }, - }, - handler: async (c) => { - // Execution only reaches here if caller has permission - }, -}) +```ts title="plugins/site-notes/src/config.api.ts" +permissionStaff: { + admin: { notes: ["can_create"] }, + moderator: { notes: ["can_delete"] }, // [!code highlight] +}, ``` ---- +You grant `site-notes:notes:can_delete` in **Staff → Moderators**, and VitNode checks it against the signed-in member. [Moderator permissions](#moderator-permissions) shows the routes that use it; this lesson continues with `can_create` only. -## Add Permissions Step by Step +</Step> -<Steps> <Step> -### 1. Define Translation Labels +### Label it -Add flat labels in `plugins/blog/src/locales/en.json`: +Open `src/locales/en.json`. Give the plugin a display name as `title` inside the `site-notes` object, and add the permission labels at the **root**, next to that object rather than inside it. This excerpt shows only the new keys: -```json title="plugins/blog/src/locales/en.json" +```json title="plugins/site-notes/src/locales/en.json" { - "@vitnode/blog": { "title": "Blog" }, - "@vitnode/blog:posts": "Articles", - "@vitnode/blog:posts:can_view": "View articles list", - "@vitnode/blog:posts:can_create": "Create articles", - "@vitnode/blog:posts:can_delete": "Delete articles" + "site-notes": { + "title": "Site Notes" + }, + "site-notes:notes": "Notes", + "site-notes:notes:can_create": "Create notes" } ``` +The AdminCP permission editor shows these names: **Site Notes** in its plugin list, then **Notes** and **Create notes**. Without them it shows the raw `site-notes` and keys. + </Step> + <Step> -### 2. Gate UI Controls +### Guard the create route -Hide buttons or panels from unauthorized staff with `AdminStaffPermissionGate`: +Admin endpoints live under an `admin` module, so their URLs contain `/admin/`. VitNode requires an AdminCP session for every such URL; `adminStaffPermission` adds the per-permission check. Create `src/api/modules/admin/notes/create.route.ts`: -```tsx title="plugins/blog/src/admin/delete-post-button.tsx" -import { AdminStaffPermissionGate } from "@vitnode/core/components/staff-permission/provider" +```ts title="plugins/site-notes/src/api/modules/admin/notes/create.route.ts" +import { z } from "@hono/zod-openapi"; -export const DeletePostButton = () => ( - // [!code ++:5] - <AdminStaffPermissionGate - plugin="@vitnode/blog" - module="posts" - permission="can_delete" - > - <button className="text-destructive">Delete</button> - </AdminStaffPermissionGate> -) -``` +import { buildRoute } from "@/api/builders"; +import { site_notes } from "@/database/notes"; -Or check permissions imperatively with `useStaffPermissions`: +import { zodNote } from "../../notes/notes.schema"; -```tsx -const { hasPermission } = useStaffPermissions() -const canDelete = hasPermission({ module: "posts", permission: "can_delete" }) +export const createNoteRoute = buildRoute({ + // [!code ++] + adminStaffPermission: { module: "notes", permission: "can_create" }, + route: { + method: "post", + path: "/", + request: { + body: { + content: { + "application/json": { + schema: z.object({ + title: z.string().trim().min(3).max(200), + body: z.string().trim().min(1).max(2000) + }) + } + } + } + }, + responses: { + 201: { + content: { "application/json": { schema: zodNote } }, + description: "The created note." + } + } + }, + handler: async (c) => { + const values = c.req.valid("json"); + const [note] = await c + .get("db") + .insert(site_notes) + .values(values) + .returning({ id: site_notes.id, title: site_notes.title, body: site_notes.body }); + + return c.json(note, 201); + } +}); ``` +`adminStaffPermission` checks the permission of the plugin that owns the route, so it never names `site-notes`. Anyone without it gets `403` before the handler runs, and an invalid body gets `400`. + </Step> + <Step> -### 3. Gate Sidebar Navigation +### Mount the admin module -Attach `permission` to navigation items in `src/admin/nav.tsx`: +Create the two modules that put the route at `admin/notes`: -```tsx title="plugins/blog/src/admin/nav.tsx" -export const adminNav: PluginAdminNav = { - nav: [ - { - id: "posts", - href: "/admin/blog/posts", - icon: <ListIcon />, - permission: { module: "posts", permission: "can_view" }, // [!code ++] - }, - ], -} +```ts title="plugins/site-notes/src/api/modules/admin/notes/notes.module.ts" +import { buildModule } from "@/api/builders"; + +import { createNoteRoute } from "./create.route"; + +export const adminNotesModule = buildModule({ + name: "notes", + routes: [createNoteRoute] +}); +``` + +```ts title="plugins/site-notes/src/api/modules/admin/admin.module.ts" +import { buildModule } from "@/api/builders"; + +import { adminNotesModule } from "./notes/notes.module"; + +export const adminModule = buildModule({ + name: "admin", + routes: [], + modules: [adminNotesModule] +}); +``` + +Then register `adminModule` in `src/config.api.ts`: + +```ts title="plugins/site-notes/src/config.api.ts" +import { adminModule } from "./api/modules/admin/admin.module"; // [!code ++] + + modules: [helloModule, notesModule], // [!code --] + modules: [helloModule, notesModule, adminModule], // [!code ++] ``` </Step> -<Step> -### 4. Grant Permissions in AdminCP +<Step> -{/* Image prompt: VitNode AdminCP staff permission editor at /admin/core/staff/admins. Left sidebar lists plugins (Core, Blog). Right panel displays permission toggles grouped by module ("Articles") with dependency locks and an Unrestricted / Restricted level switcher. Dark theme, 1440x900. */} +### Restrict the second account -1. Go to **Staff → Administrators** (`/admin/core/staff/admins`). -2. Select a User or Role and click **Edit**. -3. Choose **Restricted**, toggle the desired permissions, and click **Save Changes**. +In the AdminCP, open **Staff → Administrators** (`/admin/core/staff/admins`) and choose **Add Administrator**. Pick your second account, keep **Restricted** and save without ticking anything. That account can now sign in to the AdminCP but holds no permissions. </Step> + </Steps> ---- +## Check the result + +Add a temporary button that calls the route with `fetcher`. Create `src/pages/check-create-note.tsx`: + +```tsx title="plugins/site-notes/src/pages/check-create-note.tsx" +import { Button } from "@vitnode/core/components/ui/button"; +import { fetcher } from "@vitnode/core/tanstack/fetcher"; +import { useState } from "react"; + +import { manifest } from "@/manifest.gen"; + +export const CheckCreateNote = () => { + const [status, setStatus] = useState<number>(); + const [isPending, setIsPending] = useState(false); + + const onClick = async () => { + setIsPending(true); + try { + const response = await fetcher({ + plugin: manifest.id, + method: "post", + module: "admin/notes", + path: "/", + args: { + body: { + title: "Parking lot repaint", + body: "The lot is closed on Saturday morning." + } + } + }); + setStatus(response.status); + } finally { + setIsPending(false); + } + }; + + return ( + <div className="flex items-center gap-3"> + <Button isLoading={isPending} onClick={onClick}> + Create a test note + </Button> + {status ? <output>{status}</output> : null} + </div> + ); +}; +``` + +Render `<CheckCreateNote />` above the list in `src/pages/notes-page.tsx`. The browser sends the AdminCP session cookie from every page, so sign in to the AdminCP as each account, open `/site-notes/notes` in that browser and press the button: -## Permission Dependencies +| Who | Result | +| ---------------------------------------------- | -------------------------------------------------- | +| Signed out of the AdminCP | `403` | +| The restricted account | `403` | +| The first account (unrestricted Administrator) | `201`, and the note appears at `/site-notes/notes` | -When a permission depends on another (e.g. creating posts requires viewing them), declare `dependsOn`: +Now edit the restricted account in **Staff → Administrators**, choose **Site Notes** in the plugin list, tick **Create notes** under **Notes** and save. The same button pressed as that account shows `201`. Remove `CheckCreateNote` when you are done. -```ts title="plugins/blog/src/config.api.ts" +<ImgDocs + withoutBackground + className="*:max-w-full" + src={permissionEditor} + alt="Edit permissions for the restricted administrator Ada Lovelace, with the Site Notes plugin selected and Create notes ticked under the Notes module" +/> + +The check happens on the server. Hiding a button or a sidebar link only tidies the UI, and the [AdminCP page](/docs/dev/plugins/admin) lesson does both on top of this guard. + +## Depend on another permission + +When one permission only makes sense with another, declare the dependency. The editor locks the dependent permission until you tick its prerequisite: + +```ts title="plugins/site-notes/src/config.api.ts" permissionStaff: { admin: { - posts: [ + notes: [ "can_view", { permission: "can_create", dependsOn: ["can_view"] }, { permission: "can_delete", dependsOn: ["can_view"] }, ], }, -} +}, ``` -The AdminCP permission editor automatically disables child toggles until prerequisites are checked. +Each permission you add needs its own label and its own `adminStaffPermission` on the routes it protects. -## Moderator permissions with scopes +## Check a permission in code -A moderator permission can be limited to some resources of a scope family, such as some forum sections. Declare the family with `scope` on the entry: +`adminStaffPermission` and `moderatorStaffPermission` cover a whole route. For a decision inside a handler, such as letting staff edit any note but members only their own, call `checkStaffPermission(c, { type, plugin, module, permission })` from `@vitnode/core/api/lib/check-staff-permission`; it resolves to `true` or `false`. In AdminCP components, `useAdminPermission({ plugin, module, permission })` from `@vitnode/core/tanstack/admin` answers the same question for the signed-in administrator, for showing or hiding controls. -```ts title="config.api.ts" +## Moderator permissions + +A moderator acts on the site with their normal member session, so moderator routes live in a public module, not under `admin`. These examples declare two moderator permissions: + +```ts title="plugins/site-notes/src/config.api.ts" permissionStaff: { - moderator: { - topics: [ - { permission: "can_view_reports", scope: "forum.section" }, - { - permission: "can_manage_reports", - dependsOn: ["can_view_reports"], - scope: "forum.section", + admin: { notes: ["can_create"] }, + moderator: { notes: ["can_delete", "can_view_details"] }, +}, +``` + +```json title="plugins/site-notes/src/locales/en.json" +{ + "site-notes:notes:can_delete": "Delete notes", + "site-notes:notes:can_view_details": "See note details" +} +``` + +Grant them in **Staff → Moderators** (`/admin/core/staff/moderators`). Choose **Add Moderator**, pick the member and save. The moderator's edit page opens; keep **Restricted**, choose **Site Notes** and tick the permissions. + +Pick the check by what a missing permission should do: + +| You want | Use | +| ------------------------------------- | ------------------------------------------ | +| Refuse the whole route | `moderatorStaffPermission` on `buildRoute` | +| Refuse one option of a route | `assertStaffPermission` in the handler | +| Answer everyone, moderators with more | `checkStaffPermission` in the handler | + +All three check the signed-in member, and a signed-out visitor never passes. + +### Guard a whole route + +`moderatorStaffPermission` works like `adminStaffPermission`, but checks the member's site session. Anyone who is not a moderator with **Delete notes** gets `403` before the handler runs: + +```ts title="plugins/site-notes/src/api/modules/notes/delete.route.ts" +import { z } from "@hono/zod-openapi"; +import { eq } from "drizzle-orm"; + +import { buildRoute } from "@/api/builders"; +import { site_notes } from "@/database/notes"; + +export const deleteNoteRoute = buildRoute({ + // [!code ++] + moderatorStaffPermission: { module: "notes", permission: "can_delete" }, + route: { + method: "delete", + path: "/{id}", + request: { + params: z.object({ id: z.coerce.number().int().positive() }) + }, + responses: { + 204: { description: "The note is gone." }, + 403: { description: "The member is not a moderator with Delete notes." }, + 404: { description: "No note has this id." } + } + }, + handler: async (c) => { + const { id } = c.req.valid("param"); + const [deleted] = await c + .get("db") + .delete(site_notes) + .where(eq(site_notes.id, id)) + .returning({ id: site_notes.id }); + if (!deleted) return c.body(null, 404); + + return c.body(null, 204); + } +}); +``` + +Add it to the `routes` of `notesModule`. Call it with `fetcher`, for example from a **Delete** button on the notes page: + +```ts +const response = await fetcher({ + plugin: manifest.id, + method: "delete", + module: "notes", + path: "/{id}", + args: { params: { id: note.id } } +}); +``` + +Signed in as the moderator, `response.status` is `204`; for any other member it is `403`. + +### Show moderators more + +The public note route answers everyone, and adds when the note was written for moderators with **See note details**. `checkStaffPermission` resolves to `true` or `false` instead of refusing: + +```ts title="plugins/site-notes/src/api/modules/notes/show.route.ts" +import { z } from "@hono/zod-openapi"; +import { checkStaffPermission } from "@vitnode/core/api/lib/check-staff-permission"; +import { eq } from "drizzle-orm"; + +import { buildRoute } from "@/api/builders"; +import { site_notes } from "@/database/notes"; +import { manifest } from "@/manifest.gen"; + +import { zodNote } from "./notes.schema"; + +export const showNoteRoute = buildRoute({ + route: { + method: "get", + path: "/{id}", + request: { + params: z.object({ id: z.coerce.number().int().positive() }) + }, + responses: { + 200: { + content: { + "application/json": { + schema: zodNote.extend({ createdAt: z.string().optional() }) + } + }, + description: "The note. Moderators with See note details also get createdAt." }, - ], + 404: { description: "No note has this id." } + } }, + handler: async (c) => { + const { id } = c.req.valid("param"); + const [note] = await c.get("db").select().from(site_notes).where(eq(site_notes.id, id)); + if (!note) return c.body(null, 404); + + const canViewDetails = await checkStaffPermission(c, { + type: "moderator", + plugin: manifest.id, + module: "notes", + permission: "can_view_details" + }); + + return c.json( + { + id: note.id, + title: note.title, + body: note.body, + ...(canViewDetails ? { createdAt: note.createdAt.toISOString() } : {}) + }, + 200 + ); + } +}); +``` + +- **The extra field is optional in the schema**, so the fetcher types it as `string | undefined` and the page shows it only when it is there. +- **`plugin: manifest.id`** is required here. Only the route options fill in the plugin for you. +- **Don't cache the moderator's answer for everyone.** A response that depends on who asks must not go into a shared cache key; cache the public part, then add the extras. + +### Refuse one option + +`assertStaffPermission` takes the same arguments and throws `403` instead of returning `false`. Use it when a member asks for something only moderators may have, here the details behind `?details=1`, while the plain request stays public: + +```ts title="plugins/site-notes/src/api/modules/notes/show.route.ts" +import { assertStaffPermission } from "@vitnode/core/api/lib/check-staff-permission"; + +// request: +request: { + params: z.object({ id: z.coerce.number().int().positive() }), + query: z.object({ details: z.literal("1").optional() }), // [!code ++] }, + +// handler, before reading the note: +const { details } = c.req.valid("query"); +if (details) { + await assertStaffPermission(c, { + type: "moderator", + plugin: manifest.id, + module: "notes", + permission: "can_view_details", + }); +} ``` -Each grant then carries a scope, and dependencies are checked per resource: managing in a section needs viewing in that section. The scope families and the AdminCP picker are covered in [Scoped moderator permissions](/docs/dev/moderation/scoped-permissions). - -## Learn More - -<Cards> - <Card - title="AdminCP Pages" - description="Build staff-only admin pages and navigation" - href="/docs/dev/plugins/admin" - /> - <Card - title="Roles & Users" - description="Configure user groups and roles" - href="/docs/dev/working-with-users/roles" - /> -</Cards> +Call it before the handler reads or changes anything, so a refused request has no side effects. Add `403` to the route's `responses` too. + +Moderator permissions can also be limited to some resources, such as some forum sections, with `scope` on the entry. [Scoped moderator permissions](/docs/dev/moderation/scoped-permissions) covers the scope families and the AdminCP picker. `moderatorStaffPermission` and a check without a `resource` pass only for grants on all resources; to allow a moderator of one section, pass `resource` to `checkStaffPermission` or `assertStaffPermission` in the handler. + +Content Engine content types declare their own permissions (`can_view`, `can_create`, `can_edit`, ...). Declaring the same module in `permissionStaff` replaces them, so use your own module names for custom permissions. diff --git a/apps/web/content/docs/dev/working-with-users/users.mdx b/apps/web/content/docs/dev/working-with-users/users.mdx index 61df7d1c28..e0fc5d9a4d 100644 --- a/apps/web/content/docs/dev/working-with-users/users.mdx +++ b/apps/web/content/docs/dev/working-with-users/users.mdx @@ -1,373 +1,138 @@ --- -title: Users & Sessions -description: Access signed-in user context in Hono API handlers, route loaders, and React components. +title: Current user +description: Read the signed-in VitNode user in Hono API handlers, React components and AdminCP screens, and see which fields the user record carries. icon: UserRound --- -import { TypeTable } from 'fumadocs-ui/components/type-table' +VitNode resolves the session cookie before your code runs, so the signed-in user is one call away on the server and in React. Use this page when a route or a component needs to know who is asking, or whether anybody is signed in at all. -Every incoming request in VitNode resolves the session cookie prior to route execution. The current user profile is accessible on both server handlers and frontend components. +## In an API route -## Quick start +`c.get("user")` returns the signed-in user, or `null` for a guest. This route answers `401` for guests and returns a few fields for everybody else: -### 1. In Hono API Routes +```ts title="plugins/site-notes/src/api/modules/hello/me.route.ts" +import { z } from "@hono/zod-openapi"; +import { HTTPException } from "hono/http-exception"; -Access the authenticated user via `c.get("user")`: +import { buildRoute } from "@/api/builders"; -```ts title="plugins/blog/src/api/modules/posts/routes/create.route.ts" -import { HTTPException } from 'hono/http-exception' - -handler: async (c) => { - // [!code ++:4] - const user = c.get('user') - if (!user) { - throw new HTTPException(401, { message: 'Unauthorized' }) - } +export const meRoute = buildRoute({ + route: { + method: "get", + path: "/me", + responses: { + 200: { + content: { + "application/json": { + schema: z.object({ id: z.number(), name: z.string() }), + }, + }, + description: "The signed-in user.", + }, + 401: { description: "Nobody is signed in." }, + }, + }, + handler: c => { + const user = c.get("user"); + if (!user) { + throw new HTTPException(401, { message: "Unauthorized" }); + } - const newPost = await createPost({ authorId: user.id }) - return c.json(newPost) -} + return c.json({ id: user.id, name: user.name }, 200); + }, +}); ``` ---- +Add `meRoute` to the `hello` module's `routes` array, and the route answers at `GET /api/site-notes/hello/me`. [API routes](/docs/dev/plugins/api/routes) shows how a route joins a module. The user object carries the columns in [the user record](#the-user-record) below, with the avatar and cover already resolved to `avatarUrl` and `coverUrl`. -### 2. In Frontend Components +## In a React component -Consume the user session via TanStack Query: +`useSessionQuery()` from `@vitnode/core/tanstack/auth` returns the session. `data.user` is `null` for a guest: -```tsx title="src/components/user-greeting.tsx" -import { useSessionQuery } from '@vitnode/core/tanstack/auth' +```tsx title="plugins/site-notes/src/pages/user-greeting.tsx" +import { useSessionQuery } from "@vitnode/core/tanstack/auth"; export const UserGreeting = () => { - // [!code ++:2] - const { data } = useSessionQuery() - const user = data?.user + const { data } = useSessionQuery(); + const user = data?.user; - if (!user) return <span>Welcome, guest!</span> + if (!user) return <span>Welcome, guest!</span>; - return <span>Welcome back, {user.name}!</span> -} + return <span>Welcome back, {user.name}!</span>; +}; ``` -`useSessionQuery()` wraps `useQuery(sessionQueryOptions())`, so it never -suspends: `data` is `undefined` on the very first render of a page that did not -warm it in a loader. Routes that want the session ready before paint call -`ensureAuthState(queryClient)` in their loader. +`useSessionQuery()` never suspends, so `data` is `undefined` on the first render of a page that did not load the session in its loader. A route that needs the session before paint calls `ensureAuthState(queryClient)` (same import) in its `loader`. ---- - -### 3. In the AdminCP +## In the AdminCP -The AdminCP session is a **second cookie**, and a second query. `isAdmin` on the -public session means "may be offered the AdminCP", not "is inside it" - so an -admin screen never answers an admin question with the public session. +The AdminCP session is a **second cookie** with its own query. `isAdmin` on the public session means "may be offered the AdminCP", not "is signed in to it", so an admin screen always reads the admin session: -```tsx title="plugins/blog/src/admin/current-admin.tsx" -import { useAdminSessionQuery } from '@vitnode/core/tanstack/admin' +```tsx title="plugins/site-notes/src/admin/current-admin.tsx" +import { useAdminSessionQuery } from "@vitnode/core/tanstack/admin"; export const CurrentAdmin = () => { - // [!code ++:1] - const { data } = useAdminSessionQuery() - - if (data.status !== 'granted') return null - - return <span>Signed in as {data.session.user.name}</span> -} -``` - -`useAdminSessionQuery()` wraps `useSuspenseQuery(adminSessionQueryOptions())`, -so `data` is always there and the screen suspends instead of rendering an empty -state. The AdminCP layout warms it, which is why every screen below it reads a -cache hit. Two shorthands sit on top of it: - -| Hook | Returns | -| :----------------- | :--------------------------------------------------------------- | -| `useAdminAccess()` | The whole access state - `granted`, or the reason it was refused | -| `useAdminUser()` | The signed-in administrator, or `null` when access is refused | - ---- - -## User Model (`core_users`) - -The canonical user table contains: - -| Column | Type | Description | -| :-------------- | :------------- | :---------------------------------------------------- | -| `id` | `serial` | Unique permanent identifier | -| `name` | `varchar(255)` | Display name | -| `nameCode` | `varchar(255)` | URL-safe slug for profile URLs (`/users/[nameCode]`) | -| `email` | `varchar(255)` | Unique account login email | -| `firstName` | `varchar(128)` | Given name, set by the user (nullable) | -| `lastName` | `varchar(128)` | Family name, set by the user (nullable) | -| `phone` | `varchar(32)` | Contact number, their own (nullable) | -| `headline` | `varchar(100)` | One line about the user, their own (nullable) | -| `showRealName` | `boolean` | Show the real name publicly instead of `name` | -| `roleId` | `integer` | Primary role ID (FK to `core_roles`) | -| `avatarColor` | `varchar(6)` | Hex color for letter avatars | -| `avatarId` | `integer` | Uploaded avatar, a `core_files` row (nullable) | -| `coverId` | `integer` | Uploaded profile cover, a `core_files` row (nullable) | -| `language` | `varchar(32)` | Preferred UI locale (defaults to `en`) | -| `timeZone` | `varchar(64)` | IANA time zone, set by the user (nullable) | -| `emailVerified` | `boolean` | Verification status | - -Every user payload the API hands out - the session, the public profile, the -AdminCP list and detail, search hit authors - carries the two images resolved -to URLs as `avatarUrl` and `coverUrl`. Both are `null` until something is -uploaded, and the `Avatar` component falls back to the letter avatar on its own - -drawn from the first character of `nameCode`, never of the displayed name, so a -member who changes which name their profile shows keeps the same face in every -list they appear in. - -### Personal information - -`firstName`, `lastName`, `phone` and `headline` are the user's own to set, at **Settings → -Overview** (`/settings`). The Profile card lists them read-only; **Edit profile** -opens one sheet with every field, the **Show my real name** switch and the time -zone, so changing three things takes one save instead of three. Every field is -optional, stored as `null` rather than an empty string when cleared, and the -session payload carries them so anything already reading the session can show -them without a second request. - -| Method | Path | Who | -| :------ | :----------------------------------- | :----------------------------------- | -| `GET` | `/api/@vitnode/core/users/me/policy` | The signed-in user - may they edit? | -| `PATCH` | `/api/@vitnode/core/users/me` | The signed-in user, on their own row | - -The body takes any subset of -`{ firstName, lastName, phone, headline, showRealName }` - -a field left out is left alone, and a text field sent as `""` or whitespace is -cleared. Writing invalidates the user's cached session and emits -[`user.updated`](/docs/dev/events/built-in-events). - -`phone` is the one field with a shape: digits and the punctuation international -numbers are written with (`+`, spaces, `-`, `(` and `)`), up to 32 characters. -Anything else is rejected by the form and by the route. It is **not** on the -public profile payload - only the account's own session carries it. - -### Time zone - -`timeZone` sits under **Region** in the same **Edit profile** sheet. It takes any -IANA name, like `Europe/Warsaw`, and **Use this device's time zone** fills the -field from the browser in one click - it still waits for **Save changes**. `null` means "follow my language", then UTC. -Notification digests go out in this time zone, and the session carries it, so -`c.get("user")?.timeZone` is there for anything else that wants local time. - -| Method | Path | Who | -| :----- | :-------------------------------------- | :----------------------------------- | -| `PUT` | `/api/@vitnode/core/users/me/time-zone` | The signed-in user, on their own row | - -The body is `{ timeZone: string | null }`. An unknown name answers `400`. -Saving invalidates the cached session and emits -[`user.updated`](/docs/dev/events/built-in-events). - -### Which name the public sees - -`showRealName` decides whether a profile is headed by the nickname (`name`) or by -`firstName lastName`. The public profile route resolves it **server-side** and -returns the answer as `name`, so a real name the user has not published never -leaves the API. When the switch is on but neither half is set, the nickname is -used, so turning it on early cannot leave a profile with an empty heading. - -People in content, such as a blog post's authors, carry the same answer as -`fullName`: the real name when it is published, `null` otherwise. -[`UserFormat`](/docs/ui/user-format) shows it in place of the nickname. To -publish a real name from your own route, use `publicFullNameOf` from -`@vitnode/core/lib/user-personal-information` and never send `firstName` or -`lastName` to other people. - -The AdminCP and search always show `name`, the account's own nickname - staff -need the identifier the account is filed under. - -### What the profile header shows - -{/* Image prompt: VitNode public profile header at /users/aXen. Wide cover image with a rounded-square avatar overlapping its bottom-left corner, the display name and @nameCode beside it, and a single row of muted meta items with small icons - a briefcase for the headline, a shield for the primary role, a calendar for the join date - with an "Edit profile" outline button at the far right. Light theme, 1440x400. */} - -`GET /api/@vitnode/core/users/profile/{nameCode}` answers with the header's whole -payload: `name` (already resolved), `nameCode`, `headline`, `avatarUrl`, -`coverUrl`, `avatarColor`, `createdAt`, the primary `role` and any -`secondaryRoles`. The header draws the headline, the role and the join date as -one row of meta items, each with its own icon, and stacks them centred under the -avatar on a phone. - -`headline` follows the install switch: with `users.personalInformation.headline` -set to `false` the route returns `null` for everyone, so turning the field off -takes it off public profiles too, without touching what members had written. - -The **Edit profile** button is the header's action slot. It is rendered only for -the account's own profile; a host that renders `ProfileContent` itself can pass -any `action` it likes - a follow button, a "message" link, nothing at all. - -### Who may edit it + const { data } = useAdminSessionQuery(); -Each role has **Allow editing personal information** on its **Profile** tab -(`allowEditPersonalInfo`, default `true`). A user may edit only when **every** -role they hold allows it - one role that switches it off is enough to lock the -card. + if (data.status !== "granted") return null; -That is the opposite of the image allowances, which grant on _any_ role, and -deliberately so: this column defaults to `true`, so under "any role grants" an -administrator who switched it off on one role would find it silently re-granted -by every other role the member happens to hold, and the setting would almost -never take effect. - -The **Edit profile** sheet then shows only the time zone, which every member may -change, and `PATCH /users/me` answers `403` regardless of what the UI showed. - -### Turning fields off for the whole install - -Roles decide _who_ may edit. The API config decides _which fields exist at all_. -Every field is on unless you set it to `false` in `vitnode.api.config.ts`: - -```ts title="vitnode.api.config.ts" -export const vitNodeApiConfig = buildApiConfig({ - // ... - users: { - personalInformation: { - phone: false, - headline: false, - showRealName: false, - }, - }, -}) + return <span>Signed in as {data.session.user.name}</span>; +}; ``` -The keys are `firstName`, `lastName`, `phone`, `headline` and `showRealName`. A -field switched off disappears from the settings card and from its form, and -`PATCH /users/me` refuses to write it even if a caller sends it anyway - so a -stale browser tab or a hand-rolled request cannot get around it. - -Switching a field off never deletes anything: the column keeps whatever members -had already set, so turning it back on restores their values. With -`showRealName` off, every profile is headed by the nickname again, whatever -individual members had chosen. With **every** field off there is nothing left to -edit, so the **Edit** button disappears too. - ---- - -## AdminCP User Management - -{/* Image prompt: VitNode AdminCP User management screen at /admin/core/users. Data table listing registered accounts with columns for Avatar, Name, Email, Primary Role badge, Status, and action dropdowns (Edit, Ban, Delete). Dark theme, 1440x900. */} - -Administrators can view, edit, ban, and assign roles to users at **Core → Users** (`/admin/core/users`). - ---- - -### Edit a user - -{/* Image prompt: VitNode AdminCP user page at /admin/core/users/1. Left column: profile card with cover, round avatar, name, @handle, Staff and Verified badges, then Personal information, Preferences, Roles, Connected accounts and Devices cards. Right column: segmented tabs Activity, Notifications, Password & passkeys above a vertical activity timeline. Dark theme, 1440x1000. */} - -Open a user at `/admin/core/users/[id]`. Everything the member can change in their own `/settings` is editable here too. - -| Area | What staff can change | -| :----------------------- | :------------------------------------------------------------------------------------------------------ | -| Profile card | Display name, name code, email, avatar, cover, and **Mark email as verified** | -| Personal information | First name, last name, headline, phone, birthday, **Show real name** | -| Preferences | Language, time zone (or automatic), newsletter | -| Roles | Primary role and secondary roles | -| Connected accounts | Disconnect an SSO account, choose where the avatar and name come from, turn **Sync on sign-in** on/off | -| Devices | Sign out one device, or every device at once | -| **Notifications** tab | In-app, push and email (with frequency) per notification type | -| **Password & passkeys** tab | Set a new password, rename or delete passkeys | - -A few rules differ from the member's own settings: - -- **Staff can edit locked fields.** The role's **Allow editing personal information** and the install's `users.personalInformation` switches limit members, not staff. -- **Notification locks still apply.** Mandatory types, and types the notification settings stop members from changing, are read-only here too, because delivery ignores stored choices for them. -- **The last sign-in method can't be removed.** Disconnecting the only SSO account or deleting the only passkey answers `409`. -- **Setting a password signs the member out everywhere**, AdminCP sessions included. -- **Sync now isn't offered.** It needs the member to sign in with the provider themselves. - -Every write needs `users:can_edit`, plus `users:can_edit_admin` when the target is staff. A staff target must also sit within your own access: you need every admin and moderator permission they hold, and only a root administrator can edit a root or unrestricted account. So nobody can reset the password of, or demote, someone above them. Reads need `users:can_view`. - -| Method | Path | Does | -| :------- | :------------------------------------------------------------------ | :--------------------------------------------------- | -| `PATCH` | `/api/@vitnode/core/admin/users/{id}` | Account, personal information and preferences | -| `PUT` | `/api/@vitnode/core/admin/users/{id}/password` | Sets a new password and revokes every session | -| `GET` | `/api/@vitnode/core/admin/users/{id}/devices` | Devices with an active session | -| `DELETE` | `/api/@vitnode/core/admin/users/{id}/devices/{publicId}` | Signs one device out | -| `DELETE` | `/api/@vitnode/core/admin/users/{id}/devices` | Signs every device out | -| `GET` | `/api/@vitnode/core/admin/users/{id}/passkeys` | Passkeys | -| `PATCH` | `/api/@vitnode/core/admin/users/{id}/passkeys/{passkeyId}` | Renames a passkey | -| `DELETE` | `/api/@vitnode/core/admin/users/{id}/passkeys/{passkeyId}` | Deletes a passkey | -| `GET` | `/api/@vitnode/core/admin/users/{id}/sso` | Connected accounts, sign-in summary, profile sources | -| `DELETE` | `/api/@vitnode/core/admin/users/{id}/sso/{providerId}` | Disconnects an account | -| `PUT` | `/api/@vitnode/core/admin/users/{id}/sso/preferences` | Profile sources and sync on sign-in | -| `GET` | `/api/@vitnode/core/admin/users/{id}/notification-preferences` | Notification preferences per type | -| `PUT` | `/api/@vitnode/core/admin/users/{id}/notification-preferences` | Changes notification preferences | - -The `PATCH` body takes any of `email`, `name`, `nameCode`, `roleId`, `secondaryRoleIds`, `firstName`, `lastName`, `headline`, `phone`, `showRealName`, `birthday` (`YYYY-MM-DD` or `null`), `language`, `timeZone` (`null` for automatic) and `newsletter`. At least one field is required. - ---- - -### Where it happens - -- **Profile page** (`/users/[nameCode]`): the owner sees a camera button on the - cover and on the avatar. It opens one dialog that both uploads and removes: - with a picture already set, a pair of radio buttons chooses between them and - the confirm button reads **Upload** or **Delete** to match; with nothing set - yet, the dialog goes straight to the picker. -- **Settings overview** (`/settings`): the avatar and its camera button only, - beside the name, code name, role and email the account carries, above the - personal information the user sets for themselves. The cover is - edited on the profile page. -- **AdminCP** (`/admin/core/users/[id]`): the same two buttons and the same - dialog, for anybody holding `users:can_edit` - plus `users:can_edit_admin` - when the target is an administrator. Staff can always set an image, even when - the user's own roles cannot; the role's size cap still applies. - -### Role settings - -Each role decides, on its **Profile** tab at **Core → Users → Roles**: - -| Setting | Column | Default | -| :--------------------------------- | :---------------------- | :------ | -| Allow editing personal information | `allowEditPersonalInfo` | `true` | -| Allow avatar upload | `allowUploadAvatar` | `true` | -| Maximum avatar size (kB) | `maxAvatarSize` | `2048` | -| Allow cover image upload | `allowUploadCover` | `true` | -| Maximum cover size (kB) | `maxCoverSize` | `5120` | - -A user with several roles is allowed as soon as **one** of them allows the -image, and gets the **most generous** cap among those roles. The cap is shown in -the dialog before a file is picked, and checked again on the server. - -### API - -| Method | Path | Who | -| :------- | :-------------------------------------------------- | :--------------------------------------------------------------- | -| `GET` | `/api/@vitnode/core/users/images/policy` | Signed-in user - what they may upload, and the caps in bytes | -| `POST` | `/api/@vitnode/core/users/images/{kind}` | Signed-in user - multipart `file`, `kind` is `avatar` or `cover` | -| `DELETE` | `/api/@vitnode/core/users/images/{kind}` | Signed-in user | -| `POST` | `/api/@vitnode/core/admin/users/{id}/images/{kind}` | Staff with `users:can_edit` | -| `DELETE` | `/api/@vitnode/core/admin/users/{id}/images/{kind}` | Staff with `users:can_edit` | - -Accepted types are JPEG, PNG and WebP. The browser crops to `512×512` for an -avatar and `1800×600` for a cover and sends WebP where the browser can encode -it, so uploads are usually far under the cap. A refused upload answers `400` -with `{ error }` written for the person who picked the file, and a role that -does not allow the image answers `403`. - -Both writes emit an event - -[`user.avatar.updated` and `user.cover.updated`](/docs/dev/events/built-in-events) - -- with the new `core_files` id, or `null` when the image was removed. - -## Learn More +`useAdminSessionQuery()` suspends instead of returning `undefined`, and the AdminCP layout loads it, so every screen below the layout reads it from the cache. Two shorter hooks from the same import sit on top of it: + +| Hook | Returns | +| :----------------- | :-------------------------------------------------------------- | +| `useAdminAccess()` | The whole access state: `granted`, or why access was refused | +| `useAdminUser()` | The signed-in administrator, or `null` when access is refused | + +[Auth and sessions](/docs/dev/advanced/auth) explains why there are two sessions and how long each lasts. + +## The user record + +Users live in the `core_users` table. These are the fields `c.get("user")` and the session carry: + +| Column | Type | Description | +| :-------------- | :------------- | :----------------------------------------------------- | +| `id` | `serial` | Permanent identifier | +| `name` | `varchar(255)` | Display name (the nickname) | +| `nameCode` | `varchar(255)` | URL-safe handle for the profile URL `/users/[nameCode]` | +| `email` | `varchar(255)` | Unique sign-in email | +| `emailVerified` | `boolean` | Whether the email is confirmed | +| `firstName` | `varchar(128)` | Given name, set by the user (nullable) | +| `lastName` | `varchar(128)` | Family name, set by the user (nullable) | +| `phone` | `varchar(32)` | Contact number (nullable) | +| `headline` | `varchar(100)` | One line about the user (nullable) | +| `showRealName` | `boolean` | Show the real name publicly instead of `name` | +| `birthday` | `timestamp` | Birthday, edited in the AdminCP (nullable) | +| `newsletter` | `boolean` | Newsletter opt-in | +| `roleId` | `integer` | Primary role (foreign key to `core_roles`) | +| `avatarColor` | `varchar(6)` | Hex colour for the letter avatar | +| `avatarId` | `integer` | Uploaded avatar, a `core_files` row (nullable) | +| `coverId` | `integer` | Uploaded profile cover, a `core_files` row (nullable) | +| `language` | `varchar(32)` | Preferred UI locale (defaults to `en`) | +| `timeZone` | `varchar(64)` | IANA time zone (nullable) | +| `createdAt` | `timestamp` | When the account was created | + +Every user payload the API hands out (the session, the public profile, the AdminCP list and detail, search hit authors) carries the two images as URLs, `avatarUrl` and `coverUrl`, instead of the file ids. Both are `null` until someone uploads an image. The `Avatar` component then draws a letter avatar from the first character of `nameCode`, never of the displayed name, so a member who switches to showing their real name keeps the same face everywhere. + +## Related <Cards> <Card - title="Roles" - description="Configure permissions, color badges, and user groups" - href="/docs/dev/working-with-users/roles" + title="Profile fields" + description="Personal information, time zone, real names and who may edit them" + href="/docs/dev/working-with-users/profile-fields" /> <Card - title="Staff Permissions" - description="Grant administrative and moderation privileges" - href="/docs/dev/working-with-users/staff-permissions" + title="Avatars and covers" + description="Profile images, the role caps and the upload routes" + href="/docs/dev/working-with-users/avatars" /> <Card - title="SSO" - description="Configure social login providers" - href="/docs/dev/sso" + title="Manage users" + description="Find, create and edit accounts in the AdminCP" + href="/docs/dev/working-with-users/admin-users" /> </Cards> diff --git a/apps/web/content/docs/guides/blog.mdx b/apps/web/content/docs/guides/blog.mdx index 596f734277..4eb0bb4cbc 100644 --- a/apps/web/content/docs/guides/blog.mdx +++ b/apps/web/content/docs/guides/blog.mdx @@ -212,7 +212,7 @@ A count only includes articles a reader can open in that language, the same ones `/blog` lists. Names stay cached until a category changes, and counts until an article is published, edited, hidden or deleted. To put a blog list on another page with your own layout, see -[Add a list to another page](/docs/content-engine/public-lists-embed). +[Embedded list](/docs/content-engine/public-lists-embed). ### Article authors @@ -246,7 +246,7 @@ curl "http://localhost:3000/api/@vitnode/blog/content/blog?locale=en&first=1" } ``` -The response is trimmed here. A deleted account simply drops out of the list. +The response is trimmed here. A deleted account drops out of the list. ## Write and translate articles @@ -295,8 +295,8 @@ The **More actions** menu of an article in the list has **Hide** and sitemap without unpublishing it, and **Unhide** brings it back with the same URL and date. It needs the **Hide and unhide articles** permission. Duplicating makes a draft copy with every translation, such as "Hello world (Copy)". See -[Hide content](/docs/content-engine/content-visibility) and -[Duplicate content](/docs/content-engine/duplicate-content). +[Visibility](/docs/content-engine/content-visibility) and +[Duplication](/docs/content-engine/duplicate-content). ### Translate side by side @@ -384,7 +384,7 @@ together. <Card icon={<RouteIcon />} title="Build your first plugin" - description="Create the plugin that will own your site’s public blog route." + description="Create the plugin that will own your site's public blog route." href="/docs/guides/first-plugin" /> <Card diff --git a/apps/web/content/docs/guides/first-plugin.mdx b/apps/web/content/docs/guides/first-plugin.mdx index bb38feaddf..afc7022aa5 100644 --- a/apps/web/content/docs/guides/first-plugin.mdx +++ b/apps/web/content/docs/guides/first-plugin.mdx @@ -1,127 +1,56 @@ --- -title: Build Your First Plugin -description: Create a VitNode plugin step by step, add a TanStack Start route, register it, and open a working page. +title: First plugin +description: Generate a VitNode plugin in a few minutes, open its page at /site-notes, and continue with the lessons that add an API, a database table, permissions and an AdminCP screen. icon: PackagePlus --- -import { LayoutDashboardIcon, NetworkIcon, RouteIcon } from 'lucide-react' -import { Tab, Tabs } from 'fumadocs-ui/components/tabs' +import { Tab, Tabs } from "fumadocs-ui/components/tabs"; -This tutorial builds `@acme/site-notes`, a tiny plugin with a page at -`/site-notes`. Start from a VitNode workspace with Turborepo enabled; it gives -the plugin generator a shared home. +import { ImgDocs } from "@/components/fumadocs/img"; +import generatedPage from "../dev/plugins/create-generated-page.webp"; + +This is the short version: generate a plugin and see its page. The [Learn lessons](/docs/dev) build the same `site-notes` plugin into a real feature. + +## Before you begin + +- A VitNode app created with **Turborepo** turned on, running locally. [Setup](/docs/dev/setup) walks through it. <Steps> - <Step> -### Generate `@acme/site-notes` +<Step> + +### Generate the plugin -Run the generator from the workspace root and enter `@acme/site-notes` when it -asks for a name: +From the workspace root: <Tabs groupId="package-manager" persist items={["bun", "pnpm", "npm"]} label="Generate the plugin"> ```bash tab="bun" -bun create vitnode-app@canary --plugin +bun create vitnode-app@canary site-notes --plugin ``` ```bash tab="pnpm" -pnpm create vitnode-app@canary --plugin +pnpm create vitnode-app@canary site-notes --plugin ``` ```bash tab="npm" -npm create vitnode-app@canary -- --plugin +npm create vitnode-app@canary site-notes -- --plugin ``` </Tabs> -The result has `routes.ts`, `pages/home-page.tsx`, `locales/en.json`, and -`config.tsx`. The CLI adds a workspace dependency but leaves activation to you, -which makes installed plugins predictable. - - </Step> - <Step> - -### Claim the page URL - -The route tree belongs to the plugin and is all the host needs to discover a -route. `lazy` names the page module without importing it, so the page gets a -chunk of its own: - -```ts title="plugins/site-notes/src/routes.ts" -import { definePluginRoutes, lazy, page } from '@vitnode/core/routing' - -export const routes = definePluginRoutes([ - // [!code ++:3] - page('/site-notes', { - component: lazy(() => import('./pages/home-page')), - }), -]) -``` - - </Step> - <Step> - -### Render a translated page - -Edit the generated route module. It stays framework-neutral and is lazily -loaded by the TanStack Start host: - -```tsx title="plugins/site-notes/src/pages/home-page.tsx" -import { useTranslations } from 'use-intl' +The generator creates `plugins/site-notes` and registers it in your app's VitNode configs, so there is nothing to enable by hand. -const HomePage = () => { - const t = useTranslations('@acme/site-notes') // [!code ++] +</Step> - return ( - <div className="container mx-auto flex max-w-2xl flex-col gap-4 p-4"> - <h2 className="text-2xl font-semibold">{t('home.title')}</h2> - <p className="text-muted-foreground">{t('home.desc')}</p> - </div> - ) -} +<Step> -export default HomePage -``` - -```json title="plugins/site-notes/src/locales/en.json" -{ - "@acme/site-notes": { - "home": { - "title": "Site notes", - "desc": "This page ships from a plugin. Neat, right?" - } - } -} -``` - - </Step> - <Step> - -### Enable it in the host - -The package must be in the host's `plugins` array. This is the only composition -step; routes and pages remain inside `plugins/site-notes`: - -```ts title="apps/web/src/vitnode.config.ts" -import { siteNotesPlugin } from '@acme/site-notes/config' // [!code ++] +### Run it -export const vitNodeConfig = buildConfig({ - plugins: [ - siteNotesPlugin(), // [!code ++] - ], -}) -``` - - </Step> - <Step> - -### Run and inspect the result - -<Tabs groupId="package-manager" persist items={["bun", "pnpm", "npm"]} label="Run the plugin"> +<Tabs groupId="package-manager" persist items={["bun", "pnpm", "npm"]} label="Start the app"> ```bash tab="bun" -bun dev +bun run dev ``` ```bash tab="pnpm" @@ -134,32 +63,17 @@ npm run dev </Tabs> -Visit `http://localhost:3000/site-notes`. +</Step> -{/* Image prompt: Tutorial verification screenshot for a VitNode app at /site-notes. Show a simple “Site notes” page, browser address bar, and a compact visual hint that it is loaded from a plugin package. Dark theme, 1440x900. */} - - </Step> </Steps> -## Grow the same plugin - -<Cards> - <Card - icon={<RouteIcon />} - title="Add route behavior" - description="Use loaders, metadata, breadcrumbs, and parameters in the plugin route." - href="/docs/dev/routing" - /> - <Card - icon={<NetworkIcon />} - title="Add an API module" - description="Keep typed Hono routes beside the page that needs them." - href="/docs/dev/plugins/api/modules" - /> - <Card - icon={<LayoutDashboardIcon />} - title="Add AdminCP tools" - description="Give staff a plugin-owned screen or dashboard widget." - href="/docs/dev/plugins/admin" - /> -</Cards> +## Check the result + +Open `http://localhost:3000/site-notes`. The page comes from `plugins/site-notes/src/pages/home-page.tsx`, and `http://localhost:3000/api/site-notes/hello` answers from the plugin's API. + +<ImgDocs + withoutBackground + className="*:max-w-full" + src={generatedPage} + alt="The generated site-notes plugin page at /site-notes in a VitNode app" +/> diff --git a/apps/web/content/docs/guides/forum.mdx b/apps/web/content/docs/guides/forum.mdx index f952dc6f27..ab460d0aa6 100644 --- a/apps/web/content/docs/guides/forum.mdx +++ b/apps/web/content/docs/guides/forum.mdx @@ -12,7 +12,7 @@ everything you learn here works the same way in your own plugins. <Callout type="info" title="Lives in the monorepo"> The plugin is `private: true` and ships with the VitNode repository, like - `@vitnode/example`. It is not published to npm yet. + the `newsroom` plugin. It is not published to npm yet. </Callout> <Steps> diff --git a/apps/web/package.json b/apps/web/package.json index 65560f952a..f8b5f84d8d 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -29,8 +29,8 @@ "@tanstack/react-start": "^1.169.1", "@vitnode/blog": "workspace:*", "@vitnode/core": "workspace:*", - "@vitnode/example": "workspace:*", "@vitnode/forum": "workspace:*", + "newsroom": "workspace:*", "@vitnode/supabase-storage": "workspace:*", "d3-shape": "^3.2.0", "dotenv": "^18.0.6", diff --git a/apps/web/src/admin-nav.gen.ts b/apps/web/src/admin-nav.gen.ts index 39e211ce02..c19c78cc1d 100644 --- a/apps/web/src/admin-nav.gen.ts +++ b/apps/web/src/admin-nav.gen.ts @@ -18,14 +18,14 @@ import type { AdminNavPluginSource } from '@vitnode/core/lib/plugin' import { adminNavBundle } from '@vitnode/core/tanstack/admin' import { adminNav as adminNav0 } from '@vitnode/blog/admin/nav' -import { adminNav as adminNav1 } from '@vitnode/example/admin/nav' -import { adminNav as adminNav2 } from '@vitnode/forum/admin/nav' +import { adminNav as adminNav1 } from '@vitnode/forum/admin/nav' +import { adminNav as adminNav2 } from 'newsroom/admin/nav' export const pluginAdminNav = [ adminNav0, // @vitnode/blog - adminNav1, // @vitnode/example - adminNav2, // @vitnode/forum + adminNav1, // @vitnode/forum + adminNav2, // newsroom ] satisfies AdminNavPluginSource[] export const adminNav = adminNavBundle({ plugins: pluginAdminNav }) diff --git a/apps/web/src/api-registry.gen.ts b/apps/web/src/api-registry.gen.ts index ecd4051cb0..c2fae99d8b 100644 --- a/apps/web/src/api-registry.gen.ts +++ b/apps/web/src/api-registry.gen.ts @@ -20,14 +20,14 @@ // plugin id. import type { VitNodeApiPlugin as ApiPlugin0 } from '@vitnode/blog/config.api' -import type { VitNodeApiPlugin as ApiPlugin1 } from '@vitnode/example/config.api' -import type { VitNodeApiPlugin as ApiPlugin2 } from '@vitnode/forum/config.api' +import type { VitNodeApiPlugin as ApiPlugin1 } from '@vitnode/forum/config.api' +import type { VitNodeApiPlugin as ApiPlugin2 } from 'newsroom/config.api' declare module '@vitnode/core/lib/fetcher/registry' { interface ApiPluginRegistry { '@vitnode/blog': ApiPlugin0 - '@vitnode/example': ApiPlugin1 - '@vitnode/forum': ApiPlugin2 + '@vitnode/forum': ApiPlugin1 + 'newsroom': ApiPlugin2 } } diff --git a/apps/web/src/blocks.gen.ts b/apps/web/src/blocks.gen.ts index 7586692e83..8978419eec 100644 --- a/apps/web/src/blocks.gen.ts +++ b/apps/web/src/blocks.gen.ts @@ -29,12 +29,12 @@ import type { BlockPluginSource } from '@vitnode/core/widgets' import { createBlockRegistry, setDefaultBlockRegistry } from '@vitnode/core/widgets' import { blocks as blocks0 } from '@vitnode/core/widgets/built-in' -import { blocks as blocks1 } from '@vitnode/example/widgets' +import { blocks as blocks1 } from 'newsroom/widgets' export const pluginBlocks = [ blocks0, // @vitnode/core - blocks1, // @vitnode/example + blocks1, // newsroom ] satisfies BlockPluginSource[] export const blocksRegistry = createBlockRegistry(pluginBlocks) diff --git a/apps/web/src/content-modules.gen.ts b/apps/web/src/content-modules.gen.ts index a5152b2c8f..90d7e32514 100644 --- a/apps/web/src/content-modules.gen.ts +++ b/apps/web/src/content-modules.gen.ts @@ -12,11 +12,11 @@ // plugin id. import * as contentModule0 from '@vitnode/blog/content' -import * as contentModule1 from '@vitnode/example/content' -import * as contentModule2 from '@vitnode/forum/content' +import * as contentModule1 from '@vitnode/forum/content' +import * as contentModule2 from 'newsroom/content' export const contentModules: Readonly<Record<string, unknown>> = { '@vitnode/blog': contentModule0, - '@vitnode/example': contentModule1, - '@vitnode/forum': contentModule2, + '@vitnode/forum': contentModule1, + 'newsroom': contentModule2, } diff --git a/apps/web/src/content-registry.gen.ts b/apps/web/src/content-registry.gen.ts index 52f663bb9c..0ec4d966a3 100644 --- a/apps/web/src/content-registry.gen.ts +++ b/apps/web/src/content-registry.gen.ts @@ -18,14 +18,14 @@ import type { ContentFrontendPluginSource } from '@vitnode/core/lib/plugin' import { buildContentFrontendRegistry, setContentFrontendRegistry } from '@vitnode/core/content' import { adminContent as adminContent0 } from '@vitnode/blog/admin/content' -import { adminContent as adminContent1 } from '@vitnode/example/admin/content' -import { adminContent as adminContent2 } from '@vitnode/forum/admin/content' +import { adminContent as adminContent1 } from '@vitnode/forum/admin/content' +import { adminContent as adminContent2 } from 'newsroom/admin/content' export const pluginContentTypes = [ adminContent0, // @vitnode/blog - adminContent1, // @vitnode/example - adminContent2, // @vitnode/forum + adminContent1, // @vitnode/forum + adminContent2, // newsroom ] satisfies ContentFrontendPluginSource[] export const contentRegistry = buildContentFrontendRegistry(pluginContentTypes) diff --git a/apps/web/src/docs/docs.css b/apps/web/src/docs/docs.css index 0942508c9d..2fe35bd1da 100644 --- a/apps/web/src/docs/docs.css +++ b/apps/web/src/docs/docs.css @@ -39,6 +39,11 @@ --tw-prose-body: oklch(0.373 0.003 264); } +/* Long paths and file names in inline code would otherwise widen the page on phones. */ +.prose :not(pre) > code { + overflow-wrap: anywhere; +} + @keyframes vt-enter { from { opacity: 0; diff --git a/apps/web/src/locales/site/en.json b/apps/web/src/locales/site/en.json index 7b4db3e5f9..ae99bb4609 100644 --- a/apps/web/src/locales/site/en.json +++ b/apps/web/src/locales/site/en.json @@ -318,7 +318,7 @@ "discover": "Discover", "discover_description": "Fresh posts from every corner of the site.", "browse": "Browse", - "browse_description": "A paginated list, shipped by the example plugin.", + "browse_description": "A paginated list, shipped by the newsroom plugin.", "search": "Search" } } diff --git a/apps/web/src/locales/site/pl.json b/apps/web/src/locales/site/pl.json index 1982398935..0e14ad0338 100644 --- a/apps/web/src/locales/site/pl.json +++ b/apps/web/src/locales/site/pl.json @@ -318,7 +318,7 @@ "discover": "Odkrywaj", "discover_description": "Najnowsze wpisy z całej strony.", "browse": "Przeglądaj", - "browse_description": "Lista ze stronicowaniem z przykładowej wtyczki.", + "browse_description": "Lista ze stronicowaniem z wtyczki newsroom.", "search": "Szukaj" } } diff --git a/apps/web/src/package-messages.gen.ts b/apps/web/src/package-messages.gen.ts index f64e07a84d..dcee66bf87 100644 --- a/apps/web/src/package-messages.gen.ts +++ b/apps/web/src/package-messages.gen.ts @@ -27,10 +27,10 @@ export const packageMessages: Record<string, LocaleMessagesMap> = { '@vitnode/blog': { en: async () => await import('@vitnode/blog/locales/en.json'), }, - '@vitnode/example': { - en: async () => await import('@vitnode/example/locales/en.json'), - }, '@vitnode/forum': { en: async () => await import('@vitnode/forum/locales/en.json'), }, + 'newsroom': { + en: async () => await import('newsroom/locales/en.json'), + }, } diff --git a/apps/web/src/page-cache.gen.ts b/apps/web/src/page-cache.gen.ts index 96a393263f..3103dbe0bc 100644 --- a/apps/web/src/page-cache.gen.ts +++ b/apps/web/src/page-cache.gen.ts @@ -50,22 +50,25 @@ export const pageCacheRoutes: PageCacheRouteTable = [ { path: '/forum/topics/:id/edit' }, { path: '/blog/article/:slug', maxAge: 300, staleWhileRevalidate: 60 }, { path: '/blog/category/:slug', maxAge: 300, staleWhileRevalidate: 60 }, - { path: '/example/guide/:topic' }, { path: '/forum/comments/:slug' }, { path: '/forum/topics/:slug', maxAge: 60, staleWhileRevalidate: 30 }, { path: '/login/sso/:providerId' }, { path: '/modcp/approvals/:id' }, { path: '/modcp/reports/:id' }, - { path: '/admin/example' }, - { path: '/discussions/new' }, - { path: '/example/browse' }, - { path: '/example/embed' }, - { path: '/example/guide' }, - { path: '/example/zones' }, + { path: '/newsroom/guide/:topic' }, + { path: '/newsroom/notes/:slug' }, + { path: '/newsroom/pages/:slug' }, + { path: '/admin/newsroom' }, + { path: '/letters/new' }, { path: '/login/reset-password' }, { path: '/login/verify-email' }, { path: '/modcp/approvals' }, { path: '/modcp/reports' }, + { path: '/news/new' }, + { path: '/newsroom/browse' }, + { path: '/newsroom/embed' }, + { path: '/newsroom/guide' }, + { path: '/newsroom/zones' }, { path: '/settings/ai' }, { path: '/settings/devices' }, { path: '/settings/notifications' }, @@ -75,27 +78,30 @@ export const pageCacheRoutes: PageCacheRouteTable = [ { path: '/docs/search' }, { path: '/admin/containers/*' }, { path: '/admin/content/*' }, - { path: '/discussions/:id/edit' }, - { path: '/advanced-articles/:slug' }, - { path: '/articles/:slug' }, + { path: '/letters/:id/edit' }, + { path: '/news/:id/edit' }, { path: '/blog/:slug' }, - { path: '/boards/:slug' }, - { path: '/discussions/:slug' }, + { path: '/bulletins/:slug' }, + { path: '/desks/:slug' }, { path: '/events/:slug' }, - { path: '/example/:slug' }, + { path: '/explainers/:slug' }, { path: '/forum/:slug', maxAge: 60, staleWhileRevalidate: 30 }, - { path: '/localized-articles/:slug' }, + { path: '/letters/:slug' }, + { path: '/news/:slug' }, + { path: '/stories/:slug' }, { path: '/users/:nameCode' }, { path: '/solutions/:slug' }, { path: '/admin' }, { path: '/blog', maxAge: 300, staleWhileRevalidate: 60 }, - { path: '/boards' }, + { path: '/desks' }, { path: '/discover' }, - { path: '/discussions' }, { path: '/files' }, { path: '/forum', maxAge: 60, staleWhileRevalidate: 30 }, + { path: '/letters' }, { path: '/login' }, { path: '/modcp' }, + { path: '/news' }, + { path: '/newsroom' }, { path: '/notifications' }, { path: '/register' }, { path: '/search' }, diff --git a/apps/web/src/plugin-inventory.gen.ts b/apps/web/src/plugin-inventory.gen.ts index 889784b265..ed58bb965a 100644 --- a/apps/web/src/plugin-inventory.gen.ts +++ b/apps/web/src/plugin-inventory.gen.ts @@ -34,23 +34,6 @@ export const frontendPlugins: FrontendPluginInventory = [ updates: { url: "https://raw.githubusercontent.com/aXenDeveloper/vitnode/update-manifest/updates.json" }, }, }, - { - id: "@vitnode/example", - manifest: { - id: "@vitnode/example", - title: "Example", - icon: "flask-conical", - description: "Reference plugin exercising the VitNode Content Engine end to end.", - author: { - name: "VitNode", - email: "contact@vitnode.com", - url: "https://vitnode.com", - }, - license: "MIT", - version: "2.0.0-canary.13", - updates: null, - }, - }, { id: "@vitnode/forum", manifest: { @@ -68,4 +51,17 @@ export const frontendPlugins: FrontendPluginInventory = [ updates: null, }, }, + { + id: "newsroom", + manifest: { + id: "newsroom", + title: "Newsroom", + icon: "puzzle", + description: "Newsroom: the VitNode tutorial plugin, grown into a tour of the Content Engine.", + author: null, + license: null, + version: "0.1.0", + updates: null, + }, + }, ] diff --git a/apps/web/src/plugin-routes.gen.ts b/apps/web/src/plugin-routes.gen.ts index 33a29ea2f6..53a1f3945e 100644 --- a/apps/web/src/plugin-routes.gen.ts +++ b/apps/web/src/plugin-routes.gen.ts @@ -20,8 +20,8 @@ import type { PluginRouteDeclarationSource } from '@vitnode/core/routing' import { routes as pluginRoutes0 } from '@vitnode/blog/routes' import { routes as pluginRoutes1 } from '@vitnode/core/routes' -import { routes as pluginRoutes2 } from '@vitnode/example/routes' -import { routes as pluginRoutes3 } from '@vitnode/forum/routes' +import { routes as pluginRoutes2 } from '@vitnode/forum/routes' +import { routes as pluginRoutes3 } from 'newsroom/routes' export const pluginRouteSources = [ @@ -34,11 +34,11 @@ export const pluginRouteSources = [ routes: pluginRoutes1, }, { - pluginId: '@vitnode/example', + pluginId: '@vitnode/forum', routes: pluginRoutes2, }, { - pluginId: '@vitnode/forum', + pluginId: 'newsroom', routes: pluginRoutes3, }, ] as const satisfies readonly PluginRouteDeclarationSource[] diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index 64284c42fb..357c63b1c1 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -25,7 +25,7 @@ import { vitNodeConfig } from '@/vitnode.config' import appCss from '../styles.css?url' -const { debug, editor, i18n, logo, metadata, theme } = vitNodeConfig +const { editor, i18n, logo, metadata, theme } = vitNodeConfig export interface RootRouterContext { queryClient: QueryClient @@ -70,7 +70,7 @@ export const Route = createRootRouteWithContext<RootRouterContext>()({ function RootComponent() { return ( <VitNodeRootProviders - config={{ debug, editor, locales: i18n.locales, logo, theme }} + config={{ editor, locales: i18n.locales, logo, theme }} > <Outlet /> </VitNodeRootProviders> diff --git a/apps/web/src/site/plugins/plugins-page.tsx b/apps/web/src/site/plugins/plugins-page.tsx index 9609df16e3..e52f69ebdb 100644 --- a/apps/web/src/site/plugins/plugins-page.tsx +++ b/apps/web/src/site/plugins/plugins-page.tsx @@ -42,7 +42,7 @@ interface OfficialPlugin { const OFFICIAL_PLUGINS: OfficialPlugin[] = [ { description: - 'Articles and categories built entirely on the Content Engine: drafts, revisions and scheduled publishing, signed preview links, multiple authors, cover images and a translation for every field. It ships the content types, the AdminCP screens and a public API. The public pages are yours to design, and the example plugin shows how.', + 'Articles and categories built entirely on the Content Engine: drafts, revisions and scheduled publishing, signed preview links, multiple authors, cover images and a translation for every field. It ships the content types, the AdminCP screens and a public API. The public pages are yours to design, and the newsroom plugin shows how.', highlights: [ 'Articles, categories and multiple authors', 'Drafts, 20 revisions and scheduled publishing', @@ -59,16 +59,16 @@ const OFFICIAL_PLUGINS: OfficialPlugin[] = [ }, { description: - 'A small plugin that exists to be read. It ships public pages with loaders and a searchable browse page, an AdminCP page with navigation, four content types including a localized one, a typed Hono API module and event listeners, all wired the way the docs describe. Copy it, rename it, ship it.', + 'The plugin the Content Engine docs build step by step, then keep growing. It ships articles with comments and member writing, stories with files and scheduling, localized bulletins, uuid tags and bigint events, a desk tree for letters to the editor, block pages and widget zones, an AdminCP page and a typed Hono API module, all wired the way the docs describe. Copy it, rename it, ship it.', highlights: [ - 'Public pages with loaders and a browse page with search', - 'A typed Hono API module and event listeners', - 'Four content types, including a localized one', - 'An AdminCP page and navigation entry', + 'Articles, comments and member writing from the tutorial', + 'Localized content, relations and every id strategy', + 'Containers, moderation scopes and widget zones', + 'An AdminCP page, an AI action and event listeners', ], Icon: FlaskConical, - name: 'Example', - pkg: '@vitnode/example', + name: 'Newsroom', + pkg: 'newsroom', screen: 'integrations', status: 'reference', }, @@ -157,7 +157,7 @@ export const PluginsPage = () => ( id="official-title" title="One you can install. One you should read." > - The blog ships today, and the example plugin is the friendliest way to + The blog ships today, and the newsroom plugin is the friendliest way to learn how a plugin is put together. </SectionHeading> diff --git a/apps/web/src/vitnode.api.config.ts b/apps/web/src/vitnode.api.config.ts index 2a955190fa..12410a095e 100644 --- a/apps/web/src/vitnode.api.config.ts +++ b/apps/web/src/vitnode.api.config.ts @@ -1,11 +1,11 @@ import { blogApiPlugin } from '@vitnode/blog/config.api' import { coreRelations } from '@vitnode/core/database/relations' import { buildApiConfig } from '@vitnode/core/vitnode.config' -import { exampleApiPlugin } from '@vitnode/example/config.api' import { forumApiPlugin } from '@vitnode/forum/config.api' import { SupabaseStorageAdapter } from '@vitnode/supabase-storage' import { config } from 'dotenv' import { drizzle } from 'drizzle-orm/postgres-js' +import { newsroomApiPlugin } from 'newsroom/config.api' import { contentModules } from './content-modules.gen' import { appMessages } from './locales/app' @@ -28,7 +28,7 @@ export const POSTGRES_URL = * them - `buildApiConfig` treats all of them as optional. */ export const vitNodeApiConfig = buildApiConfig({ - plugins: [blogApiPlugin(), exampleApiPlugin(), forumApiPlugin()], + plugins: [blogApiPlugin(), forumApiPlugin(), newsroomApiPlugin()], contentModules, frontendPlugins, storage: { diff --git a/apps/web/src/vitnode.config.ts b/apps/web/src/vitnode.config.ts index e86a01cbfa..13f698734f 100644 --- a/apps/web/src/vitnode.config.ts +++ b/apps/web/src/vitnode.config.ts @@ -1,7 +1,6 @@ import { blogPlugin } from '@vitnode/blog/config' import { LogoVitNodeBrand } from '@vitnode/core/components/logo-vitnode' import { buildConfig } from '@vitnode/core/vitnode.config' -import { examplePlugin } from '@vitnode/example/config' import { forumPlugin } from '@vitnode/forum/config' import { BookOpenIcon, @@ -12,9 +11,9 @@ import { PuzzleIcon, SearchIcon, } from 'lucide-react' +import { newsroomPlugin } from 'newsroom/config' export const vitNodeConfig = buildConfig({ - debug: false, editor: { emojis: [ { @@ -67,7 +66,7 @@ export const vitNodeConfig = buildConfig({ }, { description: 'site.menu.browse_description', - href: '/example/browse', + href: '/newsroom/browse', icon: ListIcon, title: 'site.menu.browse', }, @@ -81,7 +80,7 @@ export const vitNodeConfig = buildConfig({ { href: '/search', icon: SearchIcon, title: 'site.menu.search' }, ], }, - plugins: [blogPlugin(), examplePlugin(), forumPlugin()], + plugins: [blogPlugin(), forumPlugin(), newsroomPlugin()], theme: { defaultTheme: 'system', }, diff --git a/packages/create-vitnode-app/copy-of-vitnode-app/README.md b/packages/create-vitnode-app/copy-of-vitnode-app/README.md index 6cdbd7447d..432d9dac98 100644 --- a/packages/create-vitnode-app/copy-of-vitnode-app/README.md +++ b/packages/create-vitnode-app/copy-of-vitnode-app/README.md @@ -43,10 +43,10 @@ pnpm dev Two files, and the line between them is one question: may a browser hold this? -| File | Holds | -| :----------------------------- | :----------------------------------------------------------------------------------------------------------------------- | -| `src/vitnode.config.ts` | locales, metadata, theme, `debug`, enabled plugin ids - plain data, read by the browser, the server, and your Vite build | -| `src/vitnode.server.config.ts` | message loaders - server only | +| File | Holds | +| :----------------------------- | :--------------------------------------------------------------------------------------------------------------- | +| `src/vitnode.config.ts` | locales, metadata, theme and enabled plugin ids. Plain data, read by the browser, the server and your Vite build | +| `src/vitnode.server.config.ts` | message loaders. Server only | Add a language to `i18n.locales` in the shared config; register the files that translate it in `src/locales/packages.ts` (a package's own translations) or diff --git a/packages/create-vitnode-app/copy-of-vitnode-app/root/src/routes/__root.tsx b/packages/create-vitnode-app/copy-of-vitnode-app/root/src/routes/__root.tsx index cb0130f685..d93344d628 100644 --- a/packages/create-vitnode-app/copy-of-vitnode-app/root/src/routes/__root.tsx +++ b/packages/create-vitnode-app/copy-of-vitnode-app/root/src/routes/__root.tsx @@ -23,7 +23,7 @@ import { vitNodeConfig } from "@/vitnode.config"; import appCss from "../styles.css?url"; -const { debug, editor, i18n, logo, metadata, theme } = vitNodeConfig; +const { editor, i18n, logo, metadata, theme } = vitNodeConfig; export interface RootRouterContext { queryClient: QueryClient; @@ -63,7 +63,7 @@ export const Route = createRootRouteWithContext<RootRouterContext>()({ function RootComponent() { return ( <VitNodeRootProviders - config={{ debug, editor, locales: i18n.locales, logo, theme }} + config={{ editor, locales: i18n.locales, logo, theme }} > <Outlet /> </VitNodeRootProviders> diff --git a/packages/create-vitnode-app/copy-of-vitnode-app/root/src/vitnode.config.ts b/packages/create-vitnode-app/copy-of-vitnode-app/root/src/vitnode.config.ts index e146beecdd..2a65ee658e 100644 --- a/packages/create-vitnode-app/copy-of-vitnode-app/root/src/vitnode.config.ts +++ b/packages/create-vitnode-app/copy-of-vitnode-app/root/src/vitnode.config.ts @@ -2,7 +2,6 @@ import { buildConfig } from "@vitnode/core/vitnode.config"; import { CompassIcon, SearchIcon } from "lucide-react"; export const vitNodeConfig = buildConfig({ - debug: false, i18n: { defaultLocale: "en", locales: [{ code: "en", name: "English" }], diff --git a/packages/vitnode/package.json b/packages/vitnode/package.json index 23d0f14fb0..d3b818895d 100644 --- a/packages/vitnode/package.json +++ b/packages/vitnode/package.json @@ -281,7 +281,6 @@ "commander": "^15.0.0", "cron-parser": "^5.10.1", "d3-shape": "^3.2.0", - "date-fns": "^4.4.0", "dotenv": "^18.0.6", "embla-carousel-react": "^8.6.0", "input-otp": "^1.5.0", @@ -294,7 +293,6 @@ "react-colorful": "^5.8.1", "react-day-picker": "^10.0.2", "react-resizable-panels": "^4.14.3", - "react-scan": "^0.5.7", "redis": "^6.3.0", "sanitize-html": "^2.18.0", "semver": "^7.8.5", diff --git a/packages/vitnode/src/api/config.ts b/packages/vitnode/src/api/config.ts index 90eaf58872..c2dcb777a3 100644 --- a/packages/vitnode/src/api/config.ts +++ b/packages/vitnode/src/api/config.ts @@ -2,7 +2,6 @@ import type { OpenAPIHono } from "@hono/zod-openapi"; import type { Context, Env, Schema } from "hono"; import { swaggerUI } from "@hono/swagger-ui"; -import { bodyLimit } from "hono/body-limit"; import { cors } from "hono/cors"; import { csrf } from "hono/csrf"; import { HTTPException } from "hono/http-exception"; @@ -35,6 +34,7 @@ import { generatedContentModules, packageContentModules, } from "./lib/content-modules"; +import { bodyLimitMiddleware } from "./middlewares/body-limit.middleware"; import { globalAdminMiddleware, globalMiddleware, @@ -229,7 +229,7 @@ export function VitNodeAPI({ // wall, and `maxBodySize` moves it for an install that stores large media. app.use( "*", - bodyLimit({ + bodyLimitMiddleware({ maxSize: vitNodeApiConfig.maxBodySize ?? DEFAULT_MAX_BODY_SIZE, onError: c => c.json({ error: "Payload Too Large" }, 413), }), diff --git a/packages/vitnode/src/api/lib/route.ts b/packages/vitnode/src/api/lib/route.ts index e01491062e..01bd6548e5 100644 --- a/packages/vitnode/src/api/lib/route.ts +++ b/packages/vitnode/src/api/lib/route.ts @@ -5,6 +5,7 @@ import type { ZodError } from "zod"; import { createRoute as createRouteHono } from "@hono/zod-openapi"; import type { EnvVitNode } from "../middlewares/global.middleware"; +import type { PermissionStaffType } from "./permission-staff"; import { captchaMiddleware } from "../middlewares/captcha.middleware"; import { pluginMiddleware } from "../middlewares/plugin.middleware"; @@ -21,7 +22,7 @@ export type RouteValidationHook = ( c: Context, ) => Promise<Response | undefined> | Response | undefined; -/** The admin staff permission a route requires. */ +/** The staff permission a route requires. */ export interface AdminStaffPermission { /** The permission module, e.g. `posts`. */ module: string; @@ -31,8 +32,27 @@ export interface AdminStaffPermission { plugin?: string; } +/** The moderator staff permission a route requires. */ +export type ModeratorStaffPermission = AdminStaffPermission; + const staffPermissionGuards = new WeakSet<MiddlewareHandler>(); +const staffPermissionGuard = + ( + type: PermissionStaffType, + { plugin, module, permission }: AdminStaffPermission, + pluginId: string, + ): MiddlewareHandler => + async (c, next) => { + await assertStaffPermission(c, { + type, + plugin: plugin ?? pluginId, + module, + permission, + }); + await next(); + }; + interface MountedRoute { handler: unknown; method: string; @@ -97,6 +117,14 @@ export interface BuildRouteOptions< /** The Hono handler, typed from `route`: `c.req.valid(...)` and `c.json(...)` follow its schemas. */ handler: RouteHandler<R & { path: P }, EnvVitNode>; hook?: RouteValidationHook; + /** + * Requires a signed-in member who is a moderator with this staff permission; + * anyone else gets `403`. Checked against the member's site session, so use + * it on public routes, not under `/admin/`. A grant scoped to selected + * resources does not pass: check those in the handler with + * `assertStaffPermission` and a `resource`. + */ + moderatorStaffPermission?: ModeratorStaffPermission; route: R; } @@ -105,8 +133,9 @@ export interface BuildRouteOptions< * `bindApiPlugin`, which supplies `pluginId`. * * The route is tagged with its plugin for OpenAPI and runs behind middleware - * that sets `c.get("plugin")`, then the staff-permission guard and the captcha - * check when requested, then the route's own `middleware`. + * that sets `c.get("plugin")`, then the admin and moderator staff-permission + * guards and the captcha check when requested, then the route's own + * `middleware`. * * @example * ```ts @@ -141,6 +170,7 @@ export const buildRoute = < hook, pluginId, adminStaffPermission, + moderatorStaffPermission, }: BuildRouteOptions<P, R> & { pluginId: Plugin; }): Route<Plugin, R & { path: P }> => { @@ -149,20 +179,17 @@ export const buildRoute = < const middleware: MiddlewareHandler[] = [pluginMiddleware(pluginId)]; if (adminStaffPermission) { - const { plugin, module, permission } = adminStaffPermission; - const guard: MiddlewareHandler = async (c, next) => { - await assertStaffPermission(c, { - type: "admin", - plugin: plugin ?? pluginId, - module, - permission, - }); - await next(); - }; + const guard = staffPermissionGuard("admin", adminStaffPermission, pluginId); staffPermissionGuards.add(guard); middleware.push(guard); } + if (moderatorStaffPermission) { + middleware.push( + staffPermissionGuard("moderator", moderatorStaffPermission, pluginId), + ); + } + if (route.withCaptcha) { middleware.push(captchaMiddleware()); } diff --git a/packages/vitnode/src/api/middlewares/body-limit.middleware.test.ts b/packages/vitnode/src/api/middlewares/body-limit.middleware.test.ts new file mode 100644 index 0000000000..e7040cee57 --- /dev/null +++ b/packages/vitnode/src/api/middlewares/body-limit.middleware.test.ts @@ -0,0 +1,108 @@ +import { Hono } from "hono"; +import { describe, expect, it } from "vitest"; + +import { bodyLimitMiddleware } from "./body-limit.middleware"; + +const MAX_SIZE = 8; + +/** + * A request object that quacks like a `Request` but is not one - like the + * objects TanStack Start's server hands the API. `new Request(it, init)` reads + * it as a URL string and throws, which is what turned hono's `bodyLimit` into a + * `500` for every request without a `Content-Length`. + */ +const toForeignRequest = (request: Request) => ({ + body: request.body, + headers: request.headers, + method: request.method, + signal: request.signal, + url: request.url, +}); + +const app = new Hono(); +app.use(async (c, next) => { + if (c.req.header("x-foreign-request")) { + c.req.raw = toForeignRequest(c.req.raw) as unknown as Request; + } + await next(); +}); +app.use( + bodyLimitMiddleware({ + maxSize: MAX_SIZE, + onError: c => c.json({ error: "Payload Too Large" }, 413), + }), +); +app.all("/", async c => c.text(`echo:${await c.req.text()}`)); + +const streamOf = (...chunks: string[]) => + new ReadableStream<Uint8Array>({ + start(controller) { + for (const chunk of chunks) { + controller.enqueue(new TextEncoder().encode(chunk)); + } + controller.close(); + }, + }); + +const streamedRequest = ( + chunks: string[], + headers: Record<string, string> = {}, +) => + new Request("http://localhost/", { + body: streamOf(...chunks), + // @ts-expect-error - undici requires `duplex` for a stream body; lib.dom lacks it. + duplex: "half", + headers, + method: "POST", + }); + +describe("bodyLimitMiddleware", () => { + it("passes a request without a body through", async () => { + const res = await app.request("/", { method: "DELETE" }); + + expect(res.status).toBe(200); + expect(await res.text()).toBe("echo:"); + }); + + it.each([ + { body: "12345678", status: 200 }, + { body: "123456789", status: 413 }, + ])( + "answers $status for a $body.length-byte body with a Content-Length", + async ({ body, status }) => { + const res = await app.request("/", { body, method: "POST" }); + + expect(res.status).toBe(status); + }, + ); + + it("hands a streamed body under the limit to the handler unchanged", async () => { + const res = await app.request(streamedRequest(["abc", "def"])); + + expect(res.status).toBe(200); + expect(await res.text()).toBe("echo:abcdef"); + }); + + it("answers onError for a streamed body over the limit", async () => { + const res = await app.request(streamedRequest(["abcde", "fghij"])); + + expect(res.status).toBe(413); + expect(await res.json()).toEqual({ error: "Payload Too Large" }); + }); + + it("hands an empty streamed body to the handler", async () => { + const res = await app.request(streamedRequest([])); + + expect(res.status).toBe(200); + expect(await res.text()).toBe("echo:"); + }); + + it("re-attaches a streamed body when the request is not a global Request", async () => { + const res = await app.request( + streamedRequest(["abc", "def"], { "x-foreign-request": "1" }), + ); + + expect(res.status).toBe(200); + expect(await res.text()).toBe("echo:abcdef"); + }); +}); diff --git a/packages/vitnode/src/api/middlewares/body-limit.middleware.ts b/packages/vitnode/src/api/middlewares/body-limit.middleware.ts new file mode 100644 index 0000000000..59f523f63a --- /dev/null +++ b/packages/vitnode/src/api/middlewares/body-limit.middleware.ts @@ -0,0 +1,52 @@ +import type { Context, Next } from "hono"; + +/** + * Hono's `bodyLimit`, re-attaching a buffered body with + * `new Request(url, init)` instead of `new Request(request, init)`. + * + * The second form throws on the request objects TanStack Start's server hands + * the API, so every request without a `Content-Length` - a bodiless `DELETE` + * from a browser, a chunked upload - was answered `500` before it reached a + * route. + */ +export const bodyLimitMiddleware = ({ + maxSize, + onError, +}: { + maxSize: number; + onError: (c: Context) => Promise<Response> | Response; +}) => { + return async (c: Context, next: Next) => { + const raw = c.req.raw; + if (!raw.body) return next(); + + const length = raw.headers.get("content-length"); + if (length !== null && !raw.headers.has("transfer-encoding")) { + return Number.parseInt(length, 10) > maxSize ? onError(c) : next(); + } + + const chunks: Uint8Array<ArrayBuffer>[] = []; + let size = 0; + const reader = raw.body.getReader(); + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + size += value.length; + if (size > maxSize) { + await reader.cancel(); + + return onError(c); + } + chunks.push(value); + } + + c.req.raw = new Request(raw.url, { + method: raw.method, + headers: raw.headers, + signal: raw.signal, + body: size > 0 ? new Blob(chunks) : undefined, + }); + + return next(); + }; +}; diff --git a/packages/vitnode/src/api/models/email.test.ts b/packages/vitnode/src/api/models/email.test.ts new file mode 100644 index 0000000000..1a70e0a0a4 --- /dev/null +++ b/packages/vitnode/src/api/models/email.test.ts @@ -0,0 +1,53 @@ +import type { Context } from "hono"; + +import { Hono } from "hono"; +import { createElement } from "react"; +import { describe, expect, it, vi } from "vitest"; + +import type { EmailModelSendArgs } from "./email"; + +import { EmailModel } from "./email"; + +describe("EmailModel.build", () => { + it("passes the core config's email tailwindConfig to the template", async () => { + const tailwindConfig = { + theme: { extend: { colors: { brand: "#0f766e" } } }, + }; + const logo = { text: "VitNode" }; + const content = vi.fn<EmailModelSendArgs["content"]>(() => + createElement("p", null, "Hello"), + ); + + const app = new Hono(); + app.use(async (c, next) => { + c.set("core", { + email: { logo, tailwindConfig }, + metadata: { shortTitle: "VitNode", title: "VitNode" }, + } as unknown as Context["var"]["core"]); + c.set("i18n", { + getMessages: async () => await Promise.resolve({}), + resolveSupportedLocale: () => "en", + } as unknown as Context["var"]["i18n"]); + await next(); + }); + app.get("/", async c => { + const email = await new EmailModel(c).build({ + content, + locale: "en", + subject: "Hi", + to: "user@example.com", + }); + + return c.json(email); + }); + + const res = await app.request("/"); + + expect(res.status).toBe(200); + expect(content).toHaveBeenCalledOnce(); + expect(content.mock.calls[0]?.[0].templateProps).toMatchObject({ + logo, + tailwindConfig, + }); + }); +}); diff --git a/packages/vitnode/src/api/models/email.ts b/packages/vitnode/src/api/models/email.ts index e20f4a2ce5..bbc19b3c71 100644 --- a/packages/vitnode/src/api/models/email.ts +++ b/packages/vitnode/src/api/models/email.ts @@ -153,6 +153,7 @@ export class EmailModel { url: CONFIG.web.href, }, logo: core.email?.logo, + tailwindConfig: core.email?.tailwindConfig, }, user, }); diff --git a/packages/vitnode/src/components/ui/calendar.tsx b/packages/vitnode/src/components/ui/calendar.tsx index a7089680e7..391036f580 100644 --- a/packages/vitnode/src/components/ui/calendar.tsx +++ b/packages/vitnode/src/components/ui/calendar.tsx @@ -13,7 +13,12 @@ import { getDefaultClassNames, type WeekNumber, } from "react-day-picker"; -import { useLocale, useTranslations } from "use-intl"; +import { + type DateTimeFormatOptions, + useFormatter, + useLocale, + useTranslations, +} from "use-intl"; import { type ButtonProps, buttonVariants } from "./button"; import { getFirstDayOfWeek, toDateString } from "./calendar-utils"; @@ -96,10 +101,13 @@ const Calendar = ({ }: CalendarProps) => { const t = useTranslations("core.global.calendar"); const intlLocale = useLocale(); + const format = useFormatter(); const defaultClassNames = getDefaultClassNames(); const followsIntlLocale = !locale; - const formatIntl = (date: Date, options: Intl.DateTimeFormatOptions) => - new Intl.DateTimeFormat(intlLocale, options).format(date); + const calendarTimeZone = + props.timeZone ?? new Intl.DateTimeFormat().resolvedOptions().timeZone; + const formatIntl = (date: Date, options: DateTimeFormatOptions) => + format.dateTime(date, { ...options, timeZone: calendarTimeZone }); const formatCaption = (month: Date) => formatIntl(month, { month: "long", year: "numeric" }); diff --git a/packages/vitnode/src/content/admin/spec.test.ts b/packages/vitnode/src/content/admin/spec.test.ts index fcbc97319f..5a936ea33e 100644 --- a/packages/vitnode/src/content/admin/spec.test.ts +++ b/packages/vitnode/src/content/admin/spec.test.ts @@ -442,6 +442,40 @@ describe("buildFormSchemaFromSpec", () => { expect(result.success).toBe(true); }); + it("opens a required rich text field on an empty paragraph, still refused as empty", () => { + // `{}` is what a create form started from before - the editor cannot open + // it and the "is empty" rule can never accept it, so the save never fired. + const richSchema = buildFormSchemaFromSpec( + buildContentFormSpec({ + definition: defineContentType({ + id: "test.rich", + tableName: "test_rich", + fields: { + title: field.text({ required: true }), + body: field.richText({ required: true }), + notes: field.richText({ nullable: true }), + }, + admin: { + titleField: "title", + form: { fields: ["title", "body", "notes"] }, + }, + }), + labelEnum, + labelField, + pluginId: "@vitnode/example", + }), + ); + const json = z.toJSONSchema(richSchema, { io: "input" }); + + expect(json.properties?.body).toMatchObject({ + default: { content: [{ type: "paragraph" }], type: "doc" }, + }); + expect(json.properties?.notes).not.toHaveProperty("default"); + expect(richSchema.safeParse({ title: "Hello" }).error?.issues).toEqual([ + expect.objectContaining({ message: "Body is empty.", path: ["body"] }), + ]); + }); + it("accepts null only for nullable fields", () => { expect( schema.safeParse({ category: REF, excerpt: null, title: "Hello" }) diff --git a/packages/vitnode/src/content/admin/spec.ts b/packages/vitnode/src/content/admin/spec.ts index 51b51e842c..fa218feda2 100644 --- a/packages/vitnode/src/content/admin/spec.ts +++ b/packages/vitnode/src/content/admin/spec.ts @@ -435,12 +435,16 @@ const richTextFormSchema = (spec: ContentFormFieldSpec): z.ZodType => { }); return spec.required && !spec.nullable - ? doc.refine( - value => isRichTextDocument(value) && !isRichTextEmpty(value), - { + ? doc + // Without it a create form starts from `{}`, which the editor cannot + // open and the refine below can never accept. + .default(() => ({ + content: [{ type: "paragraph" }], + type: "doc" as const, + })) + .refine(value => isRichTextDocument(value) && !isRichTextEmpty(value), { message: `${spec.label} is empty.`, - }, - ) + }) : doc; }; diff --git a/packages/vitnode/src/content/server/public-permissions.ts b/packages/vitnode/src/content/server/public-permissions.ts index ff1621cf55..8e17d4d80e 100644 --- a/packages/vitnode/src/content/server/public-permissions.ts +++ b/packages/vitnode/src/content/server/public-permissions.ts @@ -124,7 +124,9 @@ export const resolveContentViewerPermissions = async ( /** * `GET /{publicApi.path}/permissions?ids=1,2,3`: what the current visitor may * do with each record, as `can*` flags. Mounted for content types with - * `publicWrite.update` or `reports`. Personal, so never cached. + * `publicWrite.update` or `reports`. At most `CONTENT_PUBLIC_MAX_PAGE_SIZE` + * distinct ids per request - `useContentPermissions` batches for you. + * Personal, so never cached. */ export const contentPermissionsRoute = < TDefinition extends AnyContentTypeDefinition, diff --git a/packages/vitnode/src/content/server/thread.ts b/packages/vitnode/src/content/server/thread.ts index 652dc8e53c..515d5aa549 100644 --- a/packages/vitnode/src/content/server/thread.ts +++ b/packages/vitnode/src/content/server/thread.ts @@ -5,7 +5,7 @@ import type { } from "drizzle-orm/pg-core"; import type { Context } from "hono"; -import { and, asc, count, eq, gt, inArray, isNull, or } from "drizzle-orm"; +import { and, asc, count, eq, gt, inArray, isNull, or, sql } from "drizzle-orm"; import type { ContentId } from "../ids"; import type { AnyContentTypeDefinition } from "../types"; @@ -173,24 +173,35 @@ export interface ContentThreadPage<TRecord> { export const CONTENT_THREAD_MAX_NODES = 500; -const encodeCursor = (createdAt: Date, id: ContentId): string => - Buffer.from(JSON.stringify([createdAt.toISOString(), id])).toString( - "base64url", - ); +/** + * Where a page ends. `createdAt` is the column's own `::text`: Postgres keeps + * microseconds and a `Date` only milliseconds, so a cursor built from a `Date` + * sorts before its own row and the next page would repeat it. + */ +interface ThreadPosition { + createdAt: string; + id: ContentId; +} -const decodeCursor = ( - cursor: string | undefined, -): null | { createdAt: Date; id: ContentId } => { +/** A `timestamp::text` or an ISO string - anything else never reaches the cast. */ +const TIMESTAMP_TEXT = + /^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}:\d{2}(?:\.\d{1,6})?(?:Z|[+-]\d{2}(?::?\d{2})?)?$/; + +const encodeCursor = ({ createdAt, id }: ThreadPosition): string => + Buffer.from(JSON.stringify([createdAt, id])).toString("base64url"); + +const decodeCursor = (cursor: string | undefined): null | ThreadPosition => { if (!cursor) return null; try { const [createdAt, id] = JSON.parse( Buffer.from(cursor, "base64url").toString("utf8"), ) as [unknown, unknown]; - const date = typeof createdAt === "string" ? new Date(createdAt) : null; - if (!date || Number.isNaN(date.getTime())) return null; + if (typeof createdAt !== "string" || !TIMESTAMP_TEXT.test(createdAt)) { + return null; + } if (typeof id !== "number" && typeof id !== "string") return null; - return { createdAt: date, id }; + return { createdAt, id }; } catch { return null; } @@ -198,6 +209,7 @@ const decodeCursor = ( interface NodeRow extends Record<string, unknown> { createdAt: Date; + createdAtText: string; hiddenAt?: Date | null; id: ContentId; parent: ContentId | null; @@ -242,6 +254,7 @@ export const readContentThread = async < const nodeSelection = { createdAt: columns.createdAt, + createdAtText: sql<string>`${columns.createdAt}::text`, id: columns.id, parent: parentColumn, publishedAt: columns.publishedAt, @@ -250,16 +263,17 @@ export const readContentThread = async < }; const topLevel = and(eq(rootColumn, root), isNull(parentColumn)); - const after_ = (position: null | { createdAt: Date; id: ContentId }) => - position - ? or( - gt(columns.createdAt, position.createdAt), - and( - eq(columns.createdAt, position.createdAt), - gt(columns.id, position.id), - ), - ) - : undefined; + const after_ = (position: null | ThreadPosition) => { + if (!position) return undefined; + // Cast back to the column's own type, so Postgres parses it at the + // precision it stored. + const createdAt = sql`${position.createdAt}::${sql.raw(columns.createdAt.getSQLType())}`; + + return or( + gt(columns.createdAt, createdAt), + and(eq(columns.createdAt, createdAt), gt(columns.id, position.id)), + ); + }; /** * A top-level reply that is not public earns a place on the page only when @@ -301,7 +315,7 @@ export const readContentThread = async < const last = batch.at(-1); if (!last || batch.length <= first) break; - position = { createdAt: last.createdAt, id: last.id }; + position = { createdAt: last.createdAtText, id: last.id }; } return collected; @@ -397,7 +411,9 @@ export const readContentThread = async < return { items, pageInfo: { - endCursor: last ? encodeCursor(last.createdAt, last.id) : null, + endCursor: last + ? encodeCursor({ createdAt: last.createdAtText, id: last.id }) + : null, hasNextPage, totalCount: topTotal?.value ?? 0, }, diff --git a/packages/vitnode/src/framework/vite/optimize-deps.ts b/packages/vitnode/src/framework/vite/optimize-deps.ts index 46d3d9e0a6..a8e5aadf02 100644 --- a/packages/vitnode/src/framework/vite/optimize-deps.ts +++ b/packages/vitnode/src/framework/vite/optimize-deps.ts @@ -78,7 +78,6 @@ export const VITNODE_CLIENT_DEPENDENCIES = [ "react-colorful", "react-day-picker", "react-resizable-panels", - "react-scan", "sanitize-html", "sonner", "use-debounce", diff --git a/packages/vitnode/src/tanstack/admin/staff/edit-route.test.ts b/packages/vitnode/src/tanstack/admin/staff/edit-route.test.ts new file mode 100644 index 0000000000..a5590add62 --- /dev/null +++ b/packages/vitnode/src/tanstack/admin/staff/edit-route.test.ts @@ -0,0 +1,43 @@ +import type { QueryClient } from "@tanstack/react-query"; + +import { describe, expect, it } from "vitest"; + +import { MAX_NAMESPACES } from "../../i18n/query"; +import { loadStaffPermissionLabels } from "./edit-route"; + +describe("loadStaffPermissionLabels", () => { + it("keeps one plugin's labels when two chunks each carry part of it", async () => { + const scopeKey = "newsroom.moderation.category.label"; + const filler = Array.from( + { length: MAX_NAMESPACES - 1 }, + (_, index) => `filler${index}`, + ); + const queryClient = { + query: async ({ queryKey }: { queryKey: readonly unknown[] }) => { + await Promise.resolve(); + + return { + locale: "en", + messages: queryKey.includes(scopeKey) + ? { + newsroom: { moderation: { category: { label: "Categories" } } }, + } + : { newsroom: { title: "Newsroom" } }, + }; + }, + } as unknown as QueryClient; + + const labels = await loadStaffPermissionLabels({ + keys: ["newsroom", ...filler, scopeKey], + locale: "en", + queryClient, + }); + + expect(labels).toEqual({ + newsroom: { + moderation: { category: { label: "Categories" } }, + title: "Newsroom", + }, + }); + }); +}); diff --git a/packages/vitnode/src/tanstack/admin/staff/edit-route.tsx b/packages/vitnode/src/tanstack/admin/staff/edit-route.tsx index 2b7ffee5fa..0da104e3b6 100644 --- a/packages/vitnode/src/tanstack/admin/staff/edit-route.tsx +++ b/packages/vitnode/src/tanstack/admin/staff/edit-route.tsx @@ -16,6 +16,7 @@ import type { AdminStaffRow, } from "@/views/admin/views/core/staff/staff-query"; +import { deepMerge } from "@/lib/i18n/deep-merge"; import { adminStaffPermissions } from "@/views/admin/views/core/shared/admin-permissions"; import { buildStaffPermissionGroups, @@ -51,14 +52,15 @@ export const loadStaffPermissionLabels = async ({ locale: string; queryClient: QueryClient; }): Promise<Record<string, unknown>> => { - const merged: Record<string, unknown> = {}; + let merged: Record<string, unknown> = {}; for (const namespaces of chunkStaffLabelKeys(keys, MAX_NAMESPACES)) { const intl = await queryClient.query({ ...intlQueryOptions({ locale, namespaces }), staleTime: "static", }); - Object.assign(merged, intl.messages); + // Deep: two chunks can each carry part of one plugin's root object. + merged = deepMerge(merged, intl.messages); } return merged; diff --git a/packages/vitnode/src/tanstack/content/permissions.test.ts b/packages/vitnode/src/tanstack/content/permissions.test.ts new file mode 100644 index 0000000000..87a68c16bb --- /dev/null +++ b/packages/vitnode/src/tanstack/content/permissions.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from "vitest"; + +import { contentPermissionBatches } from "./permissions"; + +const range = (from: number, to: number) => + Array.from({ length: to - from + 1 }, (_, index) => from + index); + +describe("contentPermissionBatches", () => { + it.each<[string, (number | string)[], number, (number | string)[][]]>([ + ["nothing for no ids", [], 50, []], + ["repeated numbers once", [3, 1, 3, 2, 1], 50, [[1, 2, 3]]], + ["repeated strings once", ["b", "a", "b"], 50, [["a", "b"]]], + [ + "caller order across batches, each batch sorted as strings", + [9, 10, 3, 1, 2], + 2, + [[10, 9], [1, 3], [2]], + ], + ])("returns %s", (_label, ids, size, expected) => { + expect(contentPermissionBatches(ids, size)).toStrictEqual(expected); + }); + + it("cuts 120 ids into batches of 50, 50 and 20", () => { + const batches = contentPermissionBatches(range(1, 120)); + + expect(batches.map(batch => batch.length)).toStrictEqual([50, 50, 20]); + expect(batches.flat().toSorted((a, b) => a - b)).toStrictEqual( + range(1, 120), + ); + }); + + it("leaves earlier full batches untouched when ids are appended", () => { + const before = contentPermissionBatches(range(1, 100)); + const after = contentPermissionBatches([ + ...range(1, 100), + ...range(101, 130), + ]); + + expect(after.slice(0, 2)).toStrictEqual(before); + expect(after[2]).toHaveLength(30); + }); +}); diff --git a/packages/vitnode/src/tanstack/content/permissions.test.tsx b/packages/vitnode/src/tanstack/content/permissions.test.tsx new file mode 100644 index 0000000000..efc6f6adb1 --- /dev/null +++ b/packages/vitnode/src/tanstack/content/permissions.test.tsx @@ -0,0 +1,140 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { act, renderHook, waitFor } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; + +import type { ContentPermissionsResponse } from "./permissions"; + +import { sessionQueryOptions } from "../auth/session-query"; +import { useContentPermissions } from "./permissions"; + +interface Call { + ids: number[]; + reject: () => void; + resolve: () => void; +} + +const range = (from: number, to: number) => + Array.from({ length: to - from + 1 }, (_, index) => from + index); + +/** A `fetch` whose calls stay open until the test settles them. */ +const deferredFetch = () => { + const calls: Call[] = []; + let inFlight = 0; + let maxInFlight = 0; + + const fetch = async (ids: number[]) => + await new Promise<ContentPermissionsResponse<number>>((resolve, reject) => { + inFlight++; + maxInFlight = Math.max(maxInFlight, inFlight); + calls.push({ + ids, + reject: () => { + inFlight--; + reject(new Error("Loading permissions failed.")); + }, + resolve: () => { + inFlight--; + resolve({ + items: ids.map(id => ({ + canEdit: id % 2 === 0, + canReport: true, + id, + })), + }); + }, + }); + }); + + return { calls, fetch, maxInFlight: () => maxInFlight }; +}; + +const mount = ( + ids: number[], + fetch: (ids: number[]) => Promise<ContentPermissionsResponse<number>>, +) => { + const queryClient = new QueryClient(); + queryClient.setQueryData(sessionQueryOptions().queryKey, { user: null }); + + return renderHook( + () => useContentPermissions({ fetch, ids, queryKey: ["posts"] }), + { + wrapper: ({ children }) => ( + <QueryClientProvider client={queryClient}> + {children} + </QueryClientProvider> + ), + }, + ); +}; + +describe("useContentPermissions", () => { + it("asks for every id once, 50 per call and 3 calls at a time", async () => { + const ids = range(1, 230); + const { calls, fetch, maxInFlight } = deferredFetch(); + const { result } = mount(ids, fetch); + + await waitFor(() => { + expect(calls).toHaveLength(3); + }); + expect(result.current.isPending).toBe(true); + expect(result.current.of(2)).toStrictEqual({ + canEdit: false, + canReport: false, + }); + + for (let settled = 0; settled < 5; settled++) { + await waitFor(() => { + expect(calls.length).toBeGreaterThan(settled); + }); + act(() => { + calls[settled].resolve(); + }); + } + + await waitFor(() => { + expect(result.current.isPending).toBe(false); + }); + expect(calls).toHaveLength(5); + expect(maxInFlight()).toBe(3); + expect(Math.max(...calls.map(call => call.ids.length))).toBe(50); + expect( + calls.flatMap(call => call.ids).toSorted((a, b) => a - b), + ).toStrictEqual(ids); + expect(result.current.isError).toBe(false); + expect([2, 3, 228, 229].map(id => result.current.of(id))).toStrictEqual([ + { canEdit: true, canReport: true }, + { canEdit: false, canReport: true }, + { canEdit: true, canReport: true }, + { canEdit: false, canReport: true }, + ]); + }); + + it("reads a failed batch as all false and keeps the others", async () => { + const { calls, fetch } = deferredFetch(); + const { result } = mount(range(1, 120), fetch); + + await waitFor(() => { + expect(calls).toHaveLength(3); + }); + act(() => { + calls[0].resolve(); + calls[1].reject(); + calls[2].resolve(); + }); + + await waitFor(() => { + expect(result.current.isPending).toBe(false); + }); + expect(result.current.isError).toBe(true); + const [failed] = calls[1].ids; + const [answered] = calls[2].ids.filter(id => id % 2 === 0); + expect(result.current.of(failed)).toStrictEqual({ + canEdit: false, + canReport: false, + }); + expect(result.current.of(answered)).toStrictEqual({ + canEdit: true, + canReport: true, + }); + }); +}); diff --git a/packages/vitnode/src/tanstack/content/permissions.ts b/packages/vitnode/src/tanstack/content/permissions.ts index f5cdbc5be4..eae44738b0 100644 --- a/packages/vitnode/src/tanstack/content/permissions.ts +++ b/packages/vitnode/src/tanstack/content/permissions.ts @@ -1,8 +1,10 @@ -import type { QueryKey } from "@tanstack/react-query"; +import type { QueryKey, UseQueryResult } from "@tanstack/react-query"; -import { useQuery } from "@tanstack/react-query"; +import { useQueries } from "@tanstack/react-query"; import React from "react"; +import { CONTENT_PUBLIC_MAX_PAGE_SIZE } from "@/content/const"; + import { useSessionQuery } from "../auth/session-query"; /** @@ -26,17 +28,93 @@ const NO_PERMISSIONS: ContentPermissions = { canReport: false, }; +/** Batches in flight at once for one hook. */ +const MAX_CONCURRENT_BATCHES = 3; + +/** + * `ids` without duplicates, in the order given, cut into batches the + * permissions route accepts. Each batch is sorted, so the same records keep + * the same query key, and appending ids leaves the earlier batches - and their + * cached answers - alone. + */ +export const contentPermissionBatches = <TId extends number | string>( + ids: readonly TId[], + size: number = CONTENT_PUBLIC_MAX_PAGE_SIZE, +): TId[][] => { + const seen = new Set<string>(); + const unique = ids.filter(id => { + const key = String(id); + if (seen.has(key)) return false; + seen.add(key); + + return true; + }); + const batches: TId[][] = []; + for (let start = 0; start < unique.length; start += size) { + batches.push( + unique + .slice(start, start + size) + .sort((a, b) => String(a).localeCompare(String(b))), + ); + } + + return batches; +}; + +/** Runs at most `max` tasks at once; the rest wait their turn in order. */ +const createLimiter = (max: number) => { + let active = 0; + const waiting: (() => void)[] = []; + + return async <T>(task: () => Promise<T>): Promise<T> => { + if (active < max) { + active++; + } else { + // The finishing task hands its slot over, so `active` never exceeds `max`. + await new Promise<void>(resolve => { + waiting.push(resolve); + }); + } + + try { + return await task(); + } finally { + const next = waiting.shift(); + if (next) next(); + else active--; + } + }; +}; + +const combineBatches = <TId extends number | string>( + results: UseQueryResult<ContentPermissionsResponse<TId>>[], +) => ({ + byId: new Map( + results.flatMap(result => + (result.data?.items ?? []).map(item => [String(item.id), item] as const), + ), + ), + isError: results.some(result => result.isError), + isPending: results.some(result => result.isPending), +}); + /** * Asks the Content Engine what the signed-in visitor may do with some records - * - one detail page or a whole list in one request - so a page never compares - * author ids or guesses moderator rights. Every answer starts with `can`, and - * a record not answered yet, or left out, reads as all `false`. + * - one detail page or a whole thread - so a page never compares author ids or + * guesses moderator rights. Every answer starts with `can`, and a record not + * answered yet, left out by the server or in a failed batch reads as all + * `false`. * - * Browser-only: it waits for the session, and it refetches when somebody signs - * in or out, so a cached page still shows each visitor their own buttons. Write - * the `fetcher` call inline in `fetch`, so the route stays typed. The - * `queryKey` prefix is the list's, so invalidating the list after a write - * refreshes the permissions too. + * Any number of `ids` works: duplicates are dropped and the rest go out in + * batches of `CONTENT_PUBLIC_MAX_PAGE_SIZE` (50), the route's limit, at most + * three at a time. Each batch is its own query, so ids appended by "Load more" + * only fetch their own batches. + * + * Browser-only: it waits for the session, and the user id is part of every + * query key, so a cached page still shows each visitor their own buttons and + * signing in or out asks again. Write the `fetcher` call inline in `fetch`, so + * the route stays typed. The `queryKey` prefix is the list's, so invalidating + * the list after a write refreshes the permissions too. * * @example * ```tsx @@ -68,47 +146,48 @@ export const useContentPermissions = <TId extends number | string>({ ids, queryKey, }: { - /** Calls `GET /{publicApi.path}/permissions` with `ids` joined by commas. */ + /** + * Calls `GET /{publicApi.path}/permissions` with `ids` joined by commas. + * Gets at most 50 ids per call. Throw on anything but `200`. + */ fetch: (ids: TId[]) => Promise<ContentPermissionsResponse<TId>>; - /** The records on screen, at most 50. */ + /** The records on screen, any number and in any order. */ ids: readonly TId[]; /** The prefix of the content type's queries, e.g. `[manifest.id, "discussions"]`. */ queryKey: QueryKey; }) => { const session = useSessionQuery(); const userId = session.data?.user?.id ?? null; - const unique = [...new Set(ids)].sort((a, b) => - String(a).localeCompare(String(b)), - ); + const [limit] = React.useState(() => createLimiter(MAX_CONCURRENT_BATCHES)); - const query = useQuery({ - enabled: !session.isPending && unique.length > 0, - queryFn: async () => await fetch(unique), - queryKey: [...queryKey, "permissions", { ids: unique, userId }], - retry: false, + const batches = useQueries({ + combine: combineBatches<TId>, + queries: contentPermissionBatches(ids).map(batch => ({ + enabled: !session.isPending, + queryFn: async () => await limit(async () => await fetch(batch)), + queryKey: [...queryKey, "permissions", { ids: batch, userId }], + retry: false, + })), }); - const byId = React.useMemo( - () => new Map(query.data?.items.map(item => [String(item.id), item])), - [query.data], - ); - - // Stable until the answer changes, so memoized rows that receive it skip + // Stable until an answer changes, so memoized rows that receive it skip // re-rendering. const of = React.useCallback( (id: TId): ContentPermissions => { - const item = byId.get(String(id)); + const item = batches.byId.get(String(id)); return item ? { canEdit: item.canEdit, canReport: item.canReport } : NO_PERMISSIONS; }, - [byId], + [batches.byId], ); return { - /** `true` until the first answer arrives. */ - isPending: query.isPending, + /** `true` when a batch failed; its records read as all `false`. */ + isError: batches.isError, + /** `true` while the session or any batch waits for its first answer. */ + isPending: session.isPending || batches.isPending, /** The permissions of one record, all `false` until known. */ of, }; diff --git a/packages/vitnode/src/tanstack/content/public-write.tsx b/packages/vitnode/src/tanstack/content/public-write.tsx index 5f9323a6eb..da650bdf22 100644 --- a/packages/vitnode/src/tanstack/content/public-write.tsx +++ b/packages/vitnode/src/tanstack/content/public-write.tsx @@ -59,22 +59,36 @@ const versionOf = (record: unknown): number | undefined => { * * - `400`: each issue on a field in `fields` shows under that field; anything * else, and every other refusal (`401`, `403`, `404`, `429`), is one toast. - * Messages come from core and follow the reader's language. - * - `409 CONTENT_UNIQUE_CONFLICT` shows under `uniqueField`. + * Messages come from `core.global.content_write` and follow the reader's + * language. + * - `409 CONTENT_UNIQUE_CONFLICT` shows under `uniqueField`, or as a toast. * - `409 CONTENT_VERSION_CONFLICT` shows `conflict`: an alert that keeps the - * form as it is and offers "Keep my version" and "Load latest version". + * form as it is and offers "Keep my version" (with `latestVersion`) and + * "Load latest version", which reloads the page and drops unsaved input. * * For an edit, pass `version` and `latestVersion`, and send `expectedVersion` * from the `request` argument: the hook keeps it current after every save and - * after "Keep my version". Browser-only. + * after "Keep my version". `version` is read once, so remount the form (with a + * `key`) for another record. + * + * On success it only returns the record. Success messages, a pending + * `approval`, `form.reset`, cache invalidation and navigation stay with the + * caller. Browser-only. * * @example * ```tsx + * import type { AutoFormOnSubmit } from "@vitnode/core/components/form/auto-form"; + * * import { useContentPublicWrite } from "@vitnode/core/tanstack/content"; + * import { fetcher } from "@vitnode/core/tanstack/fetcher"; * * const write = useContentPublicWrite({ * fields: ["title", "body"], - * latestVersion: async () => (await loadEditable(id))?.version ?? null, + * latestVersion: async () => { + * const res = await fetchEditable(record.id); // GET /{id}/edit + * + * return res.status === 200 ? (await res.json()).version : null; + * }, * uniqueField: "title", * version: record.version, * }); @@ -86,10 +100,13 @@ const versionOf = (record: unknown): number | undefined => { * method: "patch", * module: "content/discussions", * path: "/{id}", - * args: { params: { id }, body: { values, expectedVersion } }, + * args: { params: { id: record.id }, body: { values, expectedVersion } }, * }), * ); - * if (updated) toast.success(t("saved")); + * if (!updated) return; + * + * form.reset(values); + * toast.success(t(updated.approval?.status === "pending" ? "pending" : "saved")); * }; * * <>{write.conflict}<AutoForm onSubmit={onSubmit} ... /></>; @@ -112,7 +129,7 @@ export const useContentPublicWrite = < latestVersion?: () => Promise<null | number>; /** Where "already taken" shows on a unique conflict, e.g. the slug's source field. */ uniqueField?: string; - /** The version the form opened with. Leave it out for a create. */ + /** The version the form opened with, read on the first render. Leave it out for a create. */ version?: TVersion; }) => { const t = useTranslations("core.global.content_write"); @@ -246,7 +263,10 @@ export const useContentPublicWrite = < return { /** The version-conflict alert, or `null`. Render it above the form. */ conflict, - /** Sends a write through `request` and returns the record on success, `null` otherwise. */ + /** + * Sends a write through `request` - given the current `expectedVersion` - + * and returns the record on a `2xx`, or `null` after showing why not. + */ submit, }; }; diff --git a/packages/vitnode/src/tests/memory-db.ts b/packages/vitnode/src/tests/memory-db.ts index 88a93141ce..dd6efc43ac 100644 --- a/packages/vitnode/src/tests/memory-db.ts +++ b/packages/vitnode/src/tests/memory-db.ts @@ -454,6 +454,9 @@ const evaluate = ( if (["bigint", "int", "integer", "numeric"].includes(node.to)) { return Number(value); } + if (node.to === "timestamp" || node.to === "timestamptz") { + return value instanceof Date ? value : new Date(textOf(value)); + } if (node.to === "jsonb") { return typeof value === "string" ? JSON.parse(value) : value; } diff --git a/packages/vitnode/src/tests/setup.ts b/packages/vitnode/src/tests/setup.ts index a1201e0c5a..ed8bcee22a 100644 --- a/packages/vitnode/src/tests/setup.ts +++ b/packages/vitnode/src/tests/setup.ts @@ -18,6 +18,8 @@ vi.mock("use-intl", async importOriginal => { return { ...actual, + useFormatter: () => + actual.createFormatter({ locale: "en", timeZone: "UTC" }), useLocale: () => "en", useTranslations, }; diff --git a/packages/vitnode/src/views/layouts/providers.tsx b/packages/vitnode/src/views/layouts/providers.tsx index d986e97e19..1dc901f0fc 100644 --- a/packages/vitnode/src/views/layouts/providers.tsx +++ b/packages/vitnode/src/views/layouts/providers.tsx @@ -11,13 +11,12 @@ import { LogoVitNodeBrand } from "@/components/logo-vitnode"; import { ThemeProvider } from "@/components/theme-provider"; import { Toaster } from "@/components/ui/sonner"; import { TooltipProvider } from "@/components/ui/tooltip"; -import { CONFIG } from "@/lib/config"; import { RateLimitListener } from "./rate-limit-listener"; export interface VitNodeProvidersConfig extends Pick< VitNodeConfig, - "debug" | "editor" | "logo" | "theme" + "editor" | "logo" | "theme" > { locales: LocaleConfig[]; } @@ -25,7 +24,7 @@ export interface VitNodeProvidersConfig extends Pick< export const VitNodeProviders = ({ children, toaster, - config: { debug, editor, locales, logo: Logo = LogoVitNodeBrand, theme }, + config: { editor, locales, logo: Logo = LogoVitNodeBrand, theme }, }: { children: React.ReactNode; config: VitNodeProvidersConfig; @@ -33,13 +32,6 @@ export const VitNodeProviders = ({ }) => { const logo = React.useMemo(() => <Logo />, [Logo]); - React.useEffect(() => { - // oxlint-disable-next-line react-you-might-not-need-an-effect/no-event-handler - if (!(debug && CONFIG.node_development)) return; - - void import("react-scan").then(({ scan }) => scan({ enabled: true })); - }, [debug]); - return ( <ThemeProvider attribute="class" diff --git a/packages/vitnode/src/vitnode.config.ts b/packages/vitnode/src/vitnode.config.ts index b5b9095936..c9d750b893 100644 --- a/packages/vitnode/src/vitnode.config.ts +++ b/packages/vitnode/src/vitnode.config.ts @@ -44,8 +44,6 @@ export type { LocaleConfig }; export interface VitNodeConfig< AppLocales extends LocaleConfig[] = LocaleConfig[], > { - /** Starts `react-scan` render highlighting in development. Ignored in production. */ - debug?: boolean; /** Editor settings shared by every `Editor` in the app. */ editor?: VitNodeEditorConfig; /** Languages and locale routing. */ diff --git a/plugins/example/src/admin/content.tsx b/plugins/example/src/admin/content.tsx deleted file mode 100644 index 54c3d6d1a5..0000000000 --- a/plugins/example/src/admin/content.tsx +++ /dev/null @@ -1,26 +0,0 @@ -import type { ContentFrontendPluginSource } from "@vitnode/core/lib/plugin"; - -import { manifest } from "@/manifest.gen"; - -import { - exampleArticleNav, - exampleBoardsNav, - exampleCategoryNav, - exampleDiscussionNav, - exampleEventNav, - examplePageNav, - exampleTagNav, -} from "./nav"; - -export const adminContent = { - pluginId: manifest.id, - containers: [exampleBoardsNav], - contentTypes: [ - exampleArticleNav, - exampleCategoryNav, - examplePageNav, - exampleTagNav, - exampleEventNav, - exampleDiscussionNav, - ], -} satisfies ContentFrontendPluginSource; diff --git a/plugins/example/src/admin/nav.tsx b/plugins/example/src/admin/nav.tsx deleted file mode 100644 index a9edbc91e9..0000000000 --- a/plugins/example/src/admin/nav.tsx +++ /dev/null @@ -1,80 +0,0 @@ -import type { AdminNavPluginSource } from "@vitnode/core/lib/plugin"; - -import { - CalendarDaysIcon, - FlaskConicalIcon, - FolderIcon, - FolderTreeIcon, - LayoutTemplateIcon, - MessagesSquareIcon, - NotebookPenIcon, - TagIcon, -} from "lucide-react"; - -import { articleContentType } from "@/content/article"; -import { boardContainers } from "@/content/boards"; -import { categoryContentType } from "@/content/category"; -import { discussionContentType } from "@/content/discussion"; -import { eventContentType } from "@/content/event"; -import { pageContentType } from "@/content/page"; -import { tagContentType } from "@/content/tag"; -import { manifest } from "@/manifest.gen"; - -/** The article content type, as the sidebar reads it. */ -export const exampleArticleNav = { - definition: articleContentType, - icon: <NotebookPenIcon />, -}; - -/** The category content type, likewise. */ -export const exampleCategoryNav = { - definition: categoryContentType, - icon: <FolderIcon />, -}; - -export const examplePageNav = { - definition: pageContentType, - icon: <LayoutTemplateIcon />, -}; - -export const exampleTagNav = { - definition: tagContentType, - icon: <TagIcon />, -}; - -export const exampleEventNav = { - definition: eventContentType, - icon: <CalendarDaysIcon />, -}; - -export const exampleDiscussionNav = { - definition: discussionContentType, - icon: <MessagesSquareIcon />, -}; - -export const exampleBoardsNav = { - definition: boardContainers, - icon: <FolderTreeIcon />, -}; - -export const adminNav = { - pluginId: manifest.id, - containers: [exampleBoardsNav], - contentTypes: [ - exampleArticleNav, - exampleCategoryNav, - examplePageNav, - exampleTagNav, - exampleEventNav, - exampleDiscussionNav, - ], - admin: { - nav: [ - { - href: "/admin/example", - icon: <FlaskConicalIcon />, - id: "overview", - }, - ], - }, -} satisfies AdminNavPluginSource; diff --git a/plugins/example/src/api/modules/admin/admin.module.ts b/plugins/example/src/api/modules/admin/admin.module.ts deleted file mode 100644 index 60321a240b..0000000000 --- a/plugins/example/src/api/modules/admin/admin.module.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { buildContentAdminModule, buildModule } from "@/api/builders"; -import { boardContainers } from "@/content/boards"; -import { advancedArticleContent } from "@/database/advanced-articles"; -import { articleContent } from "@/database/articles"; -import { categoryContent } from "@/database/categories"; -import { discussionContent } from "@/database/discussions"; -import { eventContent } from "@/database/events"; -import { localizedArticleContent } from "@/database/localized-articles"; -import { pageContent } from "@/database/pages"; -import { tagContent } from "@/database/tags"; - -export const adminModule = buildModule({ - name: "admin", - routes: [], - modules: [ - buildContentAdminModule({ - containers: [boardContainers], - // The localized article is registered on the API side only, so its - // generated CRUD *and* translation routes exist and its staff permissions - // are derived - but it gets no AdminCP screen yet, because a form that - // could not edit `title` in any language would be a worse thing to ship - // than no form at all. Registering it in `config.tsx` alongside the - // others is what would give it one. - contentTypes: [ - advancedArticleContent, - articleContent, - categoryContent, - discussionContent, - eventContent, - localizedArticleContent, - pageContent, - tagContent, - ], - }), - ], -}); diff --git a/plugins/example/src/config.api.ts b/plugins/example/src/config.api.ts deleted file mode 100644 index 8bf517ed84..0000000000 --- a/plugins/example/src/config.api.ts +++ /dev/null @@ -1,56 +0,0 @@ -import type { ApiPluginContract } from "@vitnode/core/api/lib/plugin"; - -import { buildApiPlugin } from "@vitnode/core/api/lib/plugin"; - -import { articleExcerptAiAction } from "@/api/ai/actions"; -import { buildContentPublicModule } from "@/api/builders"; -import { exampleCategoryScope } from "@/api/lib/moderation"; -import { adminModule } from "@/api/modules/admin/admin.module"; -import { boardContainers } from "@/content/boards"; -import { settingsPage } from "@/content/settings-page"; -import { advancedArticleContent } from "@/database/advanced-articles"; -import { articleContent } from "@/database/articles"; -import { categoryContent } from "@/database/categories"; -import { discussionContent } from "@/database/discussions"; -import { eventContent } from "@/database/events"; -import { localizedArticleContent } from "@/database/localized-articles"; -import { pageContent } from "@/database/pages"; -import { tagContent } from "@/database/tags"; -import { manifest } from "@/manifest.gen"; - -import { widgets } from "./widgets"; -import "@/api/lib/events"; - -export const exampleApiPlugin = () => - buildApiPlugin({ - manifest, - aiActions: [articleExcerptAiAction], - blocks: widgets, - editablePages: [settingsPage], - moderation: { scopes: [exampleCategoryScope] }, - permissionStaff: { - moderator: { - widgets: ["can_edit"], - }, - }, - modules: [ - adminModule, - buildContentPublicModule({ - containers: [boardContainers], - contentTypes: [ - advancedArticleContent, - articleContent, - categoryContent, - discussionContent, - eventContent, - localizedArticleContent, - pageContent, - tagContent, - ], - }), - ], - }); - -export type VitNodeApiPlugin = ApiPluginContract< - ReturnType<typeof exampleApiPlugin> ->; diff --git a/plugins/example/src/const.ts b/plugins/example/src/const.ts deleted file mode 100644 index 5a9e7d7042..0000000000 --- a/plugins/example/src/const.ts +++ /dev/null @@ -1,45 +0,0 @@ -export const EXAMPLE_MIGRATIONS = [ - "add_example_content", - "add_publication_to_example_articles", - "add_example_article_slug", - // Core, not `example_*`, but the editorial suites write revisions for an - // article - so the table has to exist before the column that needs it. - "add_content_revisions", - "add_example_article_editorial", - // Core again, for the same reason: the scheduling suites book a publication - // for an article, and the row has to have somewhere to go. - "add_content_schedules", - "add_content_schedule_effects_error", - // The Stage 5A localized fixture: a base table with only its shared field, and - // a translation table holding the localized ones. - "add_example_localized_articles", - // Stage 5B. Additive only: `languageId` arrives nullable, so every existing - // revision is a shared one, and the translation lifecycle columns arrive with - // `DEFAULT 'draft'`, so every translation written while Stage 5A was current - // becomes a draft rather than being silently published. - "add_translation_editorial", - // Stage 6: the advanced-modeling fixture. One base table with a flattened - // shared group, one translation table with a flattened localized group, two - // junction tables (one of them a self-relation) and one repeatable child - // table - each with the constraints that make its ordering and its integrity - // facts about the database rather than about the service. - "add_example_advanced_articles", - // Stage 8. Core again: `core_content_slug_history` is what makes an old public - // URL keep working, and the delivery suites write reservations for both example - // content types - so the table has to exist before either of them publishes. - "add_content_slug_history", - // The shared boolean `delivery.seo.noIndexField` reads, which drives the sitemap - // exclusion and the `robots` metadata together. Additive and defaulted, so every - // existing row becomes indexable rather than silently disappearing from a sitemap. - "add_example_article_no_index", - // The same field on `example.article`, but **nullable** - the shape an upgrade - // actually produces. `NULL` has to mean "indexable" identically in the metadata - // and in the sitemap predicate, and only a nullable column can prove it. - "add_example_article_no_index_flag", - // File fields: `example_articles.animation`, an integer foreign key into - // `core_files` with `ON DELETE RESTRICT`. Core arrives with it - - // `core_content_file_refs` is what pins the files a retained revision names - - // so the shared table has to exist before anything can reference it. - "add_content_file_fields", - "add_example_block_pages", -]; diff --git a/plugins/example/src/content/category.ts b/plugins/example/src/content/category.ts deleted file mode 100644 index 84c07500d3..0000000000 --- a/plugins/example/src/content/category.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { defineContentType, field } from "@vitnode/core/content"; - -export const categoryContentType = defineContentType({ - id: "example.category", - tableName: "example_categories", - - fields: { - name: field.text({ required: true, minLength: 1, maxLength: 100 }), - }, - - admin: { - path: "example/categories", - list: { - columns: ["name", "createdAt"], - orderableFields: ["name"], - }, - }, -}); diff --git a/plugins/example/src/database/advanced-articles.ts b/plugins/example/src/database/advanced-articles.ts deleted file mode 100644 index 85346196ea..0000000000 --- a/plugins/example/src/database/advanced-articles.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { createContentModel } from "@vitnode/core/content/server"; - -import { advancedArticleContentType } from "@/content/advanced-article"; - -import { example_categories } from "./categories"; -import { example_tags } from "./tags"; - -export const advancedArticleContent = createContentModel( - advancedArticleContentType, - { - references: { - // A to-many relation needs a reference thunk exactly as a to-one does: - // its foreign key is `related_item_id` on the generated junction table - // rather than a column on the row, but the target is just as much a fact - // this module has to supply. - categories: () => example_categories.id, - tags: () => example_tags.id, - // `relatedArticles` is deliberately absent. It is a `self: true` - // relation, and the engine resolves it from the table it is building: - // writing `() => advancedArticleContent.table.id` here would reference - // the model inside its own initializer, and TypeScript resolves that by - // widening the whole model to `any` - silently taking every typed - // service, schema and column map with it. - }, - }, -); - -// Six exports, not one. Drizzle Kit discovers each table from its export when -// it globs the built `dist/src/database/*.js`, so a junction or child table -// without one would simply be missing from the migration. -export const example_advanced_articles = advancedArticleContent.table; -export const example_advanced_articles_translations = - advancedArticleContent.translationTable; -export const example_advanced_articles_categories = - advancedArticleContent.advancedTables.junctions.categories; -export const example_advanced_articles_tags = - advancedArticleContent.advancedTables.junctions.tags; -export const example_advanced_articles_related_articles = - advancedArticleContent.advancedTables.junctions.relatedArticles; -export const example_advanced_articles_faq = - advancedArticleContent.advancedTables.repeatables.faq; diff --git a/plugins/example/src/database/categories.ts b/plugins/example/src/database/categories.ts deleted file mode 100644 index e1884d7f29..0000000000 --- a/plugins/example/src/database/categories.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { createContentModel } from "@vitnode/core/content/server"; - -import { categoryContentType } from "@/content/category"; - -export const categoryContent = createContentModel(categoryContentType); - -// Drizzle Kit discovers the table from this export when it globs the built -// `dist/src/database/*.js`, so migrations stay generated and committed. -export const example_categories = categoryContent.table; diff --git a/plugins/example/src/database/discussions.ts b/plugins/example/src/database/discussions.ts deleted file mode 100644 index 83caf0f418..0000000000 --- a/plugins/example/src/database/discussions.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { createContentModel } from "@vitnode/core/content/server"; - -import { discussionContentType } from "@/content/discussion"; - -export const discussionContent = createContentModel(discussionContentType); - -export const example_discussions = discussionContent.table; diff --git a/plugins/example/src/database/events.ts b/plugins/example/src/database/events.ts deleted file mode 100644 index ab8ccf4bdb..0000000000 --- a/plugins/example/src/database/events.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { createContentModel } from "@vitnode/core/content/server"; - -import { eventContentType } from "@/content/event"; - -import { example_categories } from "./categories"; -import { example_tags } from "./tags"; - -export const eventContent = createContentModel(eventContentType, { - references: { - category: () => example_categories.id, - primaryTag: () => example_tags.id, - tags: () => example_tags.id, - }, -}); - -export const example_events = eventContent.table; -export const example_events_translations = eventContent.translationTable; -export const example_events_tags = eventContent.advancedTables.junctions.tags; -export const example_events_related_events = - eventContent.advancedTables.junctions.relatedEvents; -export const example_events_gallery = - eventContent.advancedTables.junctions.gallery; -export const example_events_sessions = - eventContent.advancedTables.repeatables.sessions; diff --git a/plugins/example/src/database/localized-articles.ts b/plugins/example/src/database/localized-articles.ts deleted file mode 100644 index 07f55ab07c..0000000000 --- a/plugins/example/src/database/localized-articles.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { createContentModel } from "@vitnode/core/content/server"; - -import { localizedArticleContentType } from "@/content/localized-article"; - -export const localizedArticleContent = createContentModel( - localizedArticleContentType, -); - -// Two exports for a localized content type, not one. Drizzle Kit discovers each -// table from the export when it globs the built `dist/src/database/*.js`, so the -// translation table needs its own or the migration would be generated without it. -export const example_localized_articles = localizedArticleContent.table; -export const example_localized_articles_translations = - localizedArticleContent.translationTable; diff --git a/plugins/example/src/database/tags.ts b/plugins/example/src/database/tags.ts deleted file mode 100644 index a9395b4c9e..0000000000 --- a/plugins/example/src/database/tags.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { createContentModel } from "@vitnode/core/content/server"; - -import { tagContentType } from "@/content/tag"; - -import { example_categories } from "./categories"; - -export const tagContent = createContentModel(tagContentType, { - references: { category: () => example_categories.id }, -}); - -export const example_tags = tagContent.table; -export const example_tags_translations = tagContent.translationTable; -export const example_tags_related_tags = - tagContent.advancedTables.junctions.relatedTags; diff --git a/plugins/example/src/locales/en.json b/plugins/example/src/locales/en.json deleted file mode 100644 index b249ce65f7..0000000000 --- a/plugins/example/src/locales/en.json +++ /dev/null @@ -1,348 +0,0 @@ -{ - "@vitnode/example": { - "title": "Example", - "admin": { - "nav": { - "overview": "Overview" - }, - "overview": { - "title": "Example plugin", - "desc": "A plugin page inside the AdminCP, shipped by @vitnode/example and served by the app that installed it.", - "points": { - "shell": "The sidebar, the breadcrumb and the admin session guard around this page are the AdminCP's - the route declared area: \"admin\" and nothing else.", - "path": "The URL is the one the route spells out in full. An area chooses the frame; it never adds a prefix.", - "nav": "The sidebar entry that led here is a separate declaration. A plugin page may have no entry, and an entry may point anywhere." - }, - "editor": { - "title": "Editor showcase", - "desc": "Stored editor HTML rendered with EditorContent - one of everything, so a change to the tiptap extensions or to the .tiptap styles shows up here first." - }, - "form": { - "title": "Editor in a form", - "desc": "AutoFormEditor inside an AutoForm. Type / for blocks, use + for panels and tables, and press Ctrl or Cmd + Enter to save.", - "summary": { - "label": "Summary" - }, - "description": { - "label": "Description", - "desc": "Steps, logs and acceptance criteria. Nothing is stored - this form only shows what the editor sends.", - "required": "Describe what happened" - }, - "comment": { - "label": "Comment", - "placeholder": "Leave a note for the assignee…" - }, - "submit": "Save issue", - "success": { - "title": "Issue saved", - "desc": "{words, plural, one {# word} other {# words}} in the description." - } - } - } - }, - "content": { - "article": { - "label": "{count, plural, one {Article} other {Articles}}", - "title": "Articles", - "desc": "Everything the Content Engine generates, from one definition.", - "fields": { - "title": "Title", - "code": "Reference code", - "excerpt": "Excerpt", - "views": "Views", - "featured": "Featured", - "animation": "Animation", - "author": "Author", - "category": "Category", - "updatedAt": "Updated" - } - }, - "category": { - "label": "{count, plural, one {Category} other {Categories}}", - "title": "Categories", - "desc": "Group articles together.", - "fields": { - "name": "Name", - "createdAt": "Created" - } - }, - "tag": { - "label": "{count, plural, one {Tag} other {Tags}}", - "title": "Tags", - "desc": "Keyed by uuid - ids that give nothing away.", - "fields": { - "name": "Name", - "slug": "Slug", - "category": "Category", - "relatedTags": "Related tags", - "updatedAt": "Updated" - } - }, - "event": { - "label": "{count, plural, one {Event} other {Events}}", - "title": "Events", - "desc": "Keyed by bigint - ids too large for a JavaScript number arrive as exact strings.", - "fields": { - "title": "Title", - "slug": "Slug", - "body": "Description", - "primaryTag": "Main tag", - "category": "Category", - "tags": "Tags", - "relatedEvents": "Related events", - "sessions": "Sessions", - "gallery": "Gallery", - "updatedAt": "Updated" - } - }, - "discussion": { - "label": "{count, plural, one {Discussion} other {Discussions}}", - "title": "Discussions", - "desc": "Started and edited by signed-in members through the public write API.", - "fields": { - "title": "Title", - "slug": "Slug", - "body": "Message", - "author": "Author", - "publishedAt": "Published", - "updatedAt": "Updated", - "board": "Board" - } - } - }, - "containers": { - "boards": { - "label": "{count, plural, one {Board} other {Boards}}", - "title": "Boards", - "desc": "The board tree members file their discussions into. Drag to reorder or nest boards.", - "fields": { - "description": "Description", - "color": "Color", - "icon": "Icon", - "locked": "Locked" - } - } - }, - "guide": { - "title": "Plugin routing guide", - "desc": "A nested route shipped by @vitnode/example, served by the app that installed it.", - "index": { - "intro": "This page and the frame around it are two routes declared in one plugin's routes.ts. Neither was copied into the application - both are lazily imported from the plugin's own build output.", - "points": { - "nested": "A layout claims no URL; this index page claims the layout's.", - "messages": "The layout declares the messages both pages render in, so they are warmed alongside the code.", - "breadcrumb": "The breadcrumb above comes from the layout, next to the component that needs it." - } - }, - "topic": { - "from_index": "You arrived here from the guide index - the route's search contract recognised it, and everything else in the query string was dropped." - } - }, - "browse": { - "title": "Browse", - "desc": "A paginated page whose route declares an eager search schema, so ?page=999 is clamped before anything renders.", - "pagination": "Pagination", - "previous": "Previous", - "next": "Next", - "page": "Page {page} of {of}" - }, - "articles": { - "published": "Published", - "faq": "Frequently asked questions" - }, - "events": { - "sessions": "Sessions" - }, - "moderation": { - "category": { - "label": "Categories", - "desc": "Limit this permission to articles in some categories." - } - }, - "boards": { - "title": "Boards", - "desc": "Find the right place for your question, or start a new discussion.", - "tree": "All boards", - "children": "Boards inside", - "discussions": "Discussions", - "new_discussion": "New discussion", - "pagination": "Discussion pages", - "newest": "Newest discussions", - "older": "Older discussions", - "empty": { - "title": "No boards yet", - "desc": "An admin has not created any boards. Once they do, they show up here.", - "home": "Back to home" - }, - "empty_board": { - "title": "Nothing here yet", - "desc": "Be the first to start a discussion in this board." - }, - "not_found": { - "title": "Board not found", - "desc": "It may have been removed, or the link is wrong.", - "back": "See all boards" - }, - "latest": { - "title": "Latest discussions", - "all": "All discussions", - "empty": "Nothing posted yet.", - "error": "Couldn't load the latest discussions.", - "loading": "Loading discussions..." - } - }, - "discussions": { - "new": { - "title": "Start a discussion", - "desc": "Ask a question or share an idea. Everyone can read it as soon as you post it.", - "submit": "Post discussion", - "no_boards": { - "title": "No boards yet", - "desc": "Discussions live in boards, and an admin has not created one yet. Check back soon.", - "action": "Browse boards" - } - }, - "edit": { - "title": "Edit discussion", - "desc": "Changes are saved as a new revision, so nothing you replace is lost.", - "submit": "Save changes", - "view": "View discussion", - "success": { - "title": "Changes saved", - "desc": "\"{title}\" now shows your latest edits." - } - }, - "detail": { - "actions": "Discussion actions", - "edit": "Edit", - "new": "Start a discussion", - "created": { - "title": "Discussion posted", - "desc": "It is live. Use Edit whenever you want to change it." - }, - "board": "Board", - "boards": "Boards", - "posted_by": "Posted by", - "deleted_user": "Deleted user", - "report_type": "discussion", - "hidden": { - "title": "This discussion is hidden", - "desc": "A moderator hid it, so readers can't see it. You can, because you wrote it or moderate this board." - } - }, - "form": { - "board": { - "label": "Board", - "desc": "Pick where the discussion belongs. You can't move it later.", - "placeholder": "Choose a board", - "required": "Pick a board" - }, - "title": { - "label": "Title", - "min": "Use at least 3 characters", - "max": "Keep it under 200 characters" - }, - "body": { - "label": "Message", - "desc": "Type / for headings, lists and more.", - "required": "Write a message" - } - }, - "state": { - "unauthenticated": { - "title": "Sign in to continue", - "desc": "You need an account to start or edit a discussion.", - "action": "Sign in" - }, - "forbidden": { - "title": "You can't edit this discussion", - "desc": "Only its author or a moderator can change it." - }, - "not_found": { - "title": "Discussion not found", - "desc": "It may have been removed, or the link is wrong." - }, - "back": "Start a new discussion" - }, - "list": { - "title": "Discussions", - "desc": "Questions, ideas and show-and-tell from the community, newest first.", - "new": "Start a discussion", - "search_label": "Search discussions", - "search_placeholder": "Search by title", - "board_label": "Board", - "all_boards": "All boards", - "sort_label": "Sort by", - "sort": { - "newest": "Newest", - "oldest": "Oldest", - "title": "Title (A-Z)" - }, - "pagination": "Discussion pages", - "deleted_user": "Deleted user", - "empty": { - "title": "No discussions yet", - "desc": "Be the first to ask a question or share an idea." - }, - "no_results": { - "title": "No discussions match", - "desc": "Try another search term or board.", - "clear": "Clear filters" - }, - "error": { - "title": "Couldn't load discussions", - "desc": "Something went wrong on our side. Refresh the page to try again." - } - } - } - }, - "@vitnode/example:article": "Articles", - "@vitnode/example:article:can_view": "View articles", - "@vitnode/example:article:can_create": "Create articles", - "@vitnode/example:article:can_edit": "Edit articles", - "@vitnode/example:article:can_delete": "Delete articles", - "@vitnode/example:article:can_publish": "Publish and unpublish articles", - "@vitnode/example:article:can_restore": "Restore an earlier version of an article", - "@vitnode/example:category": "Categories", - "@vitnode/example:category:can_view": "View categories", - "@vitnode/example:category:can_create": "Create categories", - "@vitnode/example:category:can_edit": "Edit categories", - "@vitnode/example:category:can_delete": "Delete categories", - "@vitnode/example:tag": "Tags", - "@vitnode/example:tag:can_view": "View tags", - "@vitnode/example:tag:can_create": "Create tags", - "@vitnode/example:tag:can_edit": "Edit tags", - "@vitnode/example:tag:can_delete": "Delete tags", - "@vitnode/example:tag:can_publish": "Publish and unpublish tags", - "@vitnode/example:tag:can_restore": "Restore an earlier version of a tag", - "@vitnode/example:tag:can_hide": "Hide and unhide tags", - "@vitnode/example:event": "Events", - "@vitnode/example:event:can_view": "View events", - "@vitnode/example:event:can_create": "Create events", - "@vitnode/example:event:can_edit": "Edit events", - "@vitnode/example:event:can_delete": "Delete events", - "@vitnode/example:event:can_publish": "Publish and unpublish events", - "@vitnode/example:event:can_restore": "Restore an earlier version of an event", - "@vitnode/example:event:can_hide": "Hide and unhide events", - "@vitnode/example:discussion": "Discussions", - "@vitnode/example:discussion:can_view": "View discussions", - "@vitnode/example:discussion:can_create": "Create discussions", - "@vitnode/example:discussion:can_edit": "Edit discussions", - "@vitnode/example:discussion:can_delete": "Delete discussions", - "@vitnode/example:discussion:can_publish": "Publish and unpublish discussions", - "@vitnode/example:discussion:can_restore": "Restore an earlier version of a discussion", - "@vitnode/example:discussion:can_hide": "Hide and unhide discussions", - "@vitnode/example:example_boards": "Boards", - "@vitnode/example:example_boards:can_view": "View boards", - "@vitnode/example:example_boards:can_create": "Create boards", - "@vitnode/example:example_boards:can_edit": "Edit and move boards", - "@vitnode/example:example_boards:can_delete": "Delete boards", - "ai_actions": { - "@vitnode/example": { - "article_excerpt": { - "title": "Suggest article teaser", - "description": "Suggests a teaser for an example article." - } - } - } -} diff --git a/plugins/example/src/pages/board-search.ts b/plugins/example/src/pages/board-search.ts deleted file mode 100644 index 28dcd9f566..0000000000 --- a/plugins/example/src/pages/board-search.ts +++ /dev/null @@ -1,15 +0,0 @@ -export interface BoardSearch { - cursor?: string; -} - -const CURSOR = /^[A-Za-z0-9_-]{1,512}$/; - -export const boardSearch = (input: Record<string, unknown>): BoardSearch => - typeof input.cursor === "string" && CURSOR.test(input.cursor) - ? { cursor: input.cursor } - : {}; - -export const BOARDS_HREF = "/boards"; - -export const boardHref = (slug: string, { cursor }: BoardSearch = {}): string => - `/boards/${encodeURIComponent(slug)}${cursor ? `?cursor=${encodeURIComponent(cursor)}` : ""}`; diff --git a/plugins/example/src/pages/boards-data.ts b/plugins/example/src/pages/boards-data.ts deleted file mode 100644 index 003f0ac3cb..0000000000 --- a/plugins/example/src/pages/boards-data.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { fetcher } from "@vitnode/core/tanstack/fetcher"; - -import { manifest } from "@/manifest.gen"; - -export const fetchBoards = async () => { - const res = await fetcher({ - plugin: manifest.id, - method: "get", - module: "content/boards", - path: "/", - args: { query: {} }, - }); - - if (res.status !== 200) throw new Error("Loading the boards failed."); - - const { items } = await res.json(); - - return items; -}; - -export type Board = Awaited<ReturnType<typeof fetchBoards>>[number]; - -const HEX_COLOR = /^#(?:[0-9a-f]{3}|[0-9a-f]{6})$/i; - -export const boardColor = (board: Pick<Board, "metadata">): null | string => - typeof board.metadata.color === "string" && - HEX_COLOR.test(board.metadata.color) - ? board.metadata.color - : null; diff --git a/plugins/example/src/pages/discussion-list.ts b/plugins/example/src/pages/discussion-list.ts deleted file mode 100644 index 2f5a8a640b..0000000000 --- a/plugins/example/src/pages/discussion-list.ts +++ /dev/null @@ -1,95 +0,0 @@ -import type { QueryClient } from "@tanstack/react-query"; -import type { ContentListSearch } from "@vitnode/core/tanstack/content"; - -import { - contentListIdFilter, - contentListQueryOptions, - defineContentList, - useContentPermissions, -} from "@vitnode/core/tanstack/content"; -import { fetcher } from "@vitnode/core/tanstack/fetcher"; - -import { manifest } from "@/manifest.gen"; - -export const DISCUSSIONS_HREF = "/discussions"; - -/** `/discussions`: newest first, 10 a page, searchable and filterable by board. */ -export const discussionList = defineContentList({ - defaultSort: "newest", - filters: { board: contentListIdFilter }, - pageSize: 10, - sorts: { - newest: { order: "desc", orderBy: "publishedAt" }, - oldest: { order: "asc", orderBy: "publishedAt" }, - title: { order: "asc", orderBy: "title" }, - }, -}); - -export type DiscussionListSort = (typeof discussionList.sorts)[number]; - -export type DiscussionListSearch = ContentListSearch< - DiscussionListSort, - { board: typeof contentListIdFilter } ->; - -export const discussionListSearch = ( - input: Record<string, unknown>, -): DiscussionListSearch => discussionList.parseSearch(input); - -const discussionQueryKeys = { - all: [manifest.id, "discussions"] as const, -}; - -export const discussionsQueryOptions = (request: { - locale: string; - search: DiscussionListSearch; -}) => - contentListQueryOptions({ - ...request, - queryKey: discussionQueryKeys.all, - fetch: async ({ search }) => { - const res = await fetcher({ - plugin: manifest.id, - method: "get", - module: "content/discussions", - path: "/", - args: { - query: { - ...discussionList.apiQuery(search), - ...(search.board === undefined - ? {} - : { board: String(search.board), includeDescendants: "true" }), - }, - }, - }); - if (res.status !== 200) { - throw new Error(`Loading discussions answered ${res.status}.`); - } - - return await res.json(); - }, - }); - -/** Expires every cached discussion list, after a member posts or edits one. */ -export const invalidateDiscussionLists = async (queryClient: QueryClient) => { - await queryClient.invalidateQueries({ queryKey: discussionQueryKeys.all }); -}; - -/** What the visitor may do with each discussion on screen: `canEdit`, `canReport`. */ -export const useDiscussionPermissions = (ids: readonly number[]) => - useContentPermissions({ - ids, - queryKey: discussionQueryKeys.all, - fetch: async request => { - const res = await fetcher({ - plugin: manifest.id, - method: "get", - module: "content/discussions", - path: "/permissions", - args: { query: { ids: request.join(",") } }, - }); - if (res.status !== 200) throw new Error("Loading permissions failed."); - - return await res.json(); - }, - }); diff --git a/plugins/example/src/pages/discussion-new-page.tsx b/plugins/example/src/pages/discussion-new-page.tsx deleted file mode 100644 index 5b5864b963..0000000000 --- a/plugins/example/src/pages/discussion-new-page.tsx +++ /dev/null @@ -1,141 +0,0 @@ -import type { AutoFormOnSubmit } from "@vitnode/core/components/form/auto-form"; -import type { PluginRoutePageProps } from "@vitnode/core/routing"; - -import { useQueryClient } from "@tanstack/react-query"; -import { buttonVariants } from "@vitnode/core/components/ui/button"; -import { - Empty, - EmptyContent, - EmptyDescription, - EmptyHeader, - EmptyMedia, - EmptyTitle, -} from "@vitnode/core/components/ui/empty"; -import { useContentPublicWrite } from "@vitnode/core/tanstack/content"; -import { fetcher } from "@vitnode/core/tanstack/fetcher"; -import { HrefLink, useHrefNavigate } from "@vitnode/core/tanstack/link"; -import { - defineRoute, - type RouteLoadContext, -} from "@vitnode/core/tanstack/plugin-routes"; -import { FolderTreeIcon } from "lucide-react"; -import { useState } from "react"; -import { useTranslations } from "use-intl"; - -import { manifest } from "@/manifest.gen"; - -import type { DiscussionNewSearch } from "./discussion-search"; - -import { BOARDS_HREF } from "./board-search"; -import { - DISCUSSION_FIELDS, - DiscussionCreateForm, - type DiscussionCreateFormSchema, -} from "./discussion-form"; -import { invalidateDiscussionLists } from "./discussion-list"; -import { discussionEditHref, discussionHref } from "./discussion-search"; -import { DiscussionShell } from "./discussion-shell"; -import { boardsQueryOptions } from "./example-queries"; - -const loadBoardOptions = async (queryClient: RouteLoadContext["queryClient"]) => - (await queryClient.query(boardsQueryOptions())).map(board => ({ - label: `${"— ".repeat(board.depth - 1)}${board.name}`, - value: String(board.id), - })); - -type DiscussionNewData = Awaited<ReturnType<typeof loadBoardOptions>>; - -const DiscussionNewBreadcrumb = () => { - const t = useTranslations("@vitnode/example.discussions.new"); - - return <span>{t("title")}</span>; -}; - -export const route = defineRoute<DiscussionNewData, DiscussionNewSearch>({ - load: async ({ context }) => await loadBoardOptions(context.queryClient), - - head: ({ t }) => ({ - description: t("@vitnode/example.discussions.new.desc"), - robots: "noindex, nofollow", - title: t("@vitnode/example.discussions.new.title"), - }), - - breadcrumb: DiscussionNewBreadcrumb, -}); - -const DiscussionNewPage = ({ - loaderData: options, - search, -}: PluginRoutePageProps<DiscussionNewData, DiscussionNewSearch>) => { - const t = useTranslations("@vitnode/example.discussions"); - const write = useContentPublicWrite({ - fields: DISCUSSION_FIELDS, - uniqueField: "title", - }); - const [isLeaving, setIsLeaving] = useState(false); - const navigate = useHrefNavigate(); - const queryClient = useQueryClient(); - - const onSubmit: AutoFormOnSubmit<DiscussionCreateFormSchema> = async ( - { board, ...values }, - form, - ) => { - const record = await write.submit( - form, - async () => - await fetcher({ - plugin: manifest.id, - method: "post", - module: "content/discussions", - path: "/", - args: { body: { values: { ...values, board: Number(board) } } }, - }), - ); - if (!record) return; - - setIsLeaving(true); - await invalidateDiscussionLists(queryClient); - await navigate( - record.slug === null - ? discussionEditHref(record.id) - : discussionHref(record.slug, { created: true }), - ); - }; - - return ( - <DiscussionShell description={t("new.desc")} title={t("new.title")}> - {options.length === 0 ? ( - <Empty className="border"> - <EmptyHeader> - <EmptyMedia variant="icon"> - <FolderTreeIcon aria-hidden="true" /> - </EmptyMedia> - <EmptyTitle render={<h2 />}>{t("new.no_boards.title")}</EmptyTitle> - <EmptyDescription>{t("new.no_boards.desc")}</EmptyDescription> - </EmptyHeader> - <EmptyContent> - <HrefLink - className={buttonVariants({ variant: "outline" })} - href={BOARDS_HREF} - > - {t("new.no_boards.action")} - </HrefLink> - </EmptyContent> - </Empty> - ) : ( - <DiscussionCreateForm - boards={{ - options, - selected: - search.board === undefined ? undefined : String(search.board), - }} - isLeaving={isLeaving} - onSubmit={onSubmit} - submitLabel={t("new.submit")} - /> - )} - </DiscussionShell> - ); -}; - -export default DiscussionNewPage; diff --git a/plugins/example/src/pages/discussion-search.ts b/plugins/example/src/pages/discussion-search.ts deleted file mode 100644 index 2f0a2e9e4a..0000000000 --- a/plugins/example/src/pages/discussion-search.ts +++ /dev/null @@ -1,43 +0,0 @@ -export interface DiscussionSearch { - created?: true; -} - -export const discussionSearch = ( - input: Record<string, unknown>, -): DiscussionSearch => - input.created === true || input.created === "true" ? { created: true } : {}; - -export const discussionHref = ( - slug: string, - { created }: DiscussionSearch = {}, -): string => - `/discussions/${encodeURIComponent(slug)}${created ? "?created=true" : ""}`; - -export const discussionEditHref = (id: number | string): string => - `/discussions/${encodeURIComponent(String(id))}/edit`; - -const positiveId = (raw: unknown): number | undefined => { - const value = - typeof raw === "number" - ? raw - : typeof raw === "string" && /^[1-9]\d{0,15}$/.test(raw) - ? Number(raw) - : Number.NaN; - - return Number.isSafeInteger(value) && value > 0 ? value : undefined; -}; - -export interface DiscussionNewSearch { - board?: number; -} - -export const discussionNewSearch = ( - input: Record<string, unknown>, -): DiscussionNewSearch => { - const board = positiveId(input.board); - - return board === undefined ? {} : { board }; -}; - -export const discussionNewHref = (board?: number): string => - board === undefined ? "/discussions/new" : `/discussions/new?board=${board}`; diff --git a/plugins/example/src/pages/example-queries.ts b/plugins/example/src/pages/example-queries.ts deleted file mode 100644 index 5872a9269d..0000000000 --- a/plugins/example/src/pages/example-queries.ts +++ /dev/null @@ -1,96 +0,0 @@ -import type { RouteLoadContext } from "@vitnode/core/tanstack/plugin-routes"; - -import { LOADER_DATA_QUERY_META } from "@vitnode/core/lib/query-client"; -import { RECORD_STALE_TIME } from "@vitnode/core/lib/query-freshness"; -import { fetcher } from "@vitnode/core/tanstack/fetcher"; - -import { manifest } from "@/manifest.gen"; - -import { fetchBoards } from "./boards-data"; - -type QueryClient = RouteLoadContext["queryClient"]; - -/** - * Boards are AdminCP-managed containers, so they get a record's freshness and - * are shared across pages: the boards index, the "new discussion" picker and a - * board page all read the same entries. A discussion page and a board's list - * read the API directly; the `/discussions` list is cached in - * `discussion-list.ts`, and the create and edit pages expire it. - */ -const exampleQueryKeys = { - board: (idOrSlug: string) => [manifest.id, "board", idOrSlug] as const, - boards: () => [manifest.id, "boards"] as const, -}; - -export const boardsQueryOptions = () => ({ - meta: LOADER_DATA_QUERY_META, - queryFn: fetchBoards, - queryKey: exampleQueryKeys.boards(), - staleTime: RECORD_STALE_TIME, -}); - -const fetchBoard = async (idOrSlug: string) => { - const res = await fetcher({ - plugin: manifest.id, - method: "get", - module: "content/boards", - path: "/{idOrSlug}", - args: { params: { idOrSlug: encodeURIComponent(idOrSlug) } }, - }); - - if (res.status === 404) return null; - if (res.status !== 200) throw new Error("Loading the board failed."); - - return await res.json(); -}; - -const boardQueryOptions = (idOrSlug: string) => ({ - meta: LOADER_DATA_QUERY_META, - queryFn: async () => await fetchBoard(idOrSlug), - queryKey: exampleQueryKeys.board(idOrSlug), - // A miss is not cached, so a board created a moment ago is found next time. - staleTime: ({ state }: { state: { data: unknown } }) => - state.data ? RECORD_STALE_TIME : 0, -}); - -/** - * The board by id or slug, seeded under its other key as well - the board page - * asks by slug, a discussion page by id. A slug made only of digits is skipped: - * the route reads it as an id, so it would answer for a different board. - */ -export const queryBoard = async ( - queryClient: QueryClient, - idOrSlug: string, -) => { - const data = await queryClient.query(boardQueryOptions(idOrSlug)); - if (!data) return data; - - const { id, slug } = data.container; - // Through `query` with the data already in hand, so the copy is built from - // the same options - `meta` included, which keeps it out of the SSR payload. - // A fresh entry is left as it is; a stale one takes this newer copy. - await Promise.all( - [String(id), slug] - .filter(key => key !== idOrSlug && !(key === slug && /^\d+$/.test(slug))) - .map( - async key => - await queryClient.query({ - ...boardQueryOptions(key), - queryFn: () => data, - }), - ), - ); - - return data; -}; - -/** The board's id from the cached boards list, when a page has already read it. */ -export const cachedBoardId = ( - queryClient: QueryClient, - slug: string, -): null | number => - queryClient - .getQueryData<Awaited<ReturnType<typeof fetchBoards>>>( - exampleQueryKeys.boards(), - ) - ?.find(board => board.slug === slug)?.id ?? null; diff --git a/plugins/example/src/plugin-metadata.gen.ts b/plugins/example/src/plugin-metadata.gen.ts deleted file mode 100644 index f79bff5bd3..0000000000 --- a/plugins/example/src/plugin-metadata.gen.ts +++ /dev/null @@ -1,16 +0,0 @@ -/* oxlint-disable */ - -// This file is generated by VitNode. Do not edit it, and do not format it. -// -// It is rewritten by `vitnode build` and `vitnode dev` from this package's -// package.json - `name`, `version` and `description` - and the title and -// icon in `src/plugin-metadata.ts`. Register `pluginMetadata` as `metadata` -// in both `buildPlugin` and `buildApiPlugin`. - -export const pluginMetadata = { - id: "@vitnode/example", - title: "@vitnode/example", - icon: null, - description: "Reference plugin exercising the VitNode Content Engine end to end.", - version: "2.0.0-canary.13", -} as const diff --git a/plugins/example/src/routes.ts b/plugins/example/src/routes.ts deleted file mode 100644 index 8971a35af8..0000000000 --- a/plugins/example/src/routes.ts +++ /dev/null @@ -1,126 +0,0 @@ -import { - definePluginRoutes, - index, - layout, - lazy, - page, -} from "@vitnode/core/routing"; -import { - FeedPendingSkeleton, - RoutePendingSkeleton, -} from "@vitnode/core/tanstack/pending"; - -import type { ExampleSearch } from "./pages/example-page"; - -import { boardSearch } from "./pages/board-search"; -import { browseSearch } from "./pages/browse-search"; -import { discussionListSearch } from "./pages/discussion-list"; -import { - discussionNewSearch, - discussionSearch, -} from "./pages/discussion-search"; -import { zonesSearch } from "./pages/zones-search"; - -export const routes = definePluginRoutes([ - page("/example/:slug", { - component: lazy(() => import("./pages/example-page")), - search: (input): ExampleSearch => ({ - tab: input.tab === "details" ? "details" : "summary", - }), - }), - - page("/example/browse", { - component: lazy(() => import("./pages/browse-page")), - messages: ["@vitnode/example.browse"], - search: browseSearch, - }), - - page("/example/zones", { - component: lazy(() => import("./pages/zones-page")), - search: zonesSearch, - }), - - page("/example/embed", { - area: "blank", - component: lazy(() => import("./pages/embed-page")), - }), - - layout("/example/guide", { - component: lazy(() => import("./pages/guide-layout")), - messages: ["@vitnode/example.guide"], - children: [ - /** The page at the layout's own URL - `/example/guide`. */ - index({ - component: lazy(() => import("./pages/guide-index-page")), - }), - - page(":topic", { - component: lazy(() => import("./pages/guide-topic-page")), - }), - ], - }), - - page("/articles/:slug", { - component: lazy(() => import("./pages/article-page")), - messages: ["@vitnode/example.articles"], - }), - - page("/advanced-articles/:slug", { - component: lazy(() => import("./pages/advanced-article-page")), - messages: ["@vitnode/example.articles"], - }), - - page("/events/:slug", { - component: lazy(() => import("./pages/event-page")), - messages: ["@vitnode/example.articles", "@vitnode/example.events"], - }), - - page("/localized-articles/:slug", { - component: lazy(() => import("./pages/localized-article-page")), - messages: ["@vitnode/example.articles"], - }), - - page("/boards", { - component: lazy(() => import("./pages/boards-page")), - messages: ["@vitnode/example.boards"], - pendingComponent: RoutePendingSkeleton, - }), - - page("/boards/:slug", { - component: lazy(() => import("./pages/board-page")), - messages: ["@vitnode/example.boards"], - pendingComponent: FeedPendingSkeleton, - search: boardSearch, - }), - - page("/discussions", { - component: lazy(() => import("./pages/discussions-page")), - messages: ["@vitnode/example.discussions"], - pendingComponent: FeedPendingSkeleton, - search: discussionListSearch, - }), - - page("/discussions/new", { - component: lazy(() => import("./pages/discussion-new-page")), - messages: ["@vitnode/example.discussions"], - requires: "authenticated", - search: discussionNewSearch, - }), - - page("/discussions/:id/edit", { - component: lazy(() => import("./pages/discussion-edit-page")), - messages: ["@vitnode/example.discussions"], - }), - - page("/discussions/:slug", { - component: lazy(() => import("./pages/discussion-page")), - messages: ["@vitnode/example.articles", "@vitnode/example.discussions"], - search: discussionSearch, - }), - - page("/admin/example", { - area: "admin", - component: lazy(() => import("./pages/admin-example-page")), - messages: ["@vitnode/example.admin.overview"], - }), -]); diff --git a/plugins/forum/src/locales/en.json b/plugins/forum/src/locales/en.json index b3de652be4..05c15b9fe3 100644 --- a/plugins/forum/src/locales/en.json +++ b/plugins/forum/src/locales/en.json @@ -139,11 +139,6 @@ "pending": { "title": "Sent for approval", "desc": "A moderator will review your changes. Readers see the published version until then." - }, - "conflict": { - "title": "This topic changed", - "desc": "Someone saved a newer version while you were editing. Reload to see it, then make your changes again.", - "reload": "Reload" } }, "state": { diff --git a/plugins/forum/src/pages/forum-links.ts b/plugins/forum/src/pages/forum-links.ts index 1d07ad39e9..5a9aa7f6cd 100644 --- a/plugins/forum/src/pages/forum-links.ts +++ b/plugins/forum/src/pages/forum-links.ts @@ -65,19 +65,8 @@ export const topicNewHref = (forum?: number): string => ? `${FORUM_HREF}/create/topic` : `${FORUM_HREF}/create/topic?forum=${forum}`; -export interface TopicEditSearch { - submitted?: true; -} - -export const topicEditSearch = ( - input: Record<string, unknown>, -): TopicEditSearch => (isTrue(input.submitted) ? { submitted: true } : {}); - -export const topicEditHref = ( - id: number | string, - { submitted }: TopicEditSearch = {}, -): string => - `${FORUM_HREF}/topics/${encodeURIComponent(String(id))}/edit${submitted ? "?submitted=1" : ""}`; +export const topicEditHref = (id: number | string): string => + `${FORUM_HREF}/topics/${encodeURIComponent(String(id))}/edit`; export interface PendingSearch { page?: number; diff --git a/plugins/forum/src/pages/topic-edit-page.tsx b/plugins/forum/src/pages/topic-edit-page.tsx index d340ac9c55..9137fd2228 100644 --- a/plugins/forum/src/pages/topic-edit-page.tsx +++ b/plugins/forum/src/pages/topic-edit-page.tsx @@ -1,16 +1,13 @@ +import type { AutoFormOnSubmit } from "@vitnode/core/components/form/auto-form"; import type { ContentApprovalState } from "@vitnode/core/content"; import { useQueryClient } from "@tanstack/react-query"; -import { - type AutoFormOnSubmit, - setFormFieldError, -} from "@vitnode/core/components/form/auto-form"; import { Alert, AlertDescription, AlertTitle, } from "@vitnode/core/components/ui/alert"; -import { Button, buttonVariants } from "@vitnode/core/components/ui/button"; +import { buttonVariants } from "@vitnode/core/components/ui/button"; import { CONTENT_PUBLIC_WRITE_ISSUE_CODES, parseContentPublicWriteRejection, @@ -20,6 +17,7 @@ import { definePluginRoute, type PluginRoutePageProps, } from "@vitnode/core/routing"; +import { useContentPublicWrite } from "@vitnode/core/tanstack/content"; import { fetcher } from "@vitnode/core/tanstack/fetcher"; import { HrefLink } from "@vitnode/core/tanstack/link"; import { @@ -30,17 +28,15 @@ import { ShieldAlertIcon, XCircleIcon, } from "lucide-react"; -import { useRef, useState } from "react"; +import { useState } from "react"; import { toast } from "sonner"; import { useTranslations } from "use-intl"; import { z } from "zod"; import { manifest } from "@/manifest.gen"; -import type { TopicEditSearch } from "./forum-links"; import type { TopicFormValues } from "./topic-form"; -import { useForumRejection } from "./forum-errors"; import { FORUM_HREF, FORUM_PENDING_HREF, @@ -63,13 +59,9 @@ const zodProposedValues = z }) .catch({}); -const loadEditableTopic = async (param: string) => { - const id = Number(param); - if (!Number.isSafeInteger(id) || id < 1) { - return { state: "notFound" as const }; - } - - const res = await fetcher({ +// Answers only for the author and moderators, drafts and hidden topics included. +const fetchEditableTopic = async (id: number) => + await fetcher({ plugin: manifest.id, method: "get", module: "content/topics", @@ -77,6 +69,14 @@ const loadEditableTopic = async (param: string) => { args: { params: { id } }, }); +const loadEditableTopic = async (param: string) => { + const id = Number(param); + if (!Number.isSafeInteger(id) || id < 1) { + return { state: "notFound" as const }; + } + + const res = await fetchEditableTopic(id); + if (res.status === 200) { return { record: await res.json(), state: "ready" as const }; } @@ -108,7 +108,7 @@ const TopicEditBreadcrumb = () => { return <span>{t("title")}</span>; }; -export const route = definePluginRoute<TopicEditData, TopicEditSearch>({ +export const route = definePluginRoute<TopicEditData>({ load: async ({ params }) => await loadEditableTopic(params.id), head: ({ t }) => ({ @@ -133,15 +133,12 @@ const formValuesOf = (record: EditableTopic): TopicFormValues => { }; }; +/** Reads only the server's `approval`: neither the URL nor a draft status adds a notice. */ const ApprovalNotice = ({ approval, - status, - submitted, }: { /** Only what the notice reads, so the edit route's JSON answer fits as is. */ approval: null | Pick<ContentApprovalState, "kind" | "reason" | "status">; - status: EditableTopic["status"]; - submitted: boolean; }) => { const t = useTranslations("@vitnode/forum.topics.approval"); @@ -167,12 +164,7 @@ const ApprovalNotice = ({ ); } - if ( - submitted || - (status === "draft" && - approval?.status === "pending" && - approval.kind === "create") - ) { + if (approval?.status === "pending" && approval.kind === "create") { return ( <Alert icon={<HourglassIcon />} variant="info"> <AlertTitle>{t("pending_create.title")}</AlertTitle> @@ -189,105 +181,73 @@ const ApprovalNotice = ({ return null; }; -const TopicEditForm = ({ - record, - submitted, -}: { - record: EditableTopic; - submitted: boolean; -}) => { +const TopicEditForm = ({ record }: { record: EditableTopic }) => { const t = useTranslations("@vitnode/forum.topics"); - const tErrors = useTranslations("@vitnode/forum.forums.errors"); - const rejectTopic = useForumRejection(TOPIC_FORM_FIELDS); const queryClient = useQueryClient(); - const versionRef = useRef(record.version); const [current, setCurrent] = useState({ approval: record.approval, slug: record.slug, status: record.status, }); - const [hasConflict, setHasConflict] = useState(false); + // The form's defaults follow every save: AutoForm resets an untouched form + // to whatever `defaultValues` it renders with. + const [defaults, setDefaults] = useState(() => formValuesOf(record)); + const write = useContentPublicWrite({ + fields: TOPIC_FORM_FIELDS, + latestVersion: async () => { + const res = await fetchEditableTopic(record.id); + + return res.status === 200 ? (await res.json()).version : null; + }, + uniqueField: "title", + version: record.version, + }); const onSubmit: AutoFormOnSubmit<TopicFormSchema> = async (values, form) => { - const res = await fetcher({ - plugin: manifest.id, - method: "patch", - module: "content/topics", - path: "/{id}", - args: { - params: { id: record.id }, - body: { values, expectedVersion: versionRef.current }, - }, + const updated = await write.submit( + form, + async expectedVersion => + await fetcher({ + plugin: manifest.id, + method: "patch", + module: "content/topics", + path: "/{id}", + args: { + params: { id: record.id }, + body: { values, expectedVersion }, + }, + }), + ); + if (!updated) return; + + setCurrent({ + approval: updated.approval, + slug: updated.slug, + status: updated.status, }); + setDefaults(values); + form.reset(values); + await invalidateForumTopics(queryClient); - if (res.status === 200) { - const updated = await res.json(); - await invalidateForumTopics(queryClient); - versionRef.current = updated.version; - setCurrent({ - approval: updated.approval, - slug: updated.slug, - status: updated.status, + if (updated.approval?.status === "pending") { + toast.info(t("edit.pending.title"), { + description: t("edit.pending.desc"), + }); + } else { + toast.success(t("edit.success.title"), { + description: t("edit.success.desc", { title: values.title }), }); - setHasConflict(false); - form.reset(values); - - if (updated.approval?.status === "pending") { - toast.info(t("edit.pending.title"), { - description: t("edit.pending.desc"), - }); - } else { - toast.success(t("edit.success.title"), { - description: t("edit.success.desc", { title: values.title }), - }); - } - - return; - } - - if (res.status === 409) { - const conflict = await res.json(); - - if (conflict.code === "CONTENT_VERSION_CONFLICT") { - setHasConflict(true); - } else { - setFormFieldError(form, "title", tErrors("unique")); - } - - return; } - - await rejectTopic(res, form); }; return ( <> - <ApprovalNotice - approval={current.approval} - status={current.status} - submitted={submitted} - /> + <ApprovalNotice approval={current.approval} /> - {hasConflict ? ( - <Alert variant="warning"> - <AlertTitle>{t("edit.conflict.title")}</AlertTitle> - <AlertDescription>{t("edit.conflict.desc")}</AlertDescription> - <div className="flex pt-2"> - <Button - onClick={() => { - window.location.reload(); - }} - size="sm" - variant="outline" - > - {t("edit.conflict.reload")} - </Button> - </div> - </Alert> - ) : null} + {write.conflict} <TopicForm - defaultValues={formValuesOf(record)} + defaultValues={defaults} onSubmit={onSubmit} submitLabel={t("edit.submit")} /> @@ -310,8 +270,7 @@ const TopicEditForm = ({ const TopicEditPage = ({ loaderData, params, - search, -}: PluginRoutePageProps<TopicEditData, TopicEditSearch>) => { +}: PluginRoutePageProps<TopicEditData>) => { const t = useTranslations("@vitnode/forum.topics"); if (loaderData.state === "unauthenticated") { @@ -367,11 +326,7 @@ const TopicEditPage = ({ return ( <ForumShell description={t("edit.desc")} title={t("edit.title")}> - <TopicEditForm - key={params.id} - record={loaderData.record} - submitted={search.submitted === true} - /> + <TopicEditForm key={params.id} record={loaderData.record} /> </ForumShell> ); }; diff --git a/plugins/forum/src/pages/topic-new-page.tsx b/plugins/forum/src/pages/topic-new-page.tsx index 73ae1e7815..a3819ac9be 100644 --- a/plugins/forum/src/pages/topic-new-page.tsx +++ b/plugins/forum/src/pages/topic-new-page.tsx @@ -89,10 +89,12 @@ const TopicNewPage = ({ const record = await res.json(); setIsLeaving(true); await invalidateForumTopics(queryClient); + // Only a published topic has a public page. A pending or draft one + // opens its edit page, whose loader reads the approval state. await navigate( - record.approval?.status === "pending" || record.slug === null - ? topicEditHref(record.id, { submitted: true }) - : topicHref(record.slug, { created: true }), + record.status === "published" && record.slug !== null + ? topicHref(record.slug, { created: true }) + : topicEditHref(record.id), ); return; diff --git a/plugins/forum/src/routes.ts b/plugins/forum/src/routes.ts index 0927c74ebf..f25aea8f1a 100644 --- a/plugins/forum/src/routes.ts +++ b/plugins/forum/src/routes.ts @@ -8,7 +8,6 @@ import { import { forumSearch, pendingSearch, - topicEditSearch, topicNewSearch, topicSearch, } from "./pages/forum-links"; @@ -59,7 +58,6 @@ export const routes = definePluginRoutes([ component: lazy(() => import("./pages/topic-edit-page")), messages: ["@vitnode/forum.forums", "@vitnode/forum.topics"], pendingComponent: FormPendingSkeleton, - search: topicEditSearch, }), page("/forum/topics/:slug", { diff --git a/plugins/example/.npmignore b/plugins/newsroom/.npmignore similarity index 100% rename from plugins/example/.npmignore rename to plugins/newsroom/.npmignore diff --git a/plugins/newsroom/README.md b/plugins/newsroom/README.md new file mode 100644 index 0000000000..904a14d4e6 --- /dev/null +++ b/plugins/newsroom/README.md @@ -0,0 +1,28 @@ +# newsroom + +This is the plugin the [Content Engine docs](https://vitnode.com/docs/content-engine) build step by step. It kept growing, and now it's a tour of what the engine can do. Read it, copy it, rename it. + +## What is inside + +| Area | Where | Shows | +| --- | --- | --- | +| Articles, categories, comments | `src/content/article.ts`, `category.ts`, `comment.ts`; pages at `/news` | The tutorial: public API, delivery, member writing, threads, reports | +| Stories | `src/content/story.ts`; `/stories/:slug` | Files, scheduling, live editing, SEO, search, an AI teaser action, a moderation scope | +| Explainers | `src/content/explainer.ts`; `/explainers/:slug` | To-many relations, groups, repeatable rows, localization | +| Bulletins | `src/content/bulletin.ts`; `/bulletins/:slug` | A localized content type with per-language editorial | +| Tags and events | `src/content/tag.ts`, `event.ts`; `/events/:slug` | `uuid` and `bigint` id strategies, relations across them | +| Desks and letters | `src/content/desks.ts`, `letter.ts`; `/desks`, `/letters` | A container tree and member-written records filed in it | +| Pages and widgets | `src/content/page.ts`, `settings-page.ts`, `src/widgets/`; `/newsroom/zones` | Block fields, editable pages, content zones, block variants | +| Routing demos | `src/routes.ts`; `/newsroom/browse`, `/newsroom/guide`, `/newsroom/embed` | Search contracts, layouts, the blank area | +| AdminCP | `src/admin/nav.tsx`; `/admin/newsroom` | Content screens, containers and a plugin page | + +## Develop + +```bash +vitnode dev # rebuild dist/ on every change +vitnode build # one-off build +vitest # run the plugin's tests +vitnode plugin validate newsroom +``` + +Docs: https://vitnode.com/docs/dev/plugins diff --git a/plugins/example/global.d.ts b/plugins/newsroom/global.d.ts similarity index 62% rename from plugins/example/global.d.ts rename to plugins/newsroom/global.d.ts index c74dd53ee1..3e51a49d29 100644 --- a/plugins/example/global.d.ts +++ b/plugins/newsroom/global.d.ts @@ -1,10 +1,11 @@ /// <reference types="use-intl" /> +import coreApi from "@vitnode/core/locales/api/en.json" with { type: "json" }; import core from "@vitnode/core/locales/en.json" with { type: "json" }; import plugin from "./src/locales/en.json" with { type: "json" }; declare module "use-intl" { interface AppConfig { - Messages: typeof plugin & typeof core; + Messages: typeof plugin & typeof core & typeof coreApi; } } diff --git a/plugins/example/oxlint.config.ts b/plugins/newsroom/oxlint.config.ts similarity index 100% rename from plugins/example/oxlint.config.ts rename to plugins/newsroom/oxlint.config.ts diff --git a/plugins/example/package.json b/plugins/newsroom/package.json similarity index 81% rename from plugins/example/package.json rename to plugins/newsroom/package.json index c8ff21d57e..207c39cc15 100644 --- a/plugins/example/package.json +++ b/plugins/newsroom/package.json @@ -1,22 +1,11 @@ { - "name": "@vitnode/example", - "version": "2.0.0-canary.13", - "description": "Reference plugin exercising the VitNode Content Engine end to end.", - "author": { - "name": "VitNode", - "email": "contact@vitnode.com", - "url": "https://vitnode.com" - }, - "license": "MIT", + "name": "newsroom", + "version": "0.1.0", + "description": "Newsroom: the VitNode tutorial plugin, grown into a tour of the Content Engine.", "private": true, "type": "module", - "exports": { - "./locales/*.json": "./src/locales/*.json", - "./*": { - "import": "./dist/src/*.js", - "types": "./dist/src/*.d.ts", - "default": "./dist/src/*.js" - } + "engines": { + "node": "^22.18.0 || ^24.11.0 || >=26.0.0" }, "scripts": { "build:plugins": "vitnode build", @@ -26,6 +15,14 @@ "test": "vitest run", "test:watch": "vitest" }, + "exports": { + "./locales/*.json": "./src/locales/*.json", + "./*": { + "import": "./dist/src/*.js", + "types": "./dist/src/*.d.ts", + "default": "./dist/src/*.js" + } + }, "dependencies": { "@hono/zod-openapi": "^1.6.3", "@tanstack/react-form": "^1.33.5", diff --git a/plugins/newsroom/src/admin/content.tsx b/plugins/newsroom/src/admin/content.tsx new file mode 100644 index 0000000000..80cb10aeea --- /dev/null +++ b/plugins/newsroom/src/admin/content.tsx @@ -0,0 +1 @@ +export { adminNav as adminContent } from "./nav"; diff --git a/plugins/newsroom/src/admin/nav.tsx b/plugins/newsroom/src/admin/nav.tsx new file mode 100644 index 0000000000..2fe0a6b2bb --- /dev/null +++ b/plugins/newsroom/src/admin/nav.tsx @@ -0,0 +1,43 @@ +import type { AdminNavPluginSource } from "@vitnode/core/lib/plugin"; + +import { + CalendarDaysIcon, + FolderIcon, + FolderTreeIcon, + LayoutTemplateIcon, + MailIcon, + MessageSquareIcon, + NewspaperIcon, + NotebookPenIcon, + SparklesIcon, + TagIcon, +} from "lucide-react"; + +import { articleContentType } from "@/content/article"; +import { categoryContentType } from "@/content/category"; +import { commentContentType } from "@/content/comment"; +import { deskContainers } from "@/content/desks"; +import { eventContentType } from "@/content/event"; +import { letterContentType } from "@/content/letter"; +import { pageContentType } from "@/content/page"; +import { storyContentType } from "@/content/story"; +import { tagContentType } from "@/content/tag"; +import { manifest } from "@/manifest.gen"; + +export const adminNav = { + pluginId: manifest.id, + containers: [{ definition: deskContainers, icon: <FolderTreeIcon /> }], + contentTypes: [ + { definition: articleContentType, icon: <NewspaperIcon /> }, + { definition: categoryContentType, icon: <FolderIcon /> }, + { definition: commentContentType, icon: <MessageSquareIcon /> }, + { definition: storyContentType, icon: <NotebookPenIcon /> }, + { definition: tagContentType, icon: <TagIcon /> }, + { definition: eventContentType, icon: <CalendarDaysIcon /> }, + { definition: pageContentType, icon: <LayoutTemplateIcon /> }, + { definition: letterContentType, icon: <MailIcon /> }, + ], + admin: { + nav: [{ href: "/admin/newsroom", icon: <SparklesIcon />, id: "overview" }], + }, +} satisfies AdminNavPluginSource; diff --git a/plugins/example/src/api/admin-staff-permission.test.ts b/plugins/newsroom/src/api/admin-staff-permission.test.ts similarity index 72% rename from plugins/example/src/api/admin-staff-permission.test.ts rename to plugins/newsroom/src/api/admin-staff-permission.test.ts index 363925018b..858fff4c7a 100644 --- a/plugins/example/src/api/admin-staff-permission.test.ts +++ b/plugins/newsroom/src/api/admin-staff-permission.test.ts @@ -1,11 +1,11 @@ import { findAdminRoutesWithoutStaffPermission } from "@vitnode/core/api/lib/route"; import { describe, expect, it } from "vitest"; -import { exampleApiPlugin } from "@/config.api"; +import { newsroomApiPlugin } from "@/config.api"; -describe("example admin routes", () => { +describe("newsroom admin routes", () => { it("all require a staff permission", () => { - const { hono } = exampleApiPlugin(); + const { hono } = newsroomApiPlugin(); expect(hono.routes.some(route => route.path.includes("/admin/"))).toBe( true, diff --git a/plugins/example/src/api/ai/actions.ts b/plugins/newsroom/src/api/ai/actions.ts similarity index 68% rename from plugins/example/src/api/ai/actions.ts rename to plugins/newsroom/src/api/ai/actions.ts index edc2635bc4..68417a66ed 100644 --- a/plugins/example/src/api/ai/actions.ts +++ b/plugins/newsroom/src/api/ai/actions.ts @@ -3,21 +3,21 @@ import { languageName } from "@vitnode/core/api/lib/ai/language-name"; import { checkStaffPermission } from "@vitnode/core/api/lib/check-staff-permission"; import { z } from "zod"; -import { articleContentType } from "@/content/article"; +import { storyContentType } from "@/content/story"; import { manifest } from "@/manifest.gen"; -export const articleExcerptAiAction = defineAiAction({ +export const storyExcerptAiAction = defineAiAction({ authorize: async ({ c }) => await checkStaffPermission(c, { - module: articleContentType.permissionModule, + module: storyContentType.permissionModule, permission: "can_edit", plugin: manifest.id, type: "admin", }), buildPrompt: (input, { instructions }) => ({ - prompt: `Title: ${input.title}\nProduct code: ${input.code}`, + prompt: `Title: ${input.title}\nReference code: ${input.code}`, system: [ - "You write a one-sentence teaser for an article in a demo catalogue.", + "You write a one-sentence teaser for a newsroom story.", `Write it in ${languageName(input.locale)}, under 200 characters.`, "Answer with the teaser only, without quotes.", ...(instructions ? [instructions] : []), @@ -28,10 +28,10 @@ export const articleExcerptAiAction = defineAiAction({ maxOutputTokens: 120, timeoutMs: 20_000, }, - description: "ai_actions.@vitnode/example.article_excerpt.description", + description: "ai_actions.newsroom.story_excerpt.description", icon: "sparkles", - id: "article.excerpt", - title: "ai_actions.@vitnode/example.article_excerpt.title", + id: "story.excerpt", + title: "ai_actions.newsroom.story_excerpt.title", inputSchema: z.object({ code: z.string().max(100), locale: z.string().min(2).max(16), @@ -40,7 +40,7 @@ export const articleExcerptAiAction = defineAiAction({ output: "text", outputSchema: z.string().min(1).max(500), parseText: text => text.trim().replace(/^["“](.*)["”]$/su, "$1"), - permission: { defaultGranted: true, key: "article.excerpt" }, + permission: { defaultGranted: true, key: "story.excerpt" }, promptVersion: 1, requiredCapabilities: ["text"], }); diff --git a/plugins/example/src/api/builders.ts b/plugins/newsroom/src/api/builders.ts similarity index 100% rename from plugins/example/src/api/builders.ts rename to plugins/newsroom/src/api/builders.ts diff --git a/plugins/example/src/api/lib/events.ts b/plugins/newsroom/src/api/lib/events.ts similarity index 70% rename from plugins/example/src/api/lib/events.ts rename to plugins/newsroom/src/api/lib/events.ts index 5cb80ade52..d3289083ae 100644 --- a/plugins/example/src/api/lib/events.ts +++ b/plugins/newsroom/src/api/lib/events.ts @@ -1,11 +1,11 @@ import type { ContentEventsFor } from "@vitnode/core/content"; -import type { articleContentType } from "@/content/article"; import type { categoryContentType } from "@/content/category"; +import type { storyContentType } from "@/content/story"; declare module "@vitnode/core/api/models/events" { interface VitNodeEvents extends - ContentEventsFor<typeof articleContentType>, + ContentEventsFor<typeof storyContentType>, ContentEventsFor<typeof categoryContentType> {} } diff --git a/plugins/example/src/api/lib/moderation.ts b/plugins/newsroom/src/api/lib/moderation.ts similarity index 51% rename from plugins/example/src/api/lib/moderation.ts rename to plugins/newsroom/src/api/lib/moderation.ts index 45ad479ec4..043b1e13f4 100644 --- a/plugins/example/src/api/lib/moderation.ts +++ b/plugins/newsroom/src/api/lib/moderation.ts @@ -1,24 +1,24 @@ import { defineModerationScope } from "@vitnode/core/api/moderation"; import { and, asc, gt, ilike, inArray } from "drizzle-orm"; -import { example_categories } from "@/database/categories"; +import { newsroom_categories } from "@/database/categories"; -export const exampleCategoryScope = defineModerationScope({ - id: "example.category", - label: "@vitnode/example.moderation.category.label", - description: "@vitnode/example.moderation.category.desc", +export const categoryModerationScope = defineModerationScope({ + id: "newsroom.category", + label: "newsroom.moderation.category.label", + description: "newsroom.moderation.category.desc", list: async (c, { cursor, limit, search }) => { const rows = await c .get("db") - .select({ id: example_categories.id, name: example_categories.name }) - .from(example_categories) + .select({ id: newsroom_categories.id, name: newsroom_categories.name }) + .from(newsroom_categories) .where( and( - cursor ? gt(example_categories.id, Number(cursor)) : undefined, - search ? ilike(example_categories.name, `%${search}%`) : undefined, + cursor ? gt(newsroom_categories.id, Number(cursor)) : undefined, + search ? ilike(newsroom_categories.name, `%${search}%`) : undefined, ), ) - .orderBy(asc(example_categories.id)) + .orderBy(asc(newsroom_categories.id)) .limit(limit + 1); const page = rows.slice(0, limit); @@ -34,9 +34,9 @@ export const exampleCategoryScope = defineModerationScope({ const rows = await c .get("db") - .select({ id: example_categories.id, name: example_categories.name }) - .from(example_categories) - .where(inArray(example_categories.id, numeric)); + .select({ id: newsroom_categories.id, name: newsroom_categories.name }) + .from(newsroom_categories) + .where(inArray(newsroom_categories.id, numeric)); return rows.map(row => ({ id: String(row.id), label: row.name })); }, diff --git a/plugins/newsroom/src/api/modules/admin/admin.module.ts b/plugins/newsroom/src/api/modules/admin/admin.module.ts new file mode 100644 index 0000000000..474acb42f1 --- /dev/null +++ b/plugins/newsroom/src/api/modules/admin/admin.module.ts @@ -0,0 +1,36 @@ +import { buildContentAdminModule, buildModule } from "@/api/builders"; +import { deskContainers } from "@/content/desks"; +import { articleContent } from "@/database/articles"; +import { bulletinContent } from "@/database/bulletins"; +import { categoryContent } from "@/database/categories"; +import { commentContent } from "@/database/comments"; +import { eventContent } from "@/database/events"; +import { explainerContent } from "@/database/explainers"; +import { letterContent } from "@/database/letters"; +import { pageContent } from "@/database/pages"; +import { storyContent } from "@/database/stories"; +import { tagContent } from "@/database/tags"; + +export const adminModule = buildModule({ + name: "admin", + routes: [], + modules: [ + buildContentAdminModule({ + containers: [deskContainers], + // Explainers and bulletins get staff routes and permissions here, but no + // AdminCP screen: `src/admin/nav.tsx` does not list them. + contentTypes: [ + articleContent, + categoryContent, + commentContent, + storyContent, + explainerContent, + bulletinContent, + tagContent, + eventContent, + pageContent, + letterContent, + ], + }), + ], +}); diff --git a/plugins/newsroom/src/api/modules/hello/hello.module.test.ts b/plugins/newsroom/src/api/modules/hello/hello.module.test.ts new file mode 100644 index 0000000000..6be2829e9c --- /dev/null +++ b/plugins/newsroom/src/api/modules/hello/hello.module.test.ts @@ -0,0 +1,14 @@ +import { describe, expect, it } from "vitest"; + +import { helloModule } from "./hello.module"; + +describe("hello module", () => { + it("answers GET / with a greeting from the plugin", async () => { + const response = await helloModule.hono.request("/"); + + expect(response.status).toBe(200); + await expect(response.json()).resolves.toEqual({ + message: "Hello from newsroom!", + }); + }); +}); diff --git a/plugins/newsroom/src/api/modules/hello/hello.module.ts b/plugins/newsroom/src/api/modules/hello/hello.module.ts new file mode 100644 index 0000000000..b954fe6291 --- /dev/null +++ b/plugins/newsroom/src/api/modules/hello/hello.module.ts @@ -0,0 +1,8 @@ +import { buildModule } from "@/api/builders"; + +import { helloRoute } from "./hello.route"; + +export const helloModule = buildModule({ + name: "hello", + routes: [helloRoute], +}); diff --git a/plugins/newsroom/src/api/modules/hello/hello.route.ts b/plugins/newsroom/src/api/modules/hello/hello.route.ts new file mode 100644 index 0000000000..aeb822355c --- /dev/null +++ b/plugins/newsroom/src/api/modules/hello/hello.route.ts @@ -0,0 +1,22 @@ +import { z } from "@hono/zod-openapi"; + +import { buildRoute } from "@/api/builders"; +import { manifest } from "@/manifest.gen"; + +export const helloRoute = buildRoute({ + route: { + method: "get", + path: "/", + responses: { + 200: { + content: { + "application/json": { + schema: z.object({ message: z.string() }), + }, + }, + description: "A greeting from the plugin.", + }, + }, + }, + handler: c => c.json({ message: `Hello from ${manifest.id}!` }), +}); diff --git a/plugins/example/src/blocks.tsx b/plugins/newsroom/src/blocks.tsx similarity index 100% rename from plugins/example/src/blocks.tsx rename to plugins/newsroom/src/blocks.tsx diff --git a/plugins/newsroom/src/config.api.ts b/plugins/newsroom/src/config.api.ts new file mode 100644 index 0000000000..478112ed83 --- /dev/null +++ b/plugins/newsroom/src/config.api.ts @@ -0,0 +1,70 @@ +import type { ApiPluginContract } from "@vitnode/core/api/lib/plugin"; + +import { buildApiPlugin } from "@vitnode/core/api/lib/plugin"; + +import { storyExcerptAiAction } from "@/api/ai/actions"; +import { buildContentPublicModule } from "@/api/builders"; +import { categoryModerationScope } from "@/api/lib/moderation"; +import { deskContainers } from "@/content/desks"; +import { settingsPage } from "@/content/settings-page"; +import { articleContent } from "@/database/articles"; +import { bulletinContent } from "@/database/bulletins"; +import { categoryContent } from "@/database/categories"; +import { commentContent } from "@/database/comments"; +import { eventContent } from "@/database/events"; +import { explainerContent } from "@/database/explainers"; +import { letterContent } from "@/database/letters"; +import { pageContent } from "@/database/pages"; +import { storyContent } from "@/database/stories"; +import { tagContent } from "@/database/tags"; +import { manifest } from "@/manifest.gen"; + +import { adminModule } from "./api/modules/admin/admin.module"; +import { helloModule } from "./api/modules/hello/hello.module"; +import { widgets } from "./widgets"; +import "@/api/lib/events"; + +export const newsroomApiPlugin = () => + buildApiPlugin({ + manifest, + aiActions: [storyExcerptAiAction], + blocks: widgets, + editablePages: [settingsPage], + moderation: { scopes: [categoryModerationScope] }, + permissionStaff: { + moderator: { + widgets: ["can_edit"], + }, + }, + modules: [ + helloModule, + adminModule, + buildContentPublicModule({ + containers: [deskContainers], + contentTypes: [ + articleContent, + commentContent, + storyContent, + explainerContent, + bulletinContent, + categoryContent, + tagContent, + eventContent, + pageContent, + letterContent, + ], + }), + ], + }); + +/** + * What an application's generated api-registry.gen.ts imports to make this + * plugin's routes callable through the fetcher. + * + * Reduced here, once, to the module tree and the route definitions - so no app + * that installs this plugin re-derives them, and none of the Hono, database or + * secret code behind the factory is reachable from a browser build. + */ +export type VitNodeApiPlugin = ApiPluginContract< + ReturnType<typeof newsroomApiPlugin> +>; diff --git a/plugins/example/src/config.tsx b/plugins/newsroom/src/config.tsx similarity index 76% rename from plugins/example/src/config.tsx rename to plugins/newsroom/src/config.tsx index a0b6b2dc96..c323cc5535 100644 --- a/plugins/example/src/config.tsx +++ b/plugins/newsroom/src/config.tsx @@ -4,11 +4,11 @@ import messages from "./locales"; import { manifest } from "./manifest.gen"; import { routes } from "./routes"; -export const examplePlugin = () => +export const newsroomPlugin = () => buildPlugin({ manifest, localeFiles: { - en: "@vitnode/example/locales/en.json", + en: "newsroom/locales/en.json", }, messages, routes, diff --git a/plugins/example/src/content.ts b/plugins/newsroom/src/content.ts similarity index 50% rename from plugins/example/src/content.ts rename to plugins/newsroom/src/content.ts index b815c4fc5d..25a23f1835 100644 --- a/plugins/example/src/content.ts +++ b/plugins/newsroom/src/content.ts @@ -1,19 +1,23 @@ -import { advancedArticleContentType } from "@/content/advanced-article"; import { articleContentType } from "@/content/article"; +import { bulletinContentType } from "@/content/bulletin"; import { categoryContentType } from "@/content/category"; -import { discussionContentType } from "@/content/discussion"; +import { commentContentType } from "@/content/comment"; import { eventContentType } from "@/content/event"; -import { localizedArticleContentType } from "@/content/localized-article"; +import { explainerContentType } from "@/content/explainer"; +import { letterContentType } from "@/content/letter"; import { pageContentType } from "@/content/page"; +import { storyContentType } from "@/content/story"; import { tagContentType } from "@/content/tag"; export const contentTypes = [ articleContentType, - advancedArticleContentType, - localizedArticleContentType, categoryContentType, - pageContentType, + commentContentType, + storyContentType, + explainerContentType, + bulletinContentType, tagContentType, eventContentType, - discussionContentType, + pageContentType, + letterContentType, ]; diff --git a/plugins/newsroom/src/content/article.ts b/plugins/newsroom/src/content/article.ts new file mode 100644 index 0000000000..bc4a38442d --- /dev/null +++ b/plugins/newsroom/src/content/article.ts @@ -0,0 +1,51 @@ +import { defineContentType, field } from "@vitnode/core/content"; + +import { categoryContentType } from "@/content/category"; + +export const articleContentType = defineContentType({ + id: "newsroom.article", + tableName: "newsroom_articles", + + fields: { + title: field.text({ required: true, minLength: 3, maxLength: 200 }), + slug: field.slug({ source: "title" }), + body: field.richText({ required: true }), + author: field.user(), + category: field.relation({ + target: () => categoryContentType, + nullable: true, + onDelete: "set null", + }), + }, + + publication: true, + + publicApi: { + path: "news", + fields: ["id", "title", "slug", "body", "category", "publishedAt"], + listFields: ["id", "title", "slug", "publishedAt"], + searchableFields: ["title"], + orderableFields: ["title"], + }, + + delivery: true, + + editorial: true, + + reports: true, + + publicWrite: { + ownership: { field: "author" }, + create: { fields: ["title", "body"], status: "published" }, + update: { fields: ["title", "body"] }, + }, + + admin: { + path: "newsroom/articles", + titleField: "title", + list: { + columns: ["status", "title", "category", "updatedAt"], + orderableFields: ["title"], + }, + }, +}); diff --git a/plugins/example/src/content/localized-article.ts b/plugins/newsroom/src/content/bulletin.ts similarity index 86% rename from plugins/example/src/content/localized-article.ts rename to plugins/newsroom/src/content/bulletin.ts index c14402fe66..fb9bf045d9 100644 --- a/plugins/example/src/content/localized-article.ts +++ b/plugins/newsroom/src/content/bulletin.ts @@ -1,8 +1,8 @@ import { defineContentType, field } from "@vitnode/core/content"; -export const localizedArticleContentType = defineContentType({ - id: "example.localized-article", - tableName: "example_localized_articles", +export const bulletinContentType = defineContentType({ + id: "newsroom.bulletin", + tableName: "newsroom_bulletins", localization: { // `en` has to exist in `core_languages`, which the boot guard checks once per @@ -17,11 +17,11 @@ export const localizedArticleContentType = defineContentType({ }, // The global lifecycle every translation's own status is subordinate to: - // publishing the English copy of a draft article puts nothing on the internet. + // publishing the English copy of a draft bulletin puts nothing on the internet. publication: true, publicApi: { - path: "localized-articles", + path: "bulletins", fields: ["title", "slug", "body", "featured", "publishedAt"], searchableFields: ["title", "body"], orderableFields: ["publishedAt"], @@ -33,7 +33,7 @@ export const localizedArticleContentType = defineContentType({ search: { titleField: "title", contentFields: ["title", "body"], - pathTemplate: "/localized-articles/{slug}", + pathTemplate: "/bulletins/{slug}", }, // Per-locale versions, per-locale revisions, per-locale restore. `retention` is @@ -59,7 +59,7 @@ export const localizedArticleContentType = defineContentType({ slug: field.slug({ localized: true, source: "title" }), body: field.textarea({ localized: true, required: true }), - // Shared: whether an article is featured is a property of the article, not + // Shared: whether a bulletin is featured is a property of the bulletin, not // of the language somebody is reading it in. featured: field.boolean({ defaultValue: false }), }, diff --git a/plugins/newsroom/src/content/category.ts b/plugins/newsroom/src/content/category.ts new file mode 100644 index 0000000000..53b04a73e4 --- /dev/null +++ b/plugins/newsroom/src/content/category.ts @@ -0,0 +1,17 @@ +import { defineContentType, field } from "@vitnode/core/content"; + +export const categoryContentType = defineContentType({ + id: "newsroom.category", + tableName: "newsroom_categories", + + fields: { + name: field.text({ required: true, minLength: 2, maxLength: 80 }), + slug: field.slug({ source: "name" }), + }, + + admin: { + path: "newsroom/categories", + titleField: "name", + list: { columns: ["name", "slug", "updatedAt"] }, + }, +}); diff --git a/plugins/newsroom/src/content/comment.ts b/plugins/newsroom/src/content/comment.ts new file mode 100644 index 0000000000..bd8f5aed60 --- /dev/null +++ b/plugins/newsroom/src/content/comment.ts @@ -0,0 +1,46 @@ +import { defineContentType, field } from "@vitnode/core/content"; + +import { articleContentType } from "@/content/article"; + +/** How deep replies nest. The section hides **Reply** at this depth. */ +export const COMMENT_MAX_DEPTH = 4; + +export const commentContentType = defineContentType({ + id: "newsroom.comment", + tableName: "newsroom_comments", + + fields: { + slug: field.slug({ generated: true }), + body: field.richText({ required: true }), + author: field.user(), + article: field.relation({ + target: () => articleContentType, + required: true, + onDelete: "cascade", + }), + parent: field.relation({ + self: true, + nullable: true, + onDelete: "set null", + }), + }, + + publication: true, + visibility: true, + + publicApi: { + path: "comments", + fields: ["id", "slug", "body", "article", "parent", "publishedAt"], + people: ["author"], + }, + + publicWrite: { + ownership: { field: "author" }, + create: { fields: ["body", "article", "parent"], status: "published" }, + update: { fields: ["body"] }, + }, + + thread: { parent: "parent", root: "article", maxDepth: COMMENT_MAX_DEPTH }, + + admin: { path: "newsroom/comments", titleField: "slug" }, +}); diff --git a/plugins/example/src/content/boards.ts b/plugins/newsroom/src/content/desks.ts similarity index 77% rename from plugins/example/src/content/boards.ts rename to plugins/newsroom/src/content/desks.ts index da79e8da08..fc33656e24 100644 --- a/plugins/example/src/content/boards.ts +++ b/plugins/newsroom/src/content/desks.ts @@ -1,7 +1,7 @@ import { defineContentContainer, field } from "@vitnode/core/content"; -export const boardContainers = defineContentContainer({ - id: "example.boards", +export const deskContainers = defineContentContainer({ + id: "newsroom.desks", fields: { description: field.textarea({ maxLength: 500, nullable: true }), @@ -14,9 +14,9 @@ export const boardContainers = defineContentContainer({ ordering: { mode: "manual" }, publicApi: { - path: "boards", + path: "desks", fields: ["description", "color", "icon"], }, - admin: { path: "example/boards" }, + admin: { path: "newsroom/desks" }, }); diff --git a/plugins/example/src/content/event.ts b/plugins/newsroom/src/content/event.ts similarity index 97% rename from plugins/example/src/content/event.ts rename to plugins/newsroom/src/content/event.ts index 4a4d0c910a..ec84df7062 100644 --- a/plugins/example/src/content/event.ts +++ b/plugins/newsroom/src/content/event.ts @@ -6,8 +6,8 @@ import { categoryContentType } from "./category"; import { tagContentType } from "./tag"; export const eventContentType = defineContentType({ - id: "example.event", - tableName: "example_events", + id: "newsroom.event", + tableName: "newsroom_events", idStrategy: "bigint", localization: { defaultLocale: "en", fallback: "default" }, diff --git a/plugins/example/src/content/advanced-article.ts b/plugins/newsroom/src/content/explainer.ts similarity index 90% rename from plugins/example/src/content/advanced-article.ts rename to plugins/newsroom/src/content/explainer.ts index 0725fb1b26..9807cb34a5 100644 --- a/plugins/example/src/content/advanced-article.ts +++ b/plugins/newsroom/src/content/explainer.ts @@ -3,9 +3,9 @@ import { defineContentType, field } from "@vitnode/core/content"; import { categoryContentType } from "./category"; import { tagContentType } from "./tag"; -export const advancedArticleContentType = defineContentType({ - id: "example.advanced-article", - tableName: "example_advanced_articles", +export const explainerContentType = defineContentType({ + id: "newsroom.explainer", + tableName: "newsroom_explainers", localization: { defaultLocale: "en", @@ -45,9 +45,9 @@ export const advancedArticleContentType = defineContentType({ }), // A self-relation, and an ordered one: "read next" is a sequence somebody - // chose. `onDelete: "cascade"` drops the reference when the target article + // chose. `onDelete: "cascade"` drops the reference when the target explainer // goes, which is the honest analogue of nulling a column here. - relatedArticles: field.relation({ + relatedExplainers: field.relation({ multiple: true, onDelete: "cascade", ordered: true, @@ -67,8 +67,8 @@ export const advancedArticleContentType = defineContentType({ }, }), - // Shared: whether a search engine should index the article, and how - // important it is, are properties of the article rather than of a language. + // Shared: whether a search engine should index the explainer, and how + // important it is, are properties of the explainer rather than of a language. syndication: field.group({ fields: { indexable: field.boolean({ defaultValue: true }), @@ -94,7 +94,7 @@ export const advancedArticleContentType = defineContentType({ }, publicApi: { - path: "advanced-articles", + path: "explainers", fields: [ // Exposed because Stage 8 needs it: alternates and `hreflang` are resolved by // identifier, and delivery reads the public projection - so a localized @@ -125,7 +125,7 @@ export const advancedArticleContentType = defineContentType({ titleField: "title", descriptionField: "seo.description", contentFields: ["title", "seo.description", "faq.question", "faq.answer"], - pathTemplate: "/advanced-articles/{slug}", + pathTemplate: "/explainers/{slug}", }, delivery: { @@ -159,7 +159,7 @@ export const advancedArticleContentType = defineContentType({ form: { // The form *does* carry them: this is the surface where a group renders as // a section and a collection as an editor. - fields: ["categories", "tags", "relatedArticles", "syndication", "faq"], + fields: ["categories", "tags", "relatedExplainers", "syndication", "faq"], }, }, }); diff --git a/plugins/example/src/content/discussion.ts b/plugins/newsroom/src/content/letter.ts similarity index 59% rename from plugins/example/src/content/discussion.ts rename to plugins/newsroom/src/content/letter.ts index a60ef8df11..34e59e1f8b 100644 --- a/plugins/example/src/content/discussion.ts +++ b/plugins/newsroom/src/content/letter.ts @@ -1,17 +1,17 @@ import { defineContentType, field } from "@vitnode/core/content"; -import { boardContainers } from "@/content/boards"; +import { deskContainers } from "@/content/desks"; -export const discussionContentType = defineContentType({ - id: "example.discussion", - tableName: "example_discussions", +export const letterContentType = defineContentType({ + id: "newsroom.letter", + tableName: "newsroom_letters", fields: { title: field.text({ required: true, minLength: 3, maxLength: 200 }), slug: field.slug({ source: "title" }), body: field.richText({ required: true }), author: field.user(), - board: field.container({ definition: boardContainers, required: true }), + desk: field.container({ definition: deskContainers, required: true }), }, publication: true, @@ -20,11 +20,11 @@ export const discussionContentType = defineContentType({ reports: true, publicApi: { - path: "discussions", - fields: ["id", "title", "slug", "body", "board", "publishedAt"], - // A board's discussion list shows titles and dates, never the body. - listFields: ["id", "title", "slug", "board", "publishedAt"], - filterableFields: ["board"], + path: "letters", + fields: ["id", "title", "slug", "body", "desk", "publishedAt"], + // A desk's letter list shows titles and dates, never the body. + listFields: ["id", "title", "slug", "desk", "publishedAt"], + filterableFields: ["desk"], searchableFields: ["title"], orderableFields: ["title"], people: ["author"], @@ -32,19 +32,19 @@ export const discussionContentType = defineContentType({ publicWrite: { ownership: { field: "author" }, - create: { fields: ["title", "body", "board"], status: "published" }, + create: { fields: ["title", "body", "desk"], status: "published" }, update: { fields: ["title", "body"] }, }, admin: { - path: "example/discussions", + path: "newsroom/letters", titleField: "title", list: { columns: [ "status", "title", "slug", - "board", + "desk", "author", "publishedAt", "updatedAt", diff --git a/plugins/example/src/content/page-blocks.ts b/plugins/newsroom/src/content/page-blocks.ts similarity index 74% rename from plugins/example/src/content/page-blocks.ts rename to plugins/newsroom/src/content/page-blocks.ts index 9e6261bd20..b9c6b16165 100644 --- a/plugins/example/src/content/page-blocks.ts +++ b/plugins/newsroom/src/content/page-blocks.ts @@ -1,7 +1,7 @@ export const PAGE_BLOCKS_ALLOWED = [ "core:*", - "example:callout", - "example:features", + "newsroom:callout", + "newsroom:features", ] as const; export const PAGE_SIDEBAR_BLOCKS_ALLOWED = ["core:text"] as const; diff --git a/plugins/example/src/content/page.ts b/plugins/newsroom/src/content/page.ts similarity index 92% rename from plugins/example/src/content/page.ts rename to plugins/newsroom/src/content/page.ts index a7346a557c..c99e6e6950 100644 --- a/plugins/example/src/content/page.ts +++ b/plugins/newsroom/src/content/page.ts @@ -3,8 +3,8 @@ import { defineContentType, field } from "@vitnode/core/content"; import { PAGE_BLOCKS_ALLOWED } from "./page-blocks"; export const pageContentType = defineContentType({ - id: "example.page", - tableName: "example_pages", + id: "newsroom.page", + tableName: "newsroom_pages", fields: { title: field.text({ required: true, minLength: 3, maxLength: 200 }), diff --git a/plugins/example/src/content/settings-page.ts b/plugins/newsroom/src/content/settings-page.ts similarity index 79% rename from plugins/example/src/content/settings-page.ts rename to plugins/newsroom/src/content/settings-page.ts index a80e66011e..9257374563 100644 --- a/plugins/example/src/content/settings-page.ts +++ b/plugins/newsroom/src/content/settings-page.ts @@ -9,14 +9,14 @@ import { const ZONE_BLOCKS_MAX = 20; -export const EXAMPLE_SETTINGS_PAGE_ID = "example:settings"; +export const NEWSROOM_SETTINGS_PAGE_ID = "newsroom:settings"; -export const EXAMPLE_WIDGETS_PERMISSION = { +export const NEWSROOM_WIDGETS_PERMISSION = { module: "widgets", permission: "can_edit", } as const; -export const EXAMPLE_ZONE_IDS = { +export const NEWSROOM_ZONE_IDS = { afterProfile: "after-profile", beforeFooter: "before-footer", beforeProfile: "before-profile", @@ -24,12 +24,12 @@ export const EXAMPLE_ZONE_IDS = { } as const; export const settingsPage = defineEditablePage({ - id: EXAMPLE_SETTINGS_PAGE_ID, + id: NEWSROOM_SETTINGS_PAGE_ID, - permission: EXAMPLE_WIDGETS_PERMISSION, + permission: NEWSROOM_WIDGETS_PERMISSION, zones: { - [EXAMPLE_ZONE_IDS.beforeProfile]: { + [NEWSROOM_ZONE_IDS.beforeProfile]: { allowed: PAGE_BLOCKS_ALLOWED, max: ZONE_BLOCKS_MAX, default: [ @@ -39,8 +39,8 @@ export const settingsPage = defineEditablePage({ title: "This notice lives in a zone", tone: "info", }, - id: "01JEXAMPLEZONESBEFORE00001", - type: "example:callout", + id: "01JNEWSROOMZONESBEFORE00001", + type: "newsroom:callout", }, { data: { @@ -60,8 +60,8 @@ export const settingsPage = defineEditablePage({ title: "Layout", }, }, - id: "01JEXAMPLEZONESBEFORE00002", - type: "example:features", + id: "01JNEWSROOMZONESBEFORE00002", + type: "newsroom:features", variant: "list", }, { @@ -70,13 +70,13 @@ export const settingsPage = defineEditablePage({ heading: "Blocks at the zone root", width: "prose", }, - id: "01JEXAMPLEZONESBEFORE00003", + id: "01JNEWSROOMZONESBEFORE00003", type: "core:text", }, ], }, - [EXAMPLE_ZONE_IDS.afterProfile]: { + [NEWSROOM_ZONE_IDS.afterProfile]: { allowed: PAGE_BLOCKS_ALLOWED, max: ZONE_BLOCKS_MAX, default: [ @@ -88,7 +88,7 @@ export const settingsPage = defineEditablePage({ heading: "Left column", width: "prose", }, - id: "01JEXAMPLEZONESCOL00000001", + id: "01JNEWSROOMZONESCOL00000001", type: "core:text", }, { @@ -98,17 +98,17 @@ export const settingsPage = defineEditablePage({ label: "Read about areas", title: "Right column", }, - id: "01JEXAMPLEZONESCOL00000002", + id: "01JNEWSROOMZONESCOL00000002", type: "core:cta", }, ], - id: "01JEXAMPLEZONESAREA0000001", + id: "01JNEWSROOMZONESAREA0000001", kind: "area", layout: { align: "stretch", columns: 2, gap: 16, justify: "start" }, }, { children: [], - id: "01JEXAMPLEZONESAREA0000002", + id: "01JNEWSROOMZONESAREA0000002", kind: "area", layout: { align: "stretch", columns: 2, gap: 16, justify: "start" }, }, @@ -120,13 +120,13 @@ export const settingsPage = defineEditablePage({ label: "Read the guide", title: "Content Zones", }, - id: "01JEXAMPLEZONESAFTERCTA001", + id: "01JNEWSROOMZONESAFTERCTA001", type: "core:cta", }, ], }, - [EXAMPLE_ZONE_IDS.sidebar]: { + [NEWSROOM_ZONE_IDS.sidebar]: { allowed: PAGE_SIDEBAR_BLOCKS_ALLOWED, max: ZONE_BLOCKS_MAX, default: [ @@ -136,20 +136,20 @@ export const settingsPage = defineEditablePage({ heading: "A narrower zone", width: "prose", }, - id: "01JEXAMPLEZONESSIDEBAR0001", + id: "01JNEWSROOMZONESSIDEBAR0001", type: "core:text", }, ], }, - [EXAMPLE_ZONE_IDS.beforeFooter]: { + [NEWSROOM_ZONE_IDS.beforeFooter]: { allowed: PAGE_BLOCKS_ALLOWED, max: ZONE_BLOCKS_MAX, }, }, }); -export interface ExampleStaffPermissions { +export interface NewsroomStaffPermissions { permissions: readonly { module: string; permission: string; @@ -161,11 +161,11 @@ export interface ExampleStaffPermissions { export const mayEditSettingsPage = ({ permissions, root, -}: ExampleStaffPermissions): boolean => +}: NewsroomStaffPermissions): boolean => root || permissions.some( entry => entry.plugin === manifest.id && - entry.module === EXAMPLE_WIDGETS_PERMISSION.module && - entry.permission === EXAMPLE_WIDGETS_PERMISSION.permission, + entry.module === NEWSROOM_WIDGETS_PERMISSION.module && + entry.permission === NEWSROOM_WIDGETS_PERMISSION.permission, ); diff --git a/plugins/example/src/content/article.ts b/plugins/newsroom/src/content/story.ts similarity index 91% rename from plugins/example/src/content/article.ts rename to plugins/newsroom/src/content/story.ts index dfe6ed0baf..3d76a61707 100644 --- a/plugins/example/src/content/article.ts +++ b/plugins/newsroom/src/content/story.ts @@ -2,20 +2,20 @@ import { defineContentType, field } from "@vitnode/core/content"; import { categoryContentType } from "./category"; -export const articleContentType = defineContentType({ - id: "example.article", - tableName: "example_articles", +export const storyContentType = defineContentType({ + id: "newsroom.story", + tableName: "newsroom_stories", fields: { title: field.text({ required: true, minLength: 3, maxLength: 200 }), // Derived from the title when the payload omits it, and never re-derived - // afterwards - renaming an article does not move its URL. + // afterwards - renaming a story does not move its URL. slug: field.slug({ source: "title" }), // `unique: true` is all it takes to get a unique index in the migration. code: field.text({ required: true, maxLength: 100, unique: true }), excerpt: field.textarea({ ai: { - action: "@vitnode/example:article.excerpt", + action: "newsroom:story.excerpt", mode: "suggestion", sourceFields: ["title", "code"], }, @@ -54,7 +54,7 @@ export const articleContentType = defineContentType({ reports: true, publicApi: { - path: "articles", + path: "stories", fields: [ "title", "slug", @@ -86,7 +86,7 @@ export const articleContentType = defineContentType({ titleField: "title", descriptionField: "excerpt", contentFields: ["title", "excerpt"], - pathTemplate: "/articles/{slug}", + pathTemplate: "/stories/{slug}", authorField: "author", }, @@ -118,9 +118,9 @@ export const articleContentType = defineContentType({ indexes: [{ on: ["status", "createdAt"] }], admin: { - // `/admin/content/example/articles` - plural, like the list it opens. The id + // `/admin/content/stories` - plural, like the list it opens. The id // stays singular because it names one record, and nobody types it. - path: "example/articles", + path: "newsroom/stories", titleField: "title", list: { columns: [ diff --git a/plugins/example/src/content/tag.ts b/plugins/newsroom/src/content/tag.ts similarity index 95% rename from plugins/example/src/content/tag.ts rename to plugins/newsroom/src/content/tag.ts index f9d8246cd8..a56bbea8ac 100644 --- a/plugins/example/src/content/tag.ts +++ b/plugins/newsroom/src/content/tag.ts @@ -3,8 +3,8 @@ import { defineContentType, field } from "@vitnode/core/content"; import { categoryContentType } from "./category"; export const tagContentType = defineContentType({ - id: "example.tag", - tableName: "example_tags", + id: "newsroom.tag", + tableName: "newsroom_tags", idStrategy: "uuid", localization: { defaultLocale: "en", fallback: "default" }, diff --git a/plugins/newsroom/src/database/articles.ts b/plugins/newsroom/src/database/articles.ts new file mode 100644 index 0000000000..699622a61a --- /dev/null +++ b/plugins/newsroom/src/database/articles.ts @@ -0,0 +1,11 @@ +import { createContentModel } from "@vitnode/core/content/server"; + +import { articleContentType } from "@/content/article"; + +import { newsroom_categories } from "./categories"; + +export const articleContent = createContentModel(articleContentType, { + references: { category: () => newsroom_categories.id }, +}); + +export const newsroom_articles = articleContent.table; diff --git a/plugins/newsroom/src/database/bulletins.ts b/plugins/newsroom/src/database/bulletins.ts new file mode 100644 index 0000000000..0639a96ab9 --- /dev/null +++ b/plugins/newsroom/src/database/bulletins.ts @@ -0,0 +1,11 @@ +import { createContentModel } from "@vitnode/core/content/server"; + +import { bulletinContentType } from "@/content/bulletin"; + +export const bulletinContent = createContentModel(bulletinContentType); + +// Two exports for a localized content type, not one. Drizzle Kit discovers each +// table from the export when it globs the built `dist/src/database/*.js`, so the +// translation table needs its own or the migration would be generated without it. +export const newsroom_bulletins = bulletinContent.table; +export const newsroom_bulletins_translations = bulletinContent.translationTable; diff --git a/plugins/newsroom/src/database/categories.ts b/plugins/newsroom/src/database/categories.ts new file mode 100644 index 0000000000..6f5a493f8b --- /dev/null +++ b/plugins/newsroom/src/database/categories.ts @@ -0,0 +1,7 @@ +import { createContentModel } from "@vitnode/core/content/server"; + +import { categoryContentType } from "@/content/category"; + +export const categoryContent = createContentModel(categoryContentType); + +export const newsroom_categories = categoryContent.table; diff --git a/plugins/newsroom/src/database/comments.ts b/plugins/newsroom/src/database/comments.ts new file mode 100644 index 0000000000..269b18bc71 --- /dev/null +++ b/plugins/newsroom/src/database/comments.ts @@ -0,0 +1,11 @@ +import { createContentModel } from "@vitnode/core/content/server"; + +import { commentContentType } from "@/content/comment"; + +import { newsroom_articles } from "./articles"; + +export const commentContent = createContentModel(commentContentType, { + references: { article: () => newsroom_articles.id }, +}); + +export const newsroom_comments = commentContent.table; diff --git a/plugins/newsroom/src/database/events.ts b/plugins/newsroom/src/database/events.ts new file mode 100644 index 0000000000..76302b43a9 --- /dev/null +++ b/plugins/newsroom/src/database/events.ts @@ -0,0 +1,24 @@ +import { createContentModel } from "@vitnode/core/content/server"; + +import { eventContentType } from "@/content/event"; + +import { newsroom_categories } from "./categories"; +import { newsroom_tags } from "./tags"; + +export const eventContent = createContentModel(eventContentType, { + references: { + category: () => newsroom_categories.id, + primaryTag: () => newsroom_tags.id, + tags: () => newsroom_tags.id, + }, +}); + +export const newsroom_events = eventContent.table; +export const newsroom_events_translations = eventContent.translationTable; +export const newsroom_events_tags = eventContent.advancedTables.junctions.tags; +export const newsroom_events_related_events = + eventContent.advancedTables.junctions.relatedEvents; +export const newsroom_events_gallery = + eventContent.advancedTables.junctions.gallery; +export const newsroom_events_sessions = + eventContent.advancedTables.repeatables.sessions; diff --git a/plugins/newsroom/src/database/explainers.ts b/plugins/newsroom/src/database/explainers.ts new file mode 100644 index 0000000000..8ee58ee75a --- /dev/null +++ b/plugins/newsroom/src/database/explainers.ts @@ -0,0 +1,38 @@ +import { createContentModel } from "@vitnode/core/content/server"; + +import { explainerContentType } from "@/content/explainer"; + +import { newsroom_categories } from "./categories"; +import { newsroom_tags } from "./tags"; + +export const explainerContent = createContentModel(explainerContentType, { + references: { + // A to-many relation needs a reference thunk exactly as a to-one does: + // its foreign key is `related_item_id` on the generated junction table + // rather than a column on the row, but the target is just as much a fact + // this module has to supply. + categories: () => newsroom_categories.id, + tags: () => newsroom_tags.id, + // `relatedExplainers` is deliberately absent. It is a `self: true` + // relation, and the engine resolves it from the table it is building: + // writing `() => explainerContent.table.id` here would reference + // the model inside its own initializer, and TypeScript resolves that by + // widening the whole model to `any` - silently taking every typed + // service, schema and column map with it. + }, +}); + +// Six exports, not one. Drizzle Kit discovers each table from its export when +// it globs the built `dist/src/database/*.js`, so a junction or child table +// without one would simply be missing from the migration. +export const newsroom_explainers = explainerContent.table; +export const newsroom_explainers_translations = + explainerContent.translationTable; +export const newsroom_explainers_categories = + explainerContent.advancedTables.junctions.categories; +export const newsroom_explainers_tags = + explainerContent.advancedTables.junctions.tags; +export const newsroom_explainers_related_explainers = + explainerContent.advancedTables.junctions.relatedExplainers; +export const newsroom_explainers_faq = + explainerContent.advancedTables.repeatables.faq; diff --git a/plugins/newsroom/src/database/letters.ts b/plugins/newsroom/src/database/letters.ts new file mode 100644 index 0000000000..e5a6bfad9b --- /dev/null +++ b/plugins/newsroom/src/database/letters.ts @@ -0,0 +1,7 @@ +import { createContentModel } from "@vitnode/core/content/server"; + +import { letterContentType } from "@/content/letter"; + +export const letterContent = createContentModel(letterContentType); + +export const newsroom_letters = letterContent.table; diff --git a/plugins/example/src/database/pages.ts b/plugins/newsroom/src/database/pages.ts similarity index 78% rename from plugins/example/src/database/pages.ts rename to plugins/newsroom/src/database/pages.ts index c59acad8ed..04557ba1ff 100644 --- a/plugins/example/src/database/pages.ts +++ b/plugins/newsroom/src/database/pages.ts @@ -4,4 +4,4 @@ import { pageContentType } from "@/content/page"; export const pageContent = createContentModel(pageContentType); -export const example_pages = pageContent.table; +export const newsroom_pages = pageContent.table; diff --git a/plugins/example/src/database/articles.ts b/plugins/newsroom/src/database/stories.ts similarity index 61% rename from plugins/example/src/database/articles.ts rename to plugins/newsroom/src/database/stories.ts index 97c6a8012d..2040987234 100644 --- a/plugins/example/src/database/articles.ts +++ b/plugins/newsroom/src/database/stories.ts @@ -1,10 +1,10 @@ import { createContentModel } from "@vitnode/core/content/server"; -import { articleContentType } from "@/content/article"; +import { storyContentType } from "@/content/story"; -import { example_categories } from "./categories"; +import { newsroom_categories } from "./categories"; -export const articleContent = createContentModel(articleContentType, { +export const storyContent = createContentModel(storyContentType, { // One thunk per `relation` field - a missing or extra key is a compile error, // and the thunk keeps circular content type references safe. // @@ -12,13 +12,13 @@ export const articleContent = createContentModel(articleContentType, { // is one files table in an installation, so the engine resolves // `core_files.id` itself and asking a plugin to name it would be a line of // boilerplate with one correct value. - references: { category: () => example_categories.id }, - moderationScope: { column: "category", family: "example.category" }, + references: { category: () => newsroom_categories.id }, + moderationScope: { column: "category", family: "newsroom.category" }, }); -export const example_articles = articleContent.table; +export const newsroom_stories = storyContent.table; // Two exports, not one. Drizzle Kit discovers each table from its export when it // globs the built `dist/src/database/*.js`, so the gallery's junction table // without one would simply be missing from the migration. -export const example_articles_gallery = - articleContent.advancedTables.junctions.gallery; +export const newsroom_stories_gallery = + storyContent.advancedTables.junctions.gallery; diff --git a/plugins/newsroom/src/database/tags.ts b/plugins/newsroom/src/database/tags.ts new file mode 100644 index 0000000000..d6b0a8782c --- /dev/null +++ b/plugins/newsroom/src/database/tags.ts @@ -0,0 +1,14 @@ +import { createContentModel } from "@vitnode/core/content/server"; + +import { tagContentType } from "@/content/tag"; + +import { newsroom_categories } from "./categories"; + +export const tagContent = createContentModel(tagContentType, { + references: { category: () => newsroom_categories.id }, +}); + +export const newsroom_tags = tagContent.table; +export const newsroom_tags_translations = tagContent.translationTable; +export const newsroom_tags_related_tags = + tagContent.advancedTables.junctions.relatedTags; diff --git a/plugins/newsroom/src/locales/en.json b/plugins/newsroom/src/locales/en.json new file mode 100644 index 0000000000..63b3a0d140 --- /dev/null +++ b/plugins/newsroom/src/locales/en.json @@ -0,0 +1,540 @@ +{ + "newsroom": { + "home": { + "api": "Your plugin's API answered:", + "desc": "This page ships inside the plugin and is served by the app that installed it.", + "title": "Hello from your plugin" + }, + "title": "Newsroom", + "content": { + "article": { + "label": "{count, plural, one {Article} other {Articles}}", + "title": "Articles", + "desc": "News and updates for your community.", + "fields": { + "title": "Title", + "slug": "Slug", + "body": "Body", + "updatedAt": "Updated", + "status": "Status", + "author": "Author", + "category": "Category" + } + }, + "category": { + "label": "{count, plural, one {Category} other {Categories}}", + "title": "Categories", + "desc": "Sections that group the articles.", + "fields": { + "name": "Name", + "slug": "Slug", + "updatedAt": "Updated", + "createdAt": "Created" + } + }, + "comment": { + "label": "{count, plural, one {Comment} other {Comments}}", + "title": "Comments", + "desc": "What readers say under the articles.", + "fields": { + "slug": "Link", + "body": "Comment", + "author": "Author", + "article": "Article", + "parent": "Answers" + } + }, + "story": { + "label": "{count, plural, one {Story} other {Stories}}", + "title": "Stories", + "desc": "Reported stories with files, scheduling, live editing and SEO.", + "fields": { + "title": "Title", + "code": "Reference code", + "excerpt": "Excerpt", + "views": "Views", + "featured": "Featured", + "animation": "Animation", + "author": "Author", + "category": "Category", + "updatedAt": "Updated" + } + }, + "tag": { + "label": "{count, plural, one {Tag} other {Tags}}", + "title": "Tags", + "desc": "Keyed by uuid, so their ids give nothing away.", + "fields": { + "name": "Name", + "slug": "Slug", + "category": "Category", + "relatedTags": "Related tags", + "updatedAt": "Updated" + } + }, + "event": { + "label": "{count, plural, one {Event} other {Events}}", + "title": "Events", + "desc": "Keyed by bigint. Ids too large for a JavaScript number arrive as exact strings.", + "fields": { + "title": "Title", + "slug": "Slug", + "body": "Description", + "primaryTag": "Main tag", + "category": "Category", + "tags": "Tags", + "relatedEvents": "Related events", + "sessions": "Sessions", + "gallery": "Gallery", + "updatedAt": "Updated" + } + }, + "letter": { + "label": "{count, plural, one {Letter} other {Letters}}", + "title": "Letters", + "desc": "Letters to the editor, written and edited by signed-in members.", + "fields": { + "title": "Title", + "slug": "Slug", + "body": "Letter", + "author": "Author", + "publishedAt": "Published", + "updatedAt": "Updated", + "desk": "Desk" + } + }, + "page": { + "label": "{count, plural, one {Page} other {Pages}}", + "title": "Pages", + "desc": "Pages built from blocks.", + "fields": { + "title": "Title", + "slug": "Slug", + "content": "Content" + } + } + }, + "articles": { + "title": "News", + "desc": "The latest articles from our team.", + "empty": "No articles yet. Check back soon.", + "error": "The articles could not be loaded. Try again in a moment.", + "pagination": "Article pages", + "form": { + "title": "Title", + "body": "Article" + }, + "new": { + "title": "Write an article", + "submit": "Publish article", + "success": { + "title": "Article published", + "desc": "Readers can see it now." + }, + "draft": { + "title": "Draft saved", + "desc": "It is not public yet, so only you and the site's staff can see it." + }, + "pending": { + "title": "Sent for approval", + "desc": "A moderator reviews it before readers can see it." + } + }, + "edit": { + "title": "Edit article", + "submit": "Save changes", + "view": "View article", + "success": { + "title": "Article saved", + "desc": "\"{title}\" is up to date." + } + }, + "state": { + "unauthenticated": { + "title": "Sign in to edit", + "action": "Sign in" + }, + "forbidden": { + "title": "You can't edit this article", + "desc": "Only its author and moderators can edit it." + }, + "not_found": { + "title": "Article not found", + "desc": "It does not exist, or you cannot see it." + } + }, + "edit_link": "Edit", + "report_type": "article", + "search_label": "Search articles", + "sort_label": "Sort articles", + "sort": { + "newest": "Newest first", + "oldest": "Oldest first", + "title": "Title A–Z" + }, + "no_results": "No articles match your search.", + "latest": { + "title": "Latest articles", + "all": "All news", + "empty": "No other articles yet.", + "error": "The latest articles could not be loaded.", + "loading": "Loading the latest articles" + } + }, + "comments": { + "title": "{count, plural, =0 {Comments} one {# comment} other {# comments}}", + "empty": "No comments yet. Be the first.", + "loading": "Loading comments", + "retry": "Try again", + "load_more": "Load more comments", + "load_failed": { + "title": "Could not load comments", + "desc": "Check your connection and try again." + }, + "sign_in": "Sign in to comment", + "form": { + "label": "Your comment", + "submit": "Post comment", + "required": "Write a comment first.", + "edit_label": "Edit your comment", + "save": "Save", + "cancel": "Cancel", + "reply_label": "Reply to {name}" + }, + "created": { + "title": "Comment posted", + "desc": "Your comment is below the article." + }, + "draft": { + "title": "Comment saved as a draft", + "desc": "It is not public yet, so it does not appear below the article." + }, + "pending": { + "title": "Your comment is waiting for a moderator", + "desc": "A moderator reviews it before readers can see it." + }, + "someone": "Deleted user", + "edit": "Edit", + "edited": { + "title": "Comment saved", + "desc": "Your changes are live." + }, + "edit_pending": { + "title": "Your changes are waiting for a moderator", + "desc": "Readers see the earlier text until a moderator approves." + }, + "reply": "Reply", + "removed": "This comment was removed.", + "truncated": "This conversation is long, so some deeper replies are not shown." + }, + "admin": { + "nav": { + "overview": "Overview" + }, + "overview": { + "title": "Newsroom", + "desc": "A plugin page inside the AdminCP, shipped by the newsroom plugin and served by the app that installed it.", + "points": { + "shell": "The sidebar, the breadcrumb and the admin session guard around this page are the AdminCP's - the route declared area: \"admin\" and nothing else.", + "path": "The URL is the one the route spells out in full. An area chooses the frame; it never adds a prefix.", + "nav": "The sidebar entry that led here is a separate declaration. A plugin page may have no entry, and an entry may point anywhere." + }, + "editor": { + "title": "Editor showcase", + "desc": "Stored editor HTML rendered with EditorContent - one of everything, so a change to the tiptap extensions or to the .tiptap styles shows up here first." + }, + "form": { + "title": "Editor in a form", + "desc": "AutoFormEditor inside an AutoForm. Type / for blocks, use + for panels and tables, and press Ctrl or Cmd + Enter to save.", + "summary": { + "label": "Summary" + }, + "description": { + "label": "Description", + "desc": "Steps, logs and acceptance criteria. Nothing is stored - this form only shows what the editor sends.", + "required": "Describe what happened" + }, + "comment": { + "label": "Comment", + "placeholder": "Leave a note for the assignee…" + }, + "submit": "Save issue", + "success": { + "title": "Issue saved", + "desc": "{words, plural, one {# word} other {# words}} in the description." + } + } + } + }, + "containers": { + "desks": { + "label": "{count, plural, one {Desk} other {Desks}}", + "title": "Desks", + "desc": "The desks readers send their letters to. Drag to reorder or nest desks.", + "fields": { + "description": "Description", + "color": "Color", + "icon": "Icon", + "locked": "Locked" + } + } + }, + "guide": { + "title": "Plugin routing guide", + "desc": "A nested route shipped by the newsroom plugin, served by the app that installed it.", + "index": { + "intro": "This page and the frame around it are two routes declared in one plugin's routes.ts. Neither was copied into the application - both are lazily imported from the plugin's own build output.", + "points": { + "nested": "A layout claims no URL; this index page claims the layout's.", + "messages": "The layout declares the messages both pages render in, so they are warmed alongside the code.", + "breadcrumb": "The breadcrumb above comes from the layout, next to the component that needs it." + } + }, + "topic": { + "from_index": "You arrived here from the guide index - the route's search contract recognised it, and everything else in the query string was dropped." + } + }, + "browse": { + "title": "Browse", + "desc": "A paginated page whose route declares an eager search schema, so ?page=999 is clamped before anything renders.", + "pagination": "Pagination", + "previous": "Previous", + "next": "Next", + "page": "Page {page} of {of}" + }, + "events": { + "sessions": "Sessions" + }, + "moderation": { + "category": { + "label": "Categories", + "desc": "Limit this permission to stories in some categories." + } + }, + "desks": { + "title": "Desks", + "desc": "Pick the desk that fits your letter, or write a new one.", + "tree": "All desks", + "children": "Desks inside", + "letters": "Letters", + "new_letter": "Write a letter", + "pagination": "Letter pages", + "newest": "Newest letters", + "older": "Older letters", + "empty": { + "title": "No desks yet", + "desc": "An admin has not created any desks. Once they do, they show up here.", + "home": "Back to home" + }, + "empty_desk": { + "title": "Nothing here yet", + "desc": "Be the first to write to this desk." + }, + "not_found": { + "title": "Desk not found", + "desc": "It may have been removed, or the link is wrong.", + "back": "See all desks" + }, + "latest": { + "title": "Latest letters", + "all": "All letters", + "empty": "Nothing posted yet.", + "error": "Couldn't load the latest letters.", + "loading": "Loading letters..." + } + }, + "letters": { + "new": { + "title": "Write a letter", + "desc": "Share a question, a correction or an idea with the newsroom. Everyone can read it as soon as you send it.", + "submit": "Send letter", + "no_desks": { + "title": "No desks yet", + "desc": "Letters go to a desk, and an admin has not created one yet. Check back soon.", + "action": "Browse desks" + } + }, + "edit": { + "title": "Edit letter", + "desc": "Changes are saved as a new revision, so nothing you replace is lost.", + "submit": "Save changes", + "view": "View letter", + "success": { + "title": "Changes saved", + "desc": "\"{title}\" now shows your latest edits." + } + }, + "detail": { + "actions": "Letter actions", + "edit": "Edit", + "new": "Write a letter", + "created": { + "title": "Letter sent", + "desc": "It is live. Use Edit whenever you want to change it." + }, + "desk": "Desk", + "desks": "Desks", + "posted_by": "Posted by", + "deleted_user": "Deleted user", + "report_type": "letter", + "hidden": { + "title": "This letter is hidden", + "desc": "A moderator hid it, so readers can't see it. You can, because you wrote it or moderate this desk." + } + }, + "form": { + "desk": { + "label": "Desk", + "desc": "Pick where the letter belongs. You can't move it later.", + "placeholder": "Choose a desk", + "required": "Pick a desk" + }, + "title": { + "label": "Title", + "min": "Use at least 3 characters", + "max": "Keep it under 200 characters" + }, + "body": { + "label": "Letter", + "desc": "Type / for headings, lists and more.", + "required": "Write your letter" + } + }, + "state": { + "unauthenticated": { + "title": "Sign in to continue", + "desc": "You need an account to write or edit a letter.", + "action": "Sign in" + }, + "forbidden": { + "title": "You can't edit this letter", + "desc": "Only its author or a moderator can change it." + }, + "not_found": { + "title": "Letter not found", + "desc": "It may have been removed, or the link is wrong." + }, + "back": "Write a new letter" + }, + "list": { + "title": "Letters", + "desc": "Questions, corrections and ideas from readers, newest first.", + "new": "Write a letter", + "search_label": "Search letters", + "search_placeholder": "Search by title", + "desk_label": "Desk", + "all_desks": "All desks", + "sort_label": "Sort by", + "sort": { + "newest": "Newest", + "oldest": "Oldest", + "title": "Title (A-Z)" + }, + "pagination": "Letter pages", + "deleted_user": "Deleted user", + "empty": { + "title": "No letters yet", + "desc": "Be the first to write to the newsroom." + }, + "no_results": { + "title": "No letters match", + "desc": "Try another search term or desk.", + "clear": "Clear filters" + }, + "error": { + "title": "Couldn't load letters", + "desc": "Something went wrong on our side. Refresh the page to try again." + } + } + }, + "stories": { + "published": "Published", + "faq": "Frequently asked questions" + } + }, + "newsroom:article": "Articles", + "newsroom:article:can_view": "View articles", + "newsroom:article:can_create": "Create articles", + "newsroom:article:can_edit": "Edit articles", + "newsroom:article:can_delete": "Delete articles", + "newsroom:article:can_publish": "Publish and unpublish articles", + "newsroom:category": "Categories", + "newsroom:category:can_view": "View categories", + "newsroom:category:can_create": "Create categories", + "newsroom:category:can_edit": "Edit categories", + "newsroom:category:can_delete": "Delete categories", + "newsroom:comment": "Comments", + "newsroom:comment:can_view": "View comments", + "newsroom:comment:can_create": "Create comments", + "newsroom:comment:can_edit": "Edit comments", + "newsroom:comment:can_delete": "Delete comments", + "newsroom:comment:can_publish": "Publish and unpublish comments", + "newsroom:comment:can_hide": "Hide and unhide comments", + "newsroom:story": "Stories", + "newsroom:story:can_view": "View stories", + "newsroom:story:can_create": "Create stories", + "newsroom:story:can_edit": "Edit stories", + "newsroom:story:can_delete": "Delete stories", + "newsroom:story:can_publish": "Publish and unpublish stories", + "newsroom:story:can_restore": "Restore an earlier version of a story", + "newsroom:tag": "Tags", + "newsroom:tag:can_view": "View tags", + "newsroom:tag:can_create": "Create tags", + "newsroom:tag:can_edit": "Edit tags", + "newsroom:tag:can_delete": "Delete tags", + "newsroom:tag:can_publish": "Publish and unpublish tags", + "newsroom:tag:can_restore": "Restore an earlier version of a tag", + "newsroom:tag:can_hide": "Hide and unhide tags", + "newsroom:event": "Events", + "newsroom:event:can_view": "View events", + "newsroom:event:can_create": "Create events", + "newsroom:event:can_edit": "Edit events", + "newsroom:event:can_delete": "Delete events", + "newsroom:event:can_publish": "Publish and unpublish events", + "newsroom:event:can_restore": "Restore an earlier version of an event", + "newsroom:event:can_hide": "Hide and unhide events", + "newsroom:letter": "Letters", + "newsroom:letter:can_view": "View letters", + "newsroom:letter:can_create": "Create letters", + "newsroom:letter:can_edit": "Edit letters", + "newsroom:letter:can_delete": "Delete letters", + "newsroom:letter:can_publish": "Publish and unpublish letters", + "newsroom:letter:can_restore": "Restore an earlier version of a letter", + "newsroom:letter:can_hide": "Hide and unhide letters", + "newsroom:newsroom_desks": "Desks", + "newsroom:newsroom_desks:can_view": "View desks", + "newsroom:newsroom_desks:can_create": "Create desks", + "newsroom:newsroom_desks:can_edit": "Edit and move desks", + "newsroom:newsroom_desks:can_delete": "Delete desks", + "ai_actions": { + "newsroom": { + "story_excerpt": { + "title": "Suggest story teaser", + "description": "Suggests a teaser for a newsroom story." + } + } + }, + "newsroom:article:can_restore": "Restore an earlier version of an article", + "newsroom:explainer": "Explainers", + "newsroom:explainer:can_view": "View explainers", + "newsroom:explainer:can_create": "Create explainers", + "newsroom:explainer:can_edit": "Edit explainers", + "newsroom:explainer:can_delete": "Delete explainers", + "newsroom:explainer:can_publish": "Publish and unpublish explainers", + "newsroom:explainer:can_restore": "Restore an earlier version of an explainer", + "newsroom:bulletin": "Bulletins", + "newsroom:bulletin:can_view": "View bulletins", + "newsroom:bulletin:can_create": "Create bulletins", + "newsroom:bulletin:can_edit": "Edit bulletins", + "newsroom:bulletin:can_delete": "Delete bulletins", + "newsroom:bulletin:can_publish": "Publish and unpublish bulletins", + "newsroom:bulletin:can_restore": "Restore an earlier version of a bulletin", + "newsroom:page": "Pages", + "newsroom:page:can_view": "View pages", + "newsroom:page:can_create": "Create pages", + "newsroom:page:can_edit": "Edit pages", + "newsroom:page:can_delete": "Delete pages", + "newsroom:page:can_publish": "Publish and unpublish pages", + "newsroom:widgets": "Widgets", + "newsroom:widgets:can_edit": "Edit page widgets" +} diff --git a/plugins/example/src/locales/index.ts b/plugins/newsroom/src/locales/index.ts similarity index 59% rename from plugins/example/src/locales/index.ts rename to plugins/newsroom/src/locales/index.ts index 6e61a48826..f8b7839303 100644 --- a/plugins/example/src/locales/index.ts +++ b/plugins/newsroom/src/locales/index.ts @@ -1,9 +1,5 @@ import type { LocaleMessagesMap } from "@vitnode/core/lib/i18n/types"; -/** - * Every language this plugin ships. Add a file next to this one and a line - * here to add another; apps pick it up with no copy step. - */ const messages: LocaleMessagesMap = { en: async () => await import("./en.json", { with: { type: "json" } }), }; diff --git a/plugins/example/src/manifest.gen.ts b/plugins/newsroom/src/manifest.gen.ts similarity index 65% rename from plugins/example/src/manifest.gen.ts rename to plugins/newsroom/src/manifest.gen.ts index 9c388e423e..a23a415325 100644 --- a/plugins/example/src/manifest.gen.ts +++ b/plugins/newsroom/src/manifest.gen.ts @@ -11,16 +11,12 @@ import { pluginInfo } from "./plugin-info" export const manifest = { - id: "@vitnode/example", - title: pluginInfo.title ?? "@vitnode/example", + id: "newsroom", + title: pluginInfo.title ?? "newsroom", icon: pluginInfo.icon ?? null, - description: "Reference plugin exercising the VitNode Content Engine end to end.", - author: { - name: "VitNode", - email: "contact@vitnode.com", - url: "https://vitnode.com", - }, - license: "MIT", - version: "2.0.0-canary.13", + description: "Newsroom: the VitNode tutorial plugin, grown into a tour of the Content Engine.", + author: null, + license: null, + version: "0.1.0", updates: pluginInfo.updates ?? null, } as const diff --git a/plugins/example/src/pages/admin-example-editor-form.tsx b/plugins/newsroom/src/pages/admin-editor-form.tsx similarity index 93% rename from plugins/example/src/pages/admin-example-editor-form.tsx rename to plugins/newsroom/src/pages/admin-editor-form.tsx index 742bf2a9ae..8bcbdf3646 100644 --- a/plugins/example/src/pages/admin-example-editor-form.tsx +++ b/plugins/newsroom/src/pages/admin-editor-form.tsx @@ -14,8 +14,8 @@ const SAMPLE_DESCRIPTION = ` <ul data-type="taskList"><li data-type="taskItem" data-checked="true"><p>Checkout falls back to the undiscounted total on a timeout</p></li><li data-type="taskItem" data-checked="false"><p>The customer sees an error with a retry button instead of a blank page</p></li></ul> `; -export const AdminExampleEditorForm = () => { - const t = useTranslations("@vitnode/example.admin.overview.form"); +export const AdminEditorForm = () => { + const t = useTranslations("newsroom.admin.overview.form"); const formSchema = z.object({ summary: z .string() @@ -31,13 +31,13 @@ export const AdminExampleEditorForm = () => { return ( <section - aria-labelledby="admin-example-editor-form" + aria-labelledby="admin-editor-form" className="flex flex-col gap-4" > <header className="flex flex-col gap-2"> <h2 className="text-xl font-semibold tracking-tight text-balance" - id="admin-example-editor-form" + id="admin-editor-form" > {t("title")} </h2> diff --git a/plugins/example/src/pages/admin-example-page.tsx b/plugins/newsroom/src/pages/admin-overview-page.tsx similarity index 82% rename from plugins/example/src/pages/admin-example-page.tsx rename to plugins/newsroom/src/pages/admin-overview-page.tsx index 7f3442ab3e..b0307867c3 100644 --- a/plugins/example/src/pages/admin-example-page.tsx +++ b/plugins/newsroom/src/pages/admin-overview-page.tsx @@ -3,11 +3,11 @@ import { EditorContent } from "@vitnode/core/components/ui/editor-content"; import { definePluginRoute } from "@vitnode/core/routing"; import { useTranslations } from "use-intl"; -import { AdminExampleEditorForm } from "./admin-example-editor-form"; +import { AdminEditorForm } from "./admin-editor-form"; import { editorShowcaseHtml } from "./editor-showcase"; -const AdminExamplePage = () => { - const t = useTranslations("@vitnode/example.admin.overview"); +const AdminOverviewPage = () => { + const t = useTranslations("newsroom.admin.overview"); const { emojis } = useEditorConfig(); return ( @@ -42,23 +42,23 @@ const AdminExamplePage = () => { </div> </section> - <AdminExampleEditorForm /> + <AdminEditorForm /> </div> ); }; -const AdminExampleBreadcrumb = () => { - const t = useTranslations("@vitnode/example.admin.overview"); +const AdminOverviewBreadcrumb = () => { + const t = useTranslations("newsroom.admin.overview"); return <span>{t("title")}</span>; }; export const route = definePluginRoute({ - breadcrumb: AdminExampleBreadcrumb, + breadcrumb: AdminOverviewBreadcrumb, // Inherited from the host's `_admin` route, which declares it once for the // whole panel - restated here because a plugin cannot rely on an application // having done so, and an AdminCP page must never be indexed. head: () => ({ robots: "noindex, nofollow" }), }); -export default AdminExamplePage; +export default AdminOverviewPage; diff --git a/plugins/newsroom/src/pages/article-edit-page.tsx b/plugins/newsroom/src/pages/article-edit-page.tsx new file mode 100644 index 0000000000..b89b4947c6 --- /dev/null +++ b/plugins/newsroom/src/pages/article-edit-page.tsx @@ -0,0 +1,190 @@ +import type { AutoFormOnSubmit } from "@vitnode/core/components/form/auto-form"; + +import { useQueryClient } from "@tanstack/react-query"; +import { buttonVariants } from "@vitnode/core/components/ui/button"; +import { + definePluginRoute, + type PluginRoutePageProps, +} from "@vitnode/core/routing"; +import { useContentPublicWrite } from "@vitnode/core/tanstack/content"; +import { fetcher } from "@vitnode/core/tanstack/fetcher"; +import { HrefLink } from "@vitnode/core/tanstack/link"; +import { useState } from "react"; +import { toast } from "sonner"; +import { useTranslations } from "use-intl"; + +import { manifest } from "@/manifest.gen"; + +import { + ARTICLE_FORM_FIELDS, + ArticleForm, + type ArticleFormSchema, +} from "./article-form"; + +// Answers only for the author and moderators, drafts included. +const fetchEditableArticle = async (id: number) => + await fetcher({ + plugin: manifest.id, + method: "get", + module: "content/news", + path: "/{id}/edit", + args: { params: { id } }, + }); + +const loadEditableArticle = async (param: string) => { + const id = Number(param); + if (!Number.isSafeInteger(id) || id < 1) + return { state: "notFound" as const }; + + const res = await fetchEditableArticle(id); + if (res.status === 200) { + return { record: await res.json(), state: "ready" as const }; + } + if (res.status === 401) return { id, state: "unauthenticated" as const }; + if (res.status === 403) return { state: "forbidden" as const }; + if (res.status === 404) return { state: "notFound" as const }; + + // A server failure is not "not found": let the app's error page show it. + throw new Error(`Loading article ${id} for editing failed.`); +}; + +type ArticleEditData = Awaited<ReturnType<typeof loadEditableArticle>>; + +type EditableArticle = Extract<ArticleEditData, { state: "ready" }>["record"]; + +export const route = definePluginRoute({ + load: async ({ params }) => await loadEditableArticle(params.id), + + head: ({ t }) => ({ + robots: "noindex, nofollow", + title: t("newsroom.articles.edit.title"), + }), +}); + +const ArticleEditForm = ({ record }: { record: EditableArticle }) => { + const t = useTranslations("newsroom.articles.edit"); + const queryClient = useQueryClient(); + const [current, setCurrent] = useState({ + slug: record.slug, + status: record.status, + }); + // AutoForm resets an untouched form to the defaults it renders with, so the + // defaults follow every save. + const [defaults, setDefaults] = useState({ + body: record.values.body, + title: record.values.title, + }); + const write = useContentPublicWrite({ + fields: ARTICLE_FORM_FIELDS, + latestVersion: async () => { + const res = await fetchEditableArticle(record.id); + + return res.status === 200 ? (await res.json()).version : null; + }, + uniqueField: "title", + version: record.version, + }); + + const onSubmit: AutoFormOnSubmit<ArticleFormSchema> = async ( + values, + form, + ) => { + const updated = await write.submit( + form, + async expectedVersion => + await fetcher({ + plugin: manifest.id, + method: "patch", + module: "content/news", + path: "/{id}", + args: { + params: { id: record.id }, + body: { values, expectedVersion }, + }, + }), + ); + if (!updated) return; + + setCurrent({ slug: updated.slug, status: updated.status }); + setDefaults(values); + form.reset(values); + await queryClient.invalidateQueries({ + queryKey: [manifest.id, "articles"], + }); + + toast.success(t("success.title"), { + description: t("success.desc", { title: values.title }), + }); + }; + + return ( + <> + {write.conflict} + <ArticleForm + defaultValues={defaults} + onSubmit={onSubmit} + submitLabel={t("submit")} + /> + {current.status === "published" && current.slug !== null ? ( + <HrefLink + className={buttonVariants({ + className: "self-start", + variant: "link", + })} + href={`/news/${current.slug}`} + > + {t("view")} + </HrefLink> + ) : null} + </> + ); +}; + +const ArticleEditPage = ({ + loaderData, + params, +}: PluginRoutePageProps<ArticleEditData>) => { + const t = useTranslations("newsroom.articles"); + + if (loaderData.state === "unauthenticated") { + return ( + <div className="container mx-auto flex max-w-3xl flex-col gap-4 px-4 py-8"> + <h1 className="text-2xl font-semibold text-balance"> + {t("state.unauthenticated.title")} + </h1> + <HrefLink + className={buttonVariants({ className: "self-start" })} + href={`/login?returnTo=${encodeURIComponent(`/news/${loaderData.id}/edit`)}`} + > + {t("state.unauthenticated.action")} + </HrefLink> + </div> + ); + } + + if (loaderData.state !== "ready") { + const key = loaderData.state === "forbidden" ? "forbidden" : "not_found"; + + return ( + <div className="container mx-auto flex max-w-3xl flex-col gap-2 px-4 py-8"> + <h1 className="text-2xl font-semibold text-balance"> + {t(`state.${key}.title`)} + </h1> + <p className="text-muted-foreground leading-relaxed"> + {t(`state.${key}.desc`)} + </p> + </div> + ); + } + + return ( + <div className="container mx-auto flex max-w-3xl flex-col gap-6 px-4 py-8"> + <h1 className="text-3xl font-semibold tracking-tight text-balance"> + {t("edit.title")} + </h1> + <ArticleEditForm key={params.id} record={loaderData.record} /> + </div> + ); +}; + +export default ArticleEditPage; diff --git a/plugins/newsroom/src/pages/article-form.tsx b/plugins/newsroom/src/pages/article-form.tsx new file mode 100644 index 0000000000..8e630b2860 --- /dev/null +++ b/plugins/newsroom/src/pages/article-form.tsx @@ -0,0 +1,63 @@ +import { + AutoForm, + type AutoFormOnSubmit, +} from "@vitnode/core/components/form/auto-form"; +import { AutoFormEditor } from "@vitnode/core/components/form/fields/editor"; +import { AutoFormInput } from "@vitnode/core/components/form/fields/input"; +import { + isRichTextEmpty, + type RichTextDocument, + zodRichTextDocument, +} from "@vitnode/core/content"; +import { useTranslations } from "use-intl"; +import { z } from "zod"; + +/** The fields the form renders, so server errors on them show in place. */ +export const ARTICLE_FORM_FIELDS = ["title", "body"] as const; + +export interface ArticleFormValues { + body: RichTextDocument; + title: string; +} + +const createArticleFormSchema = (defaults: ArticleFormValues) => + z.object({ + title: z.string().trim().min(3).max(200).default(defaults.title), + body: zodRichTextDocument + .refine(doc => !isRichTextEmpty(doc)) + .default(defaults.body), + }); + +export type ArticleFormSchema = ReturnType<typeof createArticleFormSchema>; + +export const ArticleForm = ({ + defaultValues, + onSubmit, + submitLabel, +}: { + defaultValues: ArticleFormValues; + onSubmit: AutoFormOnSubmit<ArticleFormSchema>; + submitLabel: string; +}) => { + const t = useTranslations("newsroom.articles.form"); + + return ( + <AutoForm + fields={[ + { + id: "title", + component: props => <AutoFormInput {...props} label={t("title")} />, + }, + { + id: "body", + component: props => ( + <AutoFormEditor {...props} format="json" label={t("body")} /> + ), + }, + ]} + formSchema={createArticleFormSchema(defaultValues)} + onSubmit={onSubmit} + submitButtonProps={{ children: submitLabel, className: "self-start" }} + /> + ); +}; diff --git a/plugins/newsroom/src/pages/article-list.ts b/plugins/newsroom/src/pages/article-list.ts new file mode 100644 index 0000000000..0ab834ff18 --- /dev/null +++ b/plugins/newsroom/src/pages/article-list.ts @@ -0,0 +1,47 @@ +import type { ContentListSearch } from "@vitnode/core/tanstack/content"; + +import { + contentListQueryOptions, + defineContentList, +} from "@vitnode/core/tanstack/content"; +import { fetcher } from "@vitnode/core/tanstack/fetcher"; + +import { manifest } from "@/manifest.gen"; + +/** `/news`: published articles, newest first, 10 a page. */ +export const articleList = defineContentList({ + defaultSort: "newest", + pageSize: 10, + sorts: { + newest: { order: "desc", orderBy: "publishedAt" }, + oldest: { order: "asc", orderBy: "publishedAt" }, + title: { order: "asc", orderBy: "title" }, + }, +}); + +export type ArticleListSearch = ContentListSearch< + (typeof articleList.sorts)[number] +>; + +export const articlesQueryOptions = (request: { + locale: string; + search: ArticleListSearch; +}) => + contentListQueryOptions({ + ...request, + queryKey: [manifest.id, "articles"], + fetch: async ({ search }) => { + const res = await fetcher({ + plugin: manifest.id, + method: "get", + module: "content/news", + path: "/", + args: { query: articleList.apiQuery(search) }, + }); + if (res.status !== 200) { + throw new Error(`Loading articles answered ${res.status}.`); + } + + return await res.json(); + }, + }); diff --git a/plugins/newsroom/src/pages/article-new-page.tsx b/plugins/newsroom/src/pages/article-new-page.tsx new file mode 100644 index 0000000000..c35a89f4fa --- /dev/null +++ b/plugins/newsroom/src/pages/article-new-page.tsx @@ -0,0 +1,91 @@ +import type { AutoFormOnSubmit } from "@vitnode/core/components/form/auto-form"; + +import { useQueryClient } from "@tanstack/react-query"; +import { emptyRichTextDocument } from "@vitnode/core/content"; +import { definePluginRoute } from "@vitnode/core/routing"; +import { useContentPublicWrite } from "@vitnode/core/tanstack/content"; +import { fetcher } from "@vitnode/core/tanstack/fetcher"; +import { useHrefNavigate } from "@vitnode/core/tanstack/link"; +import { toast } from "sonner"; +import { useTranslations } from "use-intl"; + +import { manifest } from "@/manifest.gen"; + +import { + ARTICLE_FORM_FIELDS, + ArticleForm, + type ArticleFormSchema, +} from "./article-form"; + +export const route = definePluginRoute({ + head: ({ t }) => ({ + robots: "noindex, nofollow", + title: t("newsroom.articles.new.title"), + }), +}); + +const ArticleNewPage = () => { + const t = useTranslations("newsroom.articles.new"); + const navigate = useHrefNavigate(); + const queryClient = useQueryClient(); + const write = useContentPublicWrite({ + fields: ARTICLE_FORM_FIELDS, + uniqueField: "title", + }); + + const onSubmit: AutoFormOnSubmit<ArticleFormSchema> = async ( + values, + form, + ) => { + const record = await write.submit( + form, + async () => + await fetcher({ + plugin: manifest.id, + method: "post", + module: "content/news", + path: "/", + args: { body: { values } }, + }), + ); + if (!record) return; + + await queryClient.invalidateQueries({ + queryKey: [manifest.id, "articles"], + }); + + // Waits for a moderator. Its public page answers 404 until approval. + if (record.approval?.status === "pending") { + toast.info(t("pending.title"), { description: t("pending.desc") }); + await navigate(`/news/${record.id}/edit`); + + return; + } + + // A draft nobody has to review. It stays private, so open its edit page. + if (record.status !== "published") { + toast.info(t("draft.title"), { description: t("draft.desc") }); + await navigate(`/news/${record.id}/edit`); + + return; + } + + toast.success(t("success.title"), { description: t("success.desc") }); + await navigate(`/news/${record.slug}`); + }; + + return ( + <div className="container mx-auto flex max-w-3xl flex-col gap-6 px-4 py-8"> + <h1 className="text-3xl font-semibold tracking-tight text-balance"> + {t("title")} + </h1> + <ArticleForm + defaultValues={{ body: emptyRichTextDocument(), title: "" }} + onSubmit={onSubmit} + submitLabel={t("submit")} + /> + </div> + ); +}; + +export default ArticleNewPage; diff --git a/plugins/newsroom/src/pages/article-page.tsx b/plugins/newsroom/src/pages/article-page.tsx new file mode 100644 index 0000000000..9277dd6c5f --- /dev/null +++ b/plugins/newsroom/src/pages/article-page.tsx @@ -0,0 +1,147 @@ +import type { PluginRoutePageProps } from "@vitnode/core/routing"; + +import { DateFormat } from "@vitnode/core/components/date-format"; +import { RichTextContent } from "@vitnode/core/components/rich-text"; +import { buttonVariants } from "@vitnode/core/components/ui/button"; +import { contentTarget, zodRichTextDocument } from "@vitnode/core/content"; +import { + contentDeliveryPage, + contentDeliveryPageHead, +} from "@vitnode/core/tanstack/content"; +import { fetcher } from "@vitnode/core/tanstack/fetcher"; +import { HrefLink } from "@vitnode/core/tanstack/link"; +import { ReportButton } from "@vitnode/core/tanstack/moderation"; +import { defineRoute } from "@vitnode/core/tanstack/plugin-routes"; +import { useTranslations } from "use-intl"; +import { z } from "zod"; + +import { articleContentType } from "@/content/article"; +import { manifest } from "@/manifest.gen"; + +import { articlesQueryOptions } from "./article-list"; +import { useArticlePermissions } from "./article-permissions"; +import { commentsQueryOptions } from "./comment-data"; +import { CommentSection } from "./comment-section"; +import { LatestArticles } from "./latest-articles"; + +const zodArticle = z.object({ + body: zodRichTextDocument, + id: z.number(), + publishedAt: z.string().nullable(), + slug: z.string(), + title: z.string(), +}); + +const loadArticle = async (slug: string) => { + const [resolution, detail] = await Promise.all([ + fetcher({ + plugin: manifest.id, + method: "get", + module: "content/news", + path: "/delivery/resolve/{slug}", + args: { params: { slug: encodeURIComponent(slug) } }, + }), + fetcher({ + plugin: manifest.id, + method: "get", + module: "content/news", + path: "/{slug}", + args: { params: { slug: encodeURIComponent(slug) } }, + }), + ]); + + if (resolution.status !== 200) { + throw new Error( + `Resolving the article "${slug}" answered ${resolution.status}.`, + ); + } + + return contentDeliveryPage({ + item: detail.status === 200 ? zodArticle.parse(await detail.json()) : null, + resolution: await resolution.json(), + }); +}; + +type ArticlePageData = Awaited<ReturnType<typeof loadArticle>>; + +export const route = defineRoute({ + load: async ({ context, params }) => { + const page = await loadArticle(params.slug); + await Promise.all([ + // The first page of comments arrives with the article's HTML. A failure + // leaves the section to show its own error instead of breaking the page. + context.queryClient + .infiniteQuery(commentsQueryOptions(page.item.id)) + .catch(() => null), + // Same options as the first page of /news. A failure leaves the box to + // retry in the browser instead of breaking the article. + context.queryClient + .query(articlesQueryOptions({ locale: context.locale, search: {} })) + .catch(() => null), + ]); + + return page; + }, + + head: ({ loaderData }) => + contentDeliveryPageHead(loaderData?.metadata, { + title: loaderData?.item.title, + }), +}); + +/** Asked in the browser, so the cached page stays the same for everyone. */ +const ArticleActions = ({ id, title }: { id: number; title: string }) => { + const t = useTranslations("newsroom.articles"); + const { canEdit, canReport } = useArticlePermissions([id]).of(id); + + return ( + <div className="flex flex-wrap gap-2"> + {canEdit ? ( + <HrefLink + className={buttonVariants({ size: "sm", variant: "outline" })} + href={`/news/${id}/edit`} + > + {t("edit_link")} + </HrefLink> + ) : null} + {canReport ? ( + <ReportButton + preview={{ title }} + target={contentTarget(manifest.id, articleContentType, id)} + typeLabel={t("report_type")} + /> + ) : null} + </div> + ); +}; + +const ArticlePage = ({ + loaderData: { item }, +}: PluginRoutePageProps<ArticlePageData>) => ( + <div className="container mx-auto flex max-w-3xl flex-col gap-10 px-4 py-8"> + <article className="flex flex-col gap-6"> + <header className="flex flex-col gap-2"> + <h1 className="text-3xl font-semibold tracking-tight text-balance"> + {item.title} + </h1> + {item.publishedAt ? ( + <span className="text-muted-foreground text-sm"> + <DateFormat date={item.publishedAt} /> + </span> + ) : null} + <ArticleActions id={item.id} title={item.title} /> + </header> + + <RichTextContent + className="leading-relaxed text-pretty" + content={item.body} + /> + + <CommentSection articleId={item.id} articleSlug={item.slug} /> + </article> + + <LatestArticles currentId={item.id} /> + </div> +); + +export default ArticlePage; diff --git a/plugins/newsroom/src/pages/article-permissions.ts b/plugins/newsroom/src/pages/article-permissions.ts new file mode 100644 index 0000000000..13266406bb --- /dev/null +++ b/plugins/newsroom/src/pages/article-permissions.ts @@ -0,0 +1,23 @@ +import { useContentPermissions } from "@vitnode/core/tanstack/content"; +import { fetcher } from "@vitnode/core/tanstack/fetcher"; + +import { manifest } from "@/manifest.gen"; + +/** What the visitor may do with each article on screen: `canEdit`, `canReport`. */ +export const useArticlePermissions = (ids: readonly number[]) => + useContentPermissions({ + ids, + queryKey: [manifest.id, "articles"], + fetch: async request => { + const res = await fetcher({ + plugin: manifest.id, + method: "get", + module: "content/news", + path: "/permissions", + args: { query: { ids: request.join(",") } }, + }); + if (res.status !== 200) throw new Error("Loading permissions failed."); + + return await res.json(); + }, + }); diff --git a/plugins/newsroom/src/pages/articles-page.tsx b/plugins/newsroom/src/pages/articles-page.tsx new file mode 100644 index 0000000000..51415b15fb --- /dev/null +++ b/plugins/newsroom/src/pages/articles-page.tsx @@ -0,0 +1,159 @@ +import type { PluginRoutePageProps } from "@vitnode/core/routing"; + +import { DateFormat } from "@vitnode/core/components/date-format"; +import { Input } from "@vitnode/core/components/ui/input"; +import { + NativeSelect, + NativeSelectOption, +} from "@vitnode/core/components/ui/native-select"; +import { LOADER_DATA_QUERY_META } from "@vitnode/core/lib/query-client"; +import { + contentListPageHead, + ContentListPagination, + ContentListResults, + useContentListSearchInput, +} from "@vitnode/core/tanstack/content"; +import { HrefLink } from "@vitnode/core/tanstack/link"; +import { + defineRoute, + type RouteLoadContext, +} from "@vitnode/core/tanstack/plugin-routes"; +import { useTransition } from "react"; +import { useTranslations } from "use-intl"; + +import type { ArticleListSearch } from "./article-list"; + +import { articleList, articlesQueryOptions } from "./article-list"; + +const loadArticles = async ( + { locale, queryClient }: RouteLoadContext, + search: ArticleListSearch, +) => + // A failed list becomes a message on the page, not an error page. + await queryClient + .query({ + ...articlesQueryOptions({ locale, search }), + meta: LOADER_DATA_QUERY_META, + }) + .catch(() => null); + +type ArticlesPageData = Awaited<ReturnType<typeof loadArticles>>; + +export const route = defineRoute<ArticlesPageData, ArticleListSearch>({ + load: async ({ context, search }) => await loadArticles(context, search), + + head: ({ search, t }) => + contentListPageHead({ + description: t("newsroom.articles.desc"), + pathname: "/news", + search, + title: t("newsroom.articles.title"), + }), +}); + +const ArticlesPage = ({ + loaderData: articles, + navigate, + search, +}: PluginRoutePageProps<ArticlesPageData, ArticleListSearch>) => { + const t = useTranslations("newsroom.articles"); + const [isPending, startTransition] = useTransition(); + const update = (patch: Partial<ArticleListSearch>) => { + startTransition(async () => { + await navigate({ + resetScroll: false, + search: articleList.withSearch(search, patch), + }); + }); + }; + const input = useContentListSearchInput({ + onCommit: q => { + update({ q }); + }, + value: search.q, + }); + + return ( + <div className="container mx-auto flex max-w-3xl flex-col gap-6 px-4 py-8"> + <h1 className="text-3xl font-semibold tracking-tight text-balance"> + {t("title")} + </h1> + + <div + className="flex flex-col gap-3 md:flex-row md:items-center" + role="search" + > + <Input + aria-label={t("search_label")} + className="md:flex-1" + onChange={event => { + input.setValue(event.target.value); + }} + onKeyDown={event => { + if (event.key === "Enter" && !event.nativeEvent.isComposing) { + input.commit(); + } + }} + placeholder={t("search_label")} + type="search" + value={input.value} + /> + <NativeSelect + aria-label={t("sort_label")} + onChange={event => { + update({ + sort: articleList.sorts.find(sort => sort === event.target.value), + }); + }} + value={search.sort ?? articleList.defaultSort} + > + {articleList.sorts.map(sort => ( + <NativeSelectOption key={sort} value={sort}> + {t(`sort.${sort}`)} + </NativeSelectOption> + ))} + </NativeSelect> + </div> + + <ContentListResults + className="flex flex-col divide-y rounded-lg border" + empty={<p className="text-muted-foreground">{t("empty")}</p>} + error={<p className="text-destructive">{t("error")}</p>} + getKey={article => article.id} + isError={articles === null} + isFetching={isPending} + isFiltered={articleList.isFiltered(search)} + itemClassName="flex flex-col gap-1 p-4" + items={articles?.edges} + label={t("title")} + loading={null} + noResults={<p className="text-muted-foreground">{t("no_results")}</p>} + renderItem={article => ( + <> + <HrefLink + className="text-lg font-medium hover:underline" + href={`/news/${article.slug}`} + > + {article.title} + </HrefLink> + {article.publishedAt ? ( + <span className="text-muted-foreground text-sm"> + <DateFormat date={article.publishedAt} /> + </span> + ) : null} + </> + )} + /> + + {articles ? ( + <ContentListPagination + hrefFor={page => articleList.href("/news", { ...search, page })} + label={t("pagination")} + pageInfo={articles.pageInfo} + /> + ) : null} + </div> + ); +}; + +export default ArticlesPage; diff --git a/plugins/example/src/pages/browse-page.tsx b/plugins/newsroom/src/pages/browse-page.tsx similarity index 94% rename from plugins/example/src/pages/browse-page.tsx rename to plugins/newsroom/src/pages/browse-page.tsx index 39e5239d10..2425f2ffc1 100644 --- a/plugins/example/src/pages/browse-page.tsx +++ b/plugins/newsroom/src/pages/browse-page.tsx @@ -23,7 +23,7 @@ const BrowsePage = ({ navigate, search, }: PluginRoutePageProps<undefined, BrowseSearch>) => { - const t = useTranslations("@vitnode/example.browse"); + const t = useTranslations("newsroom.browse"); const start = (search.page - 1) * BROWSE_PAGE_SIZE; const shown = ITEMS.slice(start, start + BROWSE_PAGE_SIZE); @@ -92,8 +92,8 @@ export const route = definePluginRoute({ * declared in `routes.ts`. */ head: ({ t }) => ({ - description: t("@vitnode/example.browse.desc"), - title: t("@vitnode/example.browse.title"), + description: t("newsroom.browse.desc"), + title: t("newsroom.browse.title"), }), breadcrumb: false, diff --git a/plugins/example/src/pages/browse-search.ts b/plugins/newsroom/src/pages/browse-search.ts similarity index 100% rename from plugins/example/src/pages/browse-search.ts rename to plugins/newsroom/src/pages/browse-search.ts diff --git a/plugins/example/src/pages/localized-article-page.tsx b/plugins/newsroom/src/pages/bulletin-page.tsx similarity index 58% rename from plugins/example/src/pages/localized-article-page.tsx rename to plugins/newsroom/src/pages/bulletin-page.tsx index b6dad89ef6..59d6eaba63 100644 --- a/plugins/example/src/pages/localized-article-page.tsx +++ b/plugins/newsroom/src/pages/bulletin-page.tsx @@ -8,15 +8,15 @@ import { z } from "zod"; import { manifest } from "@/manifest.gen"; -import { ContentArticle } from "./content-article"; +import { ContentStory } from "./content-story"; -const zodLocalizedArticle = z.object({ +const zodBulletin = z.object({ body: z.string(), publishedAt: z.string().nullable(), title: z.string(), }); -const loadLocalizedArticle = async ({ +const loadBulletin = async ({ locale, slug, }: { @@ -26,7 +26,7 @@ const loadLocalizedArticle = async ({ const detail = await fetcher({ plugin: manifest.id, method: "get", - module: "content/localized-articles", + module: "content/bulletins", path: "/{slug}", args: { params: { slug: encodeURIComponent(slug) }, @@ -36,44 +36,36 @@ const loadLocalizedArticle = async ({ return { item: contentPageItem( - detail.status === 200 - ? zodLocalizedArticle.parse(await detail.json()) - : null, + detail.status === 200 ? zodBulletin.parse(await detail.json()) : null, ), locale, }; }; -type LocalizedArticlePageData = Awaited< - ReturnType<typeof loadLocalizedArticle> ->; +type BulletinPageData = Awaited<ReturnType<typeof loadBulletin>>; export const route = definePluginRoute({ load: async ({ context, params }) => - await loadLocalizedArticle({ locale: context.locale, slug: params.slug }), + await loadBulletin({ locale: context.locale, slug: params.slug }), head: ({ loaderData, params }) => ({ alternates: loaderData ? { - [loaderData.locale]: `/localized-articles/${encodeURIComponent(params.slug)}`, + [loaderData.locale]: `/bulletins/${encodeURIComponent(params.slug)}`, } : undefined, title: loaderData?.item.title, }), }); -const LocalizedArticlePage = ({ +const BulletinPage = ({ loaderData: { item, locale }, -}: PluginRoutePageProps<LocalizedArticlePageData>) => ( - <ContentArticle - lang={locale} - publishedAt={item.publishedAt} - title={item.title} - > +}: PluginRoutePageProps<BulletinPageData>) => ( + <ContentStory lang={locale} publishedAt={item.publishedAt} title={item.title}> <p className="text-foreground leading-relaxed text-pretty whitespace-pre-line"> {item.body} </p> - </ContentArticle> + </ContentStory> ); -export default LocalizedArticlePage; +export default BulletinPage; diff --git a/plugins/newsroom/src/pages/comment-composer.tsx b/plugins/newsroom/src/pages/comment-composer.tsx new file mode 100644 index 0000000000..b760b8ddb0 --- /dev/null +++ b/plugins/newsroom/src/pages/comment-composer.tsx @@ -0,0 +1,99 @@ +import { + AutoForm, + type AutoFormOnSubmit, + AutoFormSubmitButton, +} from "@vitnode/core/components/form/auto-form"; +import { AutoFormEditor } from "@vitnode/core/components/form/fields/editor"; +import { + emptyRichTextDocument, + isRichTextEmpty, + zodRichTextDocument, +} from "@vitnode/core/content"; +import { useContentPublicWrite } from "@vitnode/core/tanstack/content"; +import { fetcher } from "@vitnode/core/tanstack/fetcher"; +import { useTranslations } from "use-intl"; +import { z } from "zod"; + +import { manifest } from "@/manifest.gen"; + +/** Where a new comment ended up. Only `published` is in the public thread. */ +export type CommentOutcome = "draft" | "pending" | "published"; + +export interface CommentComposerProps { + articleId: number; + label: string; + /** Called after the server stored the comment. */ + onPosted: (outcome: CommentOutcome) => Promise<void>; + /** The comment being answered, or `null` for the article itself. */ + parentId: null | number; +} + +const createSchema = (required: string) => + z.object({ + body: zodRichTextDocument + .refine(doc => !isRichTextEmpty(doc), required) + .default(emptyRichTextDocument()), + }); + +const CommentComposer = ({ + articleId, + label, + onPosted, + parentId, +}: CommentComposerProps) => { + const t = useTranslations("newsroom.comments.form"); + const write = useContentPublicWrite({ fields: ["body"] }); + + const onSubmit: AutoFormOnSubmit<ReturnType<typeof createSchema>> = async ( + { body }, + form, + ) => { + const record = await write.submit( + form, + async () => + await fetcher({ + plugin: manifest.id, + method: "post", + module: "content/comments", + path: "/", + args: { + body: { values: { article: articleId, body, parent: parentId } }, + }, + }), + ); + if (!record) return; + + // A pending comment is a draft too, so ask about approval first. + await onPosted( + record.approval?.status === "pending" + ? "pending" + : record.status === "published" + ? "published" + : "draft", + ); + }; + + return ( + <AutoForm + aria-label={label} + className="flex flex-col gap-3" + fields={[ + { + id: "body", + component: fieldProps => ( + <AutoFormEditor {...fieldProps} format="json" label={label} /> + ), + }, + ]} + formSchema={createSchema(t("required"))} + layout={fields => <>{fields.body}</>} + onSubmit={onSubmit} + > + <AutoFormSubmitButton className="self-start"> + {t("submit")} + </AutoFormSubmitButton> + </AutoForm> + ); +}; + +export default CommentComposer; diff --git a/plugins/newsroom/src/pages/comment-data.ts b/plugins/newsroom/src/pages/comment-data.ts new file mode 100644 index 0000000000..9464e691c6 --- /dev/null +++ b/plugins/newsroom/src/pages/comment-data.ts @@ -0,0 +1,104 @@ +import { infiniteQueryOptions } from "@tanstack/react-query"; +import { + zodContentPublicPerson, + zodRichTextDocument, +} from "@vitnode/core/content"; +import { fetcher } from "@vitnode/core/tanstack/fetcher"; +import { z } from "zod"; + +import { manifest } from "@/manifest.gen"; + +/** Top-level comments per page. The thread route accepts 1 to 50. */ +const COMMENTS_PAGE_SIZE = 20; + +/** One comment as `publicApi.fields` and `people` return it. */ +const zodComment = z.object({ + author: zodContentPublicPerson.nullable().catch(null), + body: zodRichTextDocument, + id: z.number(), + publishedAt: z.string().nullable(), +}); + +const zodCommentPage = z.object({ + items: z.array( + z.object({ + depth: z.number(), + id: z.number(), + parentId: z.number().nullable(), + record: zodComment.nullable(), + removed: z.boolean(), + }), + ), + pageInfo: z.object({ + endCursor: z.string().nullable(), + hasNextPage: z.boolean(), + totalCount: z.number(), + }), + replyCount: z.number(), + truncated: z.boolean(), +}); + +export type CommentPage = z.infer<typeof zodCommentPage>; + +export type CommentItem = CommentPage["items"][number]; + +export type ArticleComment = NonNullable<CommentItem["record"]>; + +const fetchCommentPage = async (articleId: number, after?: string) => { + const res = await fetcher({ + plugin: manifest.id, + method: "get", + module: "content/comments", + path: "/thread", + args: { + query: { + root: String(articleId), + first: COMMENTS_PAGE_SIZE, + ...(after ? { after } : {}), + }, + }, + }); + if (res.status !== 200) throw new Error("Loading the comments failed."); + + // The thread route types `record` loosely, so parse it once here. + return zodCommentPage.parse(await res.json()); +}; + +export const commentsQueryKey = (articleId: number) => + [manifest.id, "comments", articleId] as const; + +/** Every loaded page under one key, so a refetch reloads them all in order. */ +export const commentsQueryOptions = (articleId: number) => + infiniteQueryOptions({ + getNextPageParam: (last: CommentPage) => + last.pageInfo.hasNextPage + ? (last.pageInfo.endCursor ?? undefined) + : undefined, + initialPageParam: undefined as string | undefined, + queryFn: async ({ pageParam }) => + await fetchCommentPage(articleId, pageParam), + queryKey: commentsQueryKey(articleId), + }); + +export interface CommentNode extends CommentItem { + children: CommentNode[]; +} + +/** The flat, depth-first answer as a tree: a parent always comes before its replies. */ +export const buildCommentTree = ( + items: readonly CommentItem[], +): CommentNode[] => { + const nodes = new Map<number, CommentNode>(); + const roots: CommentNode[] = []; + + for (const item of items) { + const node: CommentNode = { ...item, children: [] }; + nodes.set(item.id, node); + const parent = + item.parentId === null ? undefined : nodes.get(item.parentId); + if (parent) parent.children.push(node); + else roots.push(node); + } + + return roots; +}; diff --git a/plugins/newsroom/src/pages/comment-editor.tsx b/plugins/newsroom/src/pages/comment-editor.tsx new file mode 100644 index 0000000000..73887c41c1 --- /dev/null +++ b/plugins/newsroom/src/pages/comment-editor.tsx @@ -0,0 +1,93 @@ +import { + AutoForm, + type AutoFormOnSubmit, + AutoFormSubmitButton, +} from "@vitnode/core/components/form/auto-form"; +import { AutoFormEditor } from "@vitnode/core/components/form/fields/editor"; +import { Button } from "@vitnode/core/components/ui/button"; +import { + isRichTextEmpty, + type RichTextDocument, + zodRichTextDocument, +} from "@vitnode/core/content"; +import { useContentPublicWrite } from "@vitnode/core/tanstack/content"; +import { fetcher } from "@vitnode/core/tanstack/fetcher"; +import { useTranslations } from "use-intl"; +import { z } from "zod"; + +import { manifest } from "@/manifest.gen"; + +export interface CommentEditorProps { + /** The comment's latest text, from `GET /comments/{id}/edit`. */ + body: RichTextDocument; + commentId: number; + label: string; + onCancel: () => void; + /** Called after a save, with whether it waits for a moderator. */ + onSaved: (pending: boolean) => Promise<void>; +} + +const createSchema = (body: RichTextDocument, required: string) => + z.object({ + body: zodRichTextDocument + .refine(doc => !isRichTextEmpty(doc), required) + .default(body), + }); + +const CommentEditor = ({ + body, + commentId, + label, + onCancel, + onSaved, +}: CommentEditorProps) => { + const t = useTranslations("newsroom.comments.form"); + const write = useContentPublicWrite({ fields: ["body"] }); + + const onSubmit: AutoFormOnSubmit<ReturnType<typeof createSchema>> = async ( + values, + form, + ) => { + const saved = await write.submit( + form, + async () => + await fetcher({ + plugin: manifest.id, + method: "patch", + module: "content/comments", + path: "/{id}", + args: { params: { id: commentId }, body: { values } }, + }), + ); + if (!saved) return; + + await onSaved(saved.approval?.status === "pending"); + }; + + return ( + <AutoForm + aria-label={label} + className="flex flex-col gap-3" + fields={[ + { + id: "body", + component: fieldProps => ( + <AutoFormEditor {...fieldProps} format="json" label={label} /> + ), + }, + ]} + formSchema={createSchema(body, t("required"))} + layout={fields => <>{fields.body}</>} + onSubmit={onSubmit} + > + <div className="flex flex-wrap items-center gap-2"> + <AutoFormSubmitButton>{t("save")}</AutoFormSubmitButton> + <Button onClick={onCancel} type="button" variant="ghost"> + {t("cancel")} + </Button> + </div> + </AutoForm> + ); +}; + +export default CommentEditor; diff --git a/plugins/newsroom/src/pages/comment-permissions.ts b/plugins/newsroom/src/pages/comment-permissions.ts new file mode 100644 index 0000000000..63ceb27fc6 --- /dev/null +++ b/plugins/newsroom/src/pages/comment-permissions.ts @@ -0,0 +1,23 @@ +import { useContentPermissions } from "@vitnode/core/tanstack/content"; +import { fetcher } from "@vitnode/core/tanstack/fetcher"; + +import { manifest } from "@/manifest.gen"; + +/** What the visitor may do with each comment on screen: `canEdit`, `canReport`. */ +export const useCommentPermissions = (ids: readonly number[]) => + useContentPermissions({ + ids, + queryKey: [manifest.id, "comments"], + fetch: async request => { + const res = await fetcher({ + plugin: manifest.id, + method: "get", + module: "content/comments", + path: "/permissions", + args: { query: { ids: request.join(",") } }, + }); + if (res.status !== 200) throw new Error("Loading permissions failed."); + + return await res.json(); + }, + }); diff --git a/plugins/newsroom/src/pages/comment-section.tsx b/plugins/newsroom/src/pages/comment-section.tsx new file mode 100644 index 0000000000..e849f2fcff --- /dev/null +++ b/plugins/newsroom/src/pages/comment-section.tsx @@ -0,0 +1,377 @@ +import { useInfiniteQuery, useQueryClient } from "@tanstack/react-query"; +import { RichTextContent } from "@vitnode/core/components/rich-text"; +import { Button, buttonVariants } from "@vitnode/core/components/ui/button"; +import { Skeleton } from "@vitnode/core/components/ui/skeleton"; +import { type RichTextDocument } from "@vitnode/core/content"; +import { useSessionQuery } from "@vitnode/core/tanstack/auth"; +import { fetcher } from "@vitnode/core/tanstack/fetcher"; +import { HrefLink } from "@vitnode/core/tanstack/link"; +import { lazy, Suspense, useMemo, useState } from "react"; +import { toast } from "sonner"; +import { useTranslations } from "use-intl"; + +import { COMMENT_MAX_DEPTH } from "@/content/comment"; +import { manifest } from "@/manifest.gen"; + +import type { CommentComposerProps, CommentOutcome } from "./comment-composer"; +import type { ArticleComment, CommentNode } from "./comment-data"; +import type { CommentEditorProps } from "./comment-editor"; + +import { + buildCommentTree, + commentsQueryKey, + commentsQueryOptions, +} from "./comment-data"; +import { useCommentPermissions } from "./comment-permissions"; + +// The editor is heavy and most readers never write, so it loads on demand. +const CommentComposer = lazy(async () => await import("./comment-composer")); +const CommentEditor = lazy(async () => await import("./comment-editor")); + +const Composer = (props: CommentComposerProps) => ( + <Suspense fallback={<Skeleton className="h-40 w-full rounded-lg" />}> + <CommentComposer {...props} /> + </Suspense> +); + +/** Who wrote a comment, and what they said. */ +const CommentBody = ({ comment }: { comment: ArticleComment }) => { + const t = useTranslations("newsroom.comments"); + + return ( + <div className="flex flex-col gap-2"> + <p className="text-sm font-medium"> + {comment.author?.name ?? t("someone")} + </p> + <RichTextContent + className="leading-relaxed text-pretty" + content={comment.body} + /> + </div> + ); +}; + +/** A comment, with Edit for the visitors who may use it. */ +const CommentItem = ({ + canEdit, + comment, + onSaved, +}: { + canEdit: boolean; + comment: ArticleComment; + onSaved: CommentEditorProps["onSaved"]; +}) => { + const t = useTranslations("newsroom.comments"); + const tWrite = useTranslations("core.global.content_write"); + const [editing, setEditing] = useState<null | RichTextDocument>(null); + const [isOpening, setIsOpening] = useState(false); + + // Opens the editor on the comment's latest text, not on the cached copy. + const openEditor = async () => { + setIsOpening(true); + try { + const res = await fetcher({ + plugin: manifest.id, + method: "get", + module: "content/comments", + path: "/{id}/edit", + args: { params: { id: comment.id } }, + }); + if (res.status !== 200) { + const reason = + res.status === 401 + ? "unauthenticated" + : res.status === 403 + ? "forbidden" + : res.status === 404 + ? "not_found" + : "unknown"; + toast.error(tWrite("title"), { description: tWrite(reason) }); + + return; + } + + setEditing((await res.json()).values.body); + } finally { + setIsOpening(false); + } + }; + + if (editing) { + return ( + <div className="flex flex-col gap-2"> + <p className="text-sm font-medium"> + {comment.author?.name ?? t("someone")} + </p> + <Suspense fallback={<Skeleton className="h-40 w-full rounded-lg" />}> + <CommentEditor + body={editing} + commentId={comment.id} + label={t("form.edit_label")} + onCancel={() => { + setEditing(null); + }} + onSaved={async pending => { + setEditing(null); + await onSaved(pending); + }} + /> + </Suspense> + </div> + ); + } + + return ( + <div className="flex flex-col gap-2"> + <CommentBody comment={comment} /> + {canEdit ? ( + <Button + className="self-start" + isLoading={isOpening} + onClick={openEditor} + size="sm" + variant="ghost" + > + {t("edit")} + </Button> + ) : null} + </div> + ); +}; + +/** What every comment in the tree needs from the section. */ +interface CommentThread { + articleId: number; + canReply: boolean; + onEdited: CommentEditorProps["onSaved"]; + onPosted: CommentComposerProps["onPosted"]; + permissions: ReturnType<typeof useCommentPermissions>; +} + +/** A comment with its Reply form, followed by every answer below it. */ +const CommentBranch = ({ + node, + thread, +}: { + node: CommentNode; + thread: CommentThread; +}) => { + const t = useTranslations("newsroom.comments"); + const [replying, setReplying] = useState(false); + + return ( + <li className="flex flex-col gap-3"> + {node.record ? ( + <> + <CommentItem + canEdit={thread.permissions.of(node.id).canEdit} + comment={node.record} + onSaved={thread.onEdited} + /> + {thread.canReply && node.depth < COMMENT_MAX_DEPTH ? ( + <Button + aria-expanded={replying} + className="self-start" + onClick={() => { + setReplying(!replying); + }} + size="sm" + variant="ghost" + > + {t("reply")} + </Button> + ) : null} + {replying ? ( + <div className="flex flex-col gap-2"> + <Composer + articleId={thread.articleId} + label={t("form.reply_label", { + name: node.record.author?.name ?? t("someone"), + })} + onPosted={async outcome => { + setReplying(false); + await thread.onPosted(outcome); + }} + parentId={node.id} + /> + <Button + className="self-start" + onClick={() => { + setReplying(false); + }} + size="sm" + variant="ghost" + > + {t("form.cancel")} + </Button> + </div> + ) : null} + </> + ) : ( + // Hidden, but its public answers still need a parent to hang from. + <p className="text-muted-foreground text-sm italic">{t("removed")}</p> + )} + + {node.children.length > 0 ? ( + <ol className="flex flex-col gap-4 border-l-2 pl-3 md:pl-6"> + {node.children.map(child => ( + <CommentBranch key={child.id} node={child} thread={thread} /> + ))} + </ol> + ) : null} + </li> + ); +}; + +export const CommentSection = ({ + articleId, + articleSlug, +}: { + articleId: number; + articleSlug: string; +}) => { + const t = useTranslations("newsroom.comments"); + const queryClient = useQueryClient(); + const session = useSessionQuery(); + const [composerKey, setComposerKey] = useState(0); + const comments = useInfiniteQuery(commentsQueryOptions(articleId)); + + const pages = comments.data?.pages; + const items = useMemo( + () => pages?.flatMap(page => page.items) ?? [], + [pages], + ); + const tree = useMemo(() => buildCommentTree(items), [items]); + // One request for every comment on screen, batched by the hook. + const permissions = useCommentPermissions( + items.flatMap(item => (item.record ? [item.id] : [])), + ); + + const onPosted = async (outcome: CommentOutcome) => { + if (outcome !== "published") { + // Not in the public thread, so there is nothing to refresh. + toast.info(t(`${outcome}.title`), { description: t(`${outcome}.desc`) }); + + return; + } + + toast.success(t("created.title"), { description: t("created.desc") }); + // Refetches every loaded page in order, with the new count. + await queryClient.invalidateQueries({ + queryKey: commentsQueryKey(articleId), + }); + }; + + const onEdited = async (pending: boolean) => { + if (pending) { + toast.info(t("edit_pending.title"), { + description: t("edit_pending.desc"), + }); + } else { + toast.success(t("edited.title"), { description: t("edited.desc") }); + } + await queryClient.invalidateQueries({ + queryKey: commentsQueryKey(articleId), + }); + }; + + const thread: CommentThread = { + articleId, + canReply: Boolean(session.data?.user), + onEdited, + onPosted, + permissions, + }; + + return ( + <section aria-labelledby="comments-title" className="flex flex-col gap-6"> + <h2 className="text-xl font-semibold text-balance" id="comments-title"> + {t("title", { count: pages?.[0]?.replyCount ?? 0 })} + </h2> + + {comments.isPending ? ( + <div aria-busy="true" className="flex flex-col gap-4"> + <span className="sr-only" role="status"> + {t("loading")} + </span> + {[0, 1].map(key => ( + <div className="flex flex-col gap-2" key={key}> + <Skeleton className="h-4 w-32" /> + <Skeleton className="h-4 w-full" /> + </div> + ))} + </div> + ) : comments.isError && items.length === 0 ? ( + <div className="flex flex-col items-start gap-3"> + <p className="text-muted-foreground leading-relaxed" role="alert"> + {t("load_failed.desc")} + </p> + <Button + isLoading={comments.isFetching} + onClick={async () => { + await comments.refetch(); + }} + variant="outline" + > + {t("retry")} + </Button> + </div> + ) : items.length === 0 ? ( + <p className="text-muted-foreground leading-relaxed">{t("empty")}</p> + ) : ( + <ol aria-busy={comments.isFetching} className="flex flex-col gap-6"> + {tree.map(node => ( + <CommentBranch key={node.id} node={node} thread={thread} /> + ))} + </ol> + )} + + {pages?.some(page => page.truncated) ? ( + <p className="text-muted-foreground text-sm">{t("truncated")}</p> + ) : null} + + {comments.hasNextPage ? ( + <Button + className="self-start" + disabled={comments.isFetching} + isLoading={comments.isFetchingNextPage} + onClick={async () => { + const next = await comments.fetchNextPage(); + if (next.isError) { + toast.error(t("load_failed.title"), { + description: t("load_failed.desc"), + }); + } + }} + variant="outline" + > + {t("load_more")} + </Button> + ) : null} + + {session.data?.user ? ( + <Composer + articleId={articleId} + // A fresh key gives the next comment an empty editor. + key={composerKey} + label={t("form.label")} + onPosted={async outcome => { + setComposerKey(key => key + 1); + await onPosted(outcome); + }} + parentId={null} + /> + ) : ( + <HrefLink + className={buttonVariants({ + className: "self-start", + variant: "outline", + })} + href={`/login?returnTo=${encodeURIComponent(`/news/${articleSlug}`)}`} + > + {t("sign_in")} + </HrefLink> + )} + </section> + ); +}; diff --git a/plugins/example/src/pages/content-article.tsx b/plugins/newsroom/src/pages/content-story.tsx similarity index 92% rename from plugins/example/src/pages/content-article.tsx rename to plugins/newsroom/src/pages/content-story.tsx index 9a3d665bb2..a18331f7d4 100644 --- a/plugins/example/src/pages/content-article.tsx +++ b/plugins/newsroom/src/pages/content-story.tsx @@ -1,7 +1,7 @@ import { DateFormat } from "@vitnode/core/components/date-format"; import { useTranslations } from "use-intl"; -export const ContentArticle = ({ +export const ContentStory = ({ children, lang, lead, @@ -14,7 +14,7 @@ export const ContentArticle = ({ publishedAt: null | string; title: string; }) => { - const t = useTranslations("@vitnode/example.articles"); + const t = useTranslations("newsroom.stories"); return ( <article diff --git a/plugins/example/src/pages/board-page.tsx b/plugins/newsroom/src/pages/desk-page.tsx similarity index 65% rename from plugins/example/src/pages/board-page.tsx rename to plugins/newsroom/src/pages/desk-page.tsx index 0256ce5c99..4469158338 100644 --- a/plugins/example/src/pages/board-page.tsx +++ b/plugins/newsroom/src/pages/desk-page.tsx @@ -30,32 +30,32 @@ import { z } from "zod"; import { manifest } from "@/manifest.gen"; -import type { BoardSearch } from "./board-search"; +import type { DeskSearch } from "./desk-search"; -import { boardHref, BOARDS_HREF } from "./board-search"; -import { BoardColorDot, BoardTree } from "./board-tree"; -import { discussionHref, discussionNewHref } from "./discussion-search"; -import { cachedBoardId, queryBoard } from "./example-queries"; +import { cachedDeskId, queryDesk } from "./desk-queries"; +import { deskHref, DESKS_HREF } from "./desk-search"; +import { DeskColorDot, DeskTree } from "./desk-tree"; +import { letterHref, letterNewHref } from "./letter-search"; const PAGE_SIZE = 20; -const zodDiscussionRow = z.object({ +const zodLetterRow = z.object({ id: z.number(), publishedAt: z.string().nullable(), slug: z.string().nullable(), title: z.string(), }); -const fetchDiscussions = async (boardId: number, cursor?: string) => { +const fetchLetters = async (deskId: number, cursor?: string) => { const filters = { - board: String(boardId), + desk: String(deskId), includeDescendants: "true", }; const list = await fetcher({ plugin: manifest.id, method: "get", - module: "content/discussions", + module: "content/letters", path: "/", args: { query: { @@ -66,37 +66,35 @@ const fetchDiscussions = async (boardId: number, cursor?: string) => { }, }); - if (list.status !== 200) throw new Error("Loading the discussions failed."); + if (list.status !== 200) throw new Error("Loading the letters failed."); const { edges, pageInfo } = await list.json(); return { - discussions: z.array(zodDiscussionRow).parse(edges), + letters: z.array(zodLetterRow).parse(edges), nextCursor: pageInfo.hasNextPage ? pageInfo.endCursor : null, }; }; -const loadBoard = async ( +const loadDesk = async ( queryClient: RouteLoadContext["queryClient"], idOrSlug: string, - { cursor }: BoardSearch, + { cursor }: DeskSearch, ) => { - // The list needs the board's id. When the boards index already cached it, + // The list needs the desk's id. When the desks index already cached it, // both requests go out together instead of one after the other. - const knownId = cachedBoardId(queryClient, idOrSlug); - const [board, early] = await Promise.all([ - queryBoard(queryClient, idOrSlug), - // A stale id (the board was moved or deleted since) is not this page's - // error: the board read below decides, and the list is fetched again. - knownId === null - ? null - : fetchDiscussions(knownId, cursor).catch(() => null), + const knownId = cachedDeskId(queryClient, idOrSlug); + const [desk, early] = await Promise.all([ + queryDesk(queryClient, idOrSlug), + // A stale id (the desk was moved or deleted since) is not this page's + // error: the desk read below decides, and the list is fetched again. + knownId === null ? null : fetchLetters(knownId, cursor).catch(() => null), ]); - const { ancestors, children, container } = contentPageItem(board); + const { ancestors, children, container } = contentPageItem(desk); const page = early && knownId === container.id ? early - : await fetchDiscussions(container.id, cursor); + : await fetchLetters(container.id, cursor); return { ancestors, @@ -107,10 +105,10 @@ const loadBoard = async ( }; }; -type BoardPageData = Awaited<ReturnType<typeof loadBoard>>; +type DeskPageData = Awaited<ReturnType<typeof loadDesk>>; -const BoardNotFound = () => { - const t = useTranslations("@vitnode/example.boards.not_found"); +const DeskNotFound = () => { + const t = useTranslations("newsroom.desks.not_found"); return ( <div className="container mx-auto flex max-w-3xl px-4 py-8 md:py-12"> @@ -125,7 +123,7 @@ const BoardNotFound = () => { <EmptyContent> <HrefLink className={buttonVariants({ variant: "outline" })} - href={BOARDS_HREF} + href={DESKS_HREF} > {t("back")} </HrefLink> @@ -135,15 +133,15 @@ const BoardNotFound = () => { ); }; -const BoardsCrumb = () => { - const t = useTranslations("@vitnode/example.boards"); +const DesksCrumb = () => { + const t = useTranslations("newsroom.desks"); return <>{t("title")}</>; }; -export const route = defineRoute<BoardPageData, BoardSearch>({ +export const route = defineRoute<DeskPageData, DeskSearch>({ load: async ({ context, params, search }) => - await loadBoard(context.queryClient, params.slug, search), + await loadDesk(context.queryClient, params.slug, search), head: ({ loaderData }) => ({ description: loaderData?.container.metadata.description ?? undefined, @@ -154,8 +152,8 @@ export const route = defineRoute<BoardPageData, BoardSearch>({ group: ({ loaderData }) => ( <> <BreadcrumbItem> - <BreadcrumbLink render={<HrefLink href={BOARDS_HREF} />}> - <BoardsCrumb /> + <BreadcrumbLink render={<HrefLink href={DESKS_HREF} />}> + <DesksCrumb /> </BreadcrumbLink> </BreadcrumbItem> {loaderData.ancestors.map(ancestor => ( @@ -163,7 +161,7 @@ export const route = defineRoute<BoardPageData, BoardSearch>({ <BreadcrumbSeparator /> <BreadcrumbItem> <BreadcrumbLink - render={<HrefLink href={boardHref(ancestor.slug)} />} + render={<HrefLink href={deskHref(ancestor.slug)} />} > {ancestor.name} </BreadcrumbLink> @@ -178,22 +176,21 @@ export const route = defineRoute<BoardPageData, BoardSearch>({ ), }, - notFound: BoardNotFound, + notFound: DeskNotFound, }); -const BoardPage = ({ +const DeskPage = ({ loaderData, -}: PluginRoutePageProps<BoardPageData, BoardSearch>) => { - const t = useTranslations("@vitnode/example.boards"); - const { children, container, discussions, isFirstPage, nextCursor } = - loaderData; +}: PluginRoutePageProps<DeskPageData, DeskSearch>) => { + const t = useTranslations("newsroom.desks"); + const { children, container, letters, isFirstPage, nextCursor } = loaderData; return ( <div className="container mx-auto flex max-w-3xl flex-col gap-8 px-4 py-8 md:py-12"> <header className="flex flex-col gap-4 md:flex-row md:items-end md:justify-between"> <div className="flex flex-col gap-2"> <h1 className="text-foreground flex items-center gap-3 text-3xl font-semibold tracking-tight text-balance"> - <BoardColorDot board={container} /> + <DeskColorDot desk={container} /> {container.name} </h1> {container.metadata.description ? ( @@ -205,81 +202,78 @@ const BoardPage = ({ <HrefLink className={buttonVariants({ className: "self-start" })} - href={discussionNewHref(container.id)} + href={letterNewHref(container.id)} > - {t("new_discussion")} + {t("new_letter")} </HrefLink> </header> {children.length > 0 ? ( <section - aria-labelledby="board-children" + aria-labelledby="desk-children" className="flex flex-col gap-4" > <h2 className="text-foreground text-xl font-semibold text-balance" - id="board-children" + id="desk-children" > {t("children")} </h2> - <BoardTree + <DeskTree label={t("children")} nodes={children.map(child => ({ ...child, children: [] }))} /> </section> ) : null} - <section - aria-labelledby="board-discussions" - className="flex flex-col gap-4" - > + <section aria-labelledby="desk-letters" className="flex flex-col gap-4"> <h2 className="text-foreground text-xl font-semibold text-balance" - id="board-discussions" + id="desk-letters" > - {t("discussions")} + {t("letters")} </h2> - {discussions.length === 0 ? ( + {letters.length === 0 ? ( <Empty className="border"> <EmptyHeader> <EmptyMedia variant="icon"> <MessagesSquareIcon aria-hidden="true" /> </EmptyMedia> - <EmptyTitle render={<h3 />}>{t("empty_board.title")}</EmptyTitle> - <EmptyDescription>{t("empty_board.desc")}</EmptyDescription> + <EmptyTitle render={<h3 />}>{t("empty_desk.title")}</EmptyTitle> + <EmptyDescription>{t("empty_desk.desc")}</EmptyDescription> </EmptyHeader> <EmptyContent> <HrefLink className={buttonVariants({ variant: "outline" })} - href={discussionNewHref(container.id)} + href={letterNewHref(container.id)} > - {t("new_discussion")} + {t("new_letter")} </HrefLink> </EmptyContent> </Empty> ) : ( <ul className="flex flex-col divide-y rounded-lg border"> - {discussions.map(discussion => ( - <li className="flex flex-col gap-1 p-4" key={discussion.id}> - {discussion.slug === null ? ( + {letters.map(letter => ( + <li className="flex flex-col gap-1 p-4" key={letter.id}> + {letter.slug === null ? ( <span className="text-foreground font-medium text-balance"> - {discussion.title} + {letter.title} </span> ) : ( <HrefLink className="text-foreground font-medium text-balance hover:underline" - href={discussionHref(discussion.slug)} + href={letterHref(letter.slug)} > - {discussion.title} + {letter.title} </HrefLink> )} - {discussion.publishedAt ? ( + {letter.publishedAt ? ( <time className="text-muted-foreground text-sm leading-relaxed" - dateTime={new Date(discussion.publishedAt).toISOString()} + dateTime={new Date(letter.publishedAt).toISOString()} > - <DateFormat date={discussion.publishedAt} /> + <DateFormat date={letter.publishedAt} /> </time> ) : null} </li> @@ -295,7 +289,7 @@ const BoardPage = ({ {isFirstPage ? null : ( <HrefLink className={buttonVariants({ variant: "outline" })} - href={boardHref(container.slug)} + href={deskHref(container.slug)} > {t("newest")} </HrefLink> @@ -303,7 +297,7 @@ const BoardPage = ({ {nextCursor === null ? null : ( <HrefLink className={buttonVariants({ variant: "outline" })} - href={boardHref(container.slug, { cursor: nextCursor })} + href={deskHref(container.slug, { cursor: nextCursor })} > {t("older")} </HrefLink> @@ -315,4 +309,4 @@ const BoardPage = ({ ); }; -export default BoardPage; +export default DeskPage; diff --git a/plugins/newsroom/src/pages/desk-queries.ts b/plugins/newsroom/src/pages/desk-queries.ts new file mode 100644 index 0000000000..db16b0a146 --- /dev/null +++ b/plugins/newsroom/src/pages/desk-queries.ts @@ -0,0 +1,91 @@ +import type { RouteLoadContext } from "@vitnode/core/tanstack/plugin-routes"; + +import { LOADER_DATA_QUERY_META } from "@vitnode/core/lib/query-client"; +import { RECORD_STALE_TIME } from "@vitnode/core/lib/query-freshness"; +import { fetcher } from "@vitnode/core/tanstack/fetcher"; + +import { manifest } from "@/manifest.gen"; + +import { fetchDesks } from "./desks-data"; + +type QueryClient = RouteLoadContext["queryClient"]; + +/** + * Desks are AdminCP-managed containers, so they get a record's freshness and + * are shared across pages: the desks index, the "new letter" picker and a + * desk page all read the same entries. A letter page and a desk's list + * read the API directly; the `/letters` list is cached in + * `letter-list.ts`, and the create and edit pages expire it. + */ +const deskQueryKeys = { + desk: (idOrSlug: string) => [manifest.id, "desk", idOrSlug] as const, + desks: () => [manifest.id, "desks"] as const, +}; + +export const desksQueryOptions = () => ({ + meta: LOADER_DATA_QUERY_META, + queryFn: fetchDesks, + queryKey: deskQueryKeys.desks(), + staleTime: RECORD_STALE_TIME, +}); + +const fetchDesk = async (idOrSlug: string) => { + const res = await fetcher({ + plugin: manifest.id, + method: "get", + module: "content/desks", + path: "/{idOrSlug}", + args: { params: { idOrSlug: encodeURIComponent(idOrSlug) } }, + }); + + if (res.status === 404) return null; + if (res.status !== 200) throw new Error("Loading the desk failed."); + + return await res.json(); +}; + +const deskQueryOptions = (idOrSlug: string) => ({ + meta: LOADER_DATA_QUERY_META, + queryFn: async () => await fetchDesk(idOrSlug), + queryKey: deskQueryKeys.desk(idOrSlug), + // A miss is not cached, so a desk created a moment ago is found next time. + staleTime: ({ state }: { state: { data: unknown } }) => + state.data ? RECORD_STALE_TIME : 0, +}); + +/** + * The desk by id or slug, seeded under its other key as well - the desk page + * asks by slug, a letter page by id. A slug made only of digits is skipped: + * the route reads it as an id, so it would answer for a different desk. + */ +export const queryDesk = async (queryClient: QueryClient, idOrSlug: string) => { + const data = await queryClient.query(deskQueryOptions(idOrSlug)); + if (!data) return data; + + const { id, slug } = data.container; + // Through `query` with the data already in hand, so the copy is built from + // the same options - `meta` included, which keeps it out of the SSR payload. + // A fresh entry is left as it is; a stale one takes this newer copy. + await Promise.all( + [String(id), slug] + .filter(key => key !== idOrSlug && !(key === slug && /^\d+$/.test(slug))) + .map( + async key => + await queryClient.query({ + ...deskQueryOptions(key), + queryFn: () => data, + }), + ), + ); + + return data; +}; + +/** The desk's id from the cached desks list, when a page has already read it. */ +export const cachedDeskId = ( + queryClient: QueryClient, + slug: string, +): null | number => + queryClient + .getQueryData<Awaited<ReturnType<typeof fetchDesks>>>(deskQueryKeys.desks()) + ?.find(desk => desk.slug === slug)?.id ?? null; diff --git a/plugins/newsroom/src/pages/desk-search.ts b/plugins/newsroom/src/pages/desk-search.ts new file mode 100644 index 0000000000..fbf01d51c9 --- /dev/null +++ b/plugins/newsroom/src/pages/desk-search.ts @@ -0,0 +1,15 @@ +export interface DeskSearch { + cursor?: string; +} + +const CURSOR = /^[A-Za-z0-9_-]{1,512}$/; + +export const deskSearch = (input: Record<string, unknown>): DeskSearch => + typeof input.cursor === "string" && CURSOR.test(input.cursor) + ? { cursor: input.cursor } + : {}; + +export const DESKS_HREF = "/desks"; + +export const deskHref = (slug: string, { cursor }: DeskSearch = {}): string => + `/desks/${encodeURIComponent(slug)}${cursor ? `?cursor=${encodeURIComponent(cursor)}` : ""}`; diff --git a/plugins/example/src/pages/board-tree.tsx b/plugins/newsroom/src/pages/desk-tree.tsx similarity index 71% rename from plugins/example/src/pages/board-tree.tsx rename to plugins/newsroom/src/pages/desk-tree.tsx index 4d43656ac4..e63e262d85 100644 --- a/plugins/example/src/pages/board-tree.tsx +++ b/plugins/newsroom/src/pages/desk-tree.tsx @@ -2,13 +2,13 @@ import type { ContentContainerTreeNode } from "@vitnode/core/content"; import { HrefLink } from "@vitnode/core/tanstack/link"; -import type { Board } from "./boards-data"; +import type { Desk } from "./desks-data"; -import { boardHref } from "./board-search"; -import { boardColor } from "./boards-data"; +import { deskHref } from "./desk-search"; +import { deskColor } from "./desks-data"; -export const BoardColorDot = ({ board }: { board: Board }) => { - const color = boardColor(board); +export const DeskColorDot = ({ desk }: { desk: Desk }) => { + const color = deskColor(desk); return ( <span @@ -19,20 +19,20 @@ export const BoardColorDot = ({ board }: { board: Board }) => { ); }; -const BoardTreeItems = ({ +const DeskTreeItems = ({ nodes, }: { - nodes: ContentContainerTreeNode<Board>[]; + nodes: ContentContainerTreeNode<Desk>[]; }) => ( <ul className="flex flex-col gap-2"> {nodes.map(node => ( <li className="flex flex-col gap-2" key={node.id}> <HrefLink className="hover:bg-accent hover:text-accent-foreground focus-visible:ring-ring flex flex-col gap-1 rounded-lg border p-4 transition-colors focus-visible:ring-2 focus-visible:outline-none" - href={boardHref(node.slug)} + href={deskHref(node.slug)} > <span className="text-foreground flex items-center gap-2 font-medium text-balance"> - <BoardColorDot board={node} /> + <DeskColorDot desk={node} /> {node.name} </span> {node.metadata.description ? ( @@ -44,7 +44,7 @@ const BoardTreeItems = ({ {node.children.length > 0 ? ( <div className="border-l pl-4 md:pl-6"> - <BoardTreeItems nodes={node.children} /> + <DeskTreeItems nodes={node.children} /> </div> ) : null} </li> @@ -52,14 +52,14 @@ const BoardTreeItems = ({ </ul> ); -export const BoardTree = ({ +export const DeskTree = ({ label, nodes, }: { label: string; - nodes: ContentContainerTreeNode<Board>[]; + nodes: ContentContainerTreeNode<Desk>[]; }) => ( <nav aria-label={label}> - <BoardTreeItems nodes={nodes} /> + <DeskTreeItems nodes={nodes} /> </nav> ); diff --git a/plugins/newsroom/src/pages/desks-data.ts b/plugins/newsroom/src/pages/desks-data.ts new file mode 100644 index 0000000000..1b0c3eab57 --- /dev/null +++ b/plugins/newsroom/src/pages/desks-data.ts @@ -0,0 +1,28 @@ +import { fetcher } from "@vitnode/core/tanstack/fetcher"; + +import { manifest } from "@/manifest.gen"; + +export const fetchDesks = async () => { + const res = await fetcher({ + plugin: manifest.id, + method: "get", + module: "content/desks", + path: "/", + args: { query: {} }, + }); + + if (res.status !== 200) throw new Error("Loading the desks failed."); + + const { items } = await res.json(); + + return items; +}; + +export type Desk = Awaited<ReturnType<typeof fetchDesks>>[number]; + +const HEX_COLOR = /^#(?:[0-9a-f]{3}|[0-9a-f]{6})$/i; + +export const deskColor = (desk: Pick<Desk, "metadata">): null | string => + typeof desk.metadata.color === "string" && HEX_COLOR.test(desk.metadata.color) + ? desk.metadata.color + : null; diff --git a/plugins/example/src/pages/boards-page.tsx b/plugins/newsroom/src/pages/desks-page.tsx similarity index 63% rename from plugins/example/src/pages/boards-page.tsx rename to plugins/newsroom/src/pages/desks-page.tsx index 4c678d43c2..9b94496384 100644 --- a/plugins/example/src/pages/boards-page.tsx +++ b/plugins/newsroom/src/pages/desks-page.tsx @@ -18,50 +18,50 @@ import { import { FolderTreeIcon } from "lucide-react"; import { useTranslations } from "use-intl"; -import { BoardTree } from "./board-tree"; -import { discussionsQueryOptions } from "./discussion-list"; -import { discussionNewHref } from "./discussion-search"; -import { boardsQueryOptions } from "./example-queries"; -import { LatestDiscussions } from "./latest-discussions"; +import { desksQueryOptions } from "./desk-queries"; +import { DeskTree } from "./desk-tree"; +import { LatestLetters } from "./latest-letters"; +import { lettersQueryOptions } from "./letter-list"; +import { letterNewHref } from "./letter-search"; -const loadBoards = async ( +const loadDesks = async ( queryClient: RouteLoadContext["queryClient"], locale: string, ) => { - const [boards] = await Promise.all([ - queryClient.query(boardsQueryOptions()), + const [desks] = await Promise.all([ + queryClient.query(desksQueryOptions()), // No `LOADER_DATA_QUERY_META`: the entry is dehydrated, so - // `LatestDiscussions` finds it in the browser and renders on the server. + // `LatestLetters` finds it in the browser and renders on the server. queryClient - .query(discussionsQueryOptions({ locale, search: {} })) + .query(lettersQueryOptions({ locale, search: {} })) .catch(() => null), ]); - return { tree: contentContainerTree(boards) }; + return { tree: contentContainerTree(desks) }; }; -type BoardsPageData = Awaited<ReturnType<typeof loadBoards>>; +type DesksPageData = Awaited<ReturnType<typeof loadDesks>>; -const BoardsBreadcrumb = () => { - const t = useTranslations("@vitnode/example.boards"); +const DesksBreadcrumb = () => { + const t = useTranslations("newsroom.desks"); return <span>{t("title")}</span>; }; -export const route = defineRoute<BoardsPageData>({ +export const route = defineRoute<DesksPageData>({ load: async ({ context }) => - await loadBoards(context.queryClient, context.locale), + await loadDesks(context.queryClient, context.locale), head: ({ t }) => ({ - description: t("@vitnode/example.boards.desc"), - title: t("@vitnode/example.boards.title"), + description: t("newsroom.desks.desc"), + title: t("newsroom.desks.title"), }), - breadcrumb: BoardsBreadcrumb, + breadcrumb: DesksBreadcrumb, }); -const BoardsPage = ({ loaderData }: PluginRoutePageProps<BoardsPageData>) => { - const t = useTranslations("@vitnode/example.boards"); +const DesksPage = ({ loaderData }: PluginRoutePageProps<DesksPageData>) => { + const t = useTranslations("newsroom.desks"); return ( <div className="container mx-auto flex max-w-3xl flex-col gap-6 px-4 py-8 md:gap-8 md:py-12"> @@ -77,9 +77,9 @@ const BoardsPage = ({ loaderData }: PluginRoutePageProps<BoardsPageData>) => { <HrefLink className={buttonVariants({ className: "self-start" })} - href={discussionNewHref()} + href={letterNewHref()} > - {t("new_discussion")} + {t("new_letter")} </HrefLink> </header> @@ -102,12 +102,12 @@ const BoardsPage = ({ loaderData }: PluginRoutePageProps<BoardsPageData>) => { </EmptyContent> </Empty> ) : ( - <BoardTree label={t("tree")} nodes={loaderData.tree} /> + <DeskTree label={t("tree")} nodes={loaderData.tree} /> )} - <LatestDiscussions /> + <LatestLetters /> </div> ); }; -export default BoardsPage; +export default DesksPage; diff --git a/plugins/example/src/pages/editor-showcase.ts b/plugins/newsroom/src/pages/editor-showcase.ts similarity index 100% rename from plugins/example/src/pages/editor-showcase.ts rename to plugins/newsroom/src/pages/editor-showcase.ts diff --git a/plugins/example/src/pages/embed-page.tsx b/plugins/newsroom/src/pages/embed-page.tsx similarity index 100% rename from plugins/example/src/pages/embed-page.tsx rename to plugins/newsroom/src/pages/embed-page.tsx diff --git a/plugins/example/src/pages/event-page.tsx b/plugins/newsroom/src/pages/event-page.tsx similarity index 94% rename from plugins/example/src/pages/event-page.tsx rename to plugins/newsroom/src/pages/event-page.tsx index 5016bd9dbd..ae38201e13 100644 --- a/plugins/example/src/pages/event-page.tsx +++ b/plugins/newsroom/src/pages/event-page.tsx @@ -11,7 +11,7 @@ import { z } from "zod"; import { manifest } from "@/manifest.gen"; -import { ContentArticle } from "./content-article"; +import { ContentStory } from "./content-story"; const zodEvent = z.object({ body: zodRichTextDocument.nullable(), @@ -61,10 +61,10 @@ export const route = definePluginRoute({ const EventPage = ({ loaderData: { item, locale }, }: PluginRoutePageProps<EventPageData>) => { - const t = useTranslations("@vitnode/example.events"); + const t = useTranslations("newsroom.events"); return ( - <ContentArticle + <ContentStory lang={locale} publishedAt={item.publishedAt} title={item.title} @@ -99,7 +99,7 @@ const EventPage = ({ </ul> </section> ) : null} - </ContentArticle> + </ContentStory> ); }; diff --git a/plugins/example/src/pages/advanced-article-page.tsx b/plugins/newsroom/src/pages/explainer-page.tsx similarity index 74% rename from plugins/example/src/pages/advanced-article-page.tsx rename to plugins/newsroom/src/pages/explainer-page.tsx index 376952f7fe..7f30208c85 100644 --- a/plugins/example/src/pages/advanced-article-page.tsx +++ b/plugins/newsroom/src/pages/explainer-page.tsx @@ -12,9 +12,9 @@ import { z } from "zod"; import { manifest } from "@/manifest.gen"; -import { ContentArticle } from "./content-article"; +import { ContentStory } from "./content-story"; -const zodAdvancedArticle = z.object({ +const zodExplainer = z.object({ faq: z .array(z.object({ answer: z.string(), question: z.string() })) .nullable() @@ -27,7 +27,7 @@ const zodAdvancedArticle = z.object({ title: z.string(), }); -const loadAdvancedArticle = async ({ +const loadExplainer = async ({ locale, slug, }: { @@ -38,7 +38,7 @@ const loadAdvancedArticle = async ({ fetcher({ plugin: manifest.id, method: "get", - module: "content/advanced-articles", + module: "content/explainers", path: "/delivery/resolve/{slug}", args: { params: { slug: encodeURIComponent(slug) }, @@ -48,7 +48,7 @@ const loadAdvancedArticle = async ({ fetcher({ plugin: manifest.id, method: "get", - module: "content/advanced-articles", + module: "content/explainers", path: "/{slug}", args: { params: { slug: encodeURIComponent(slug) }, @@ -59,24 +59,22 @@ const loadAdvancedArticle = async ({ if (resolution.status !== 200) { throw new Error( - `Resolving the advanced article "${slug}" answered ${resolution.status}.`, + `Resolving the explainer "${slug}" answered ${resolution.status}.`, ); } return contentDeliveryPage({ item: - detail.status === 200 - ? zodAdvancedArticle.parse(await detail.json()) - : null, + detail.status === 200 ? zodExplainer.parse(await detail.json()) : null, resolution: await resolution.json(), }); }; -type AdvancedArticlePageData = Awaited<ReturnType<typeof loadAdvancedArticle>>; +type ExplainerPageData = Awaited<ReturnType<typeof loadExplainer>>; export const route = definePluginRoute({ load: async ({ context, params }) => - await loadAdvancedArticle({ locale: context.locale, slug: params.slug }), + await loadExplainer({ locale: context.locale, slug: params.slug }), head: ({ loaderData }) => contentDeliveryPageHead(loaderData?.metadata, { @@ -84,14 +82,14 @@ export const route = definePluginRoute({ }), }); -const AdvancedArticlePage = ({ +const ExplainerPage = ({ loaderData: { item, metadata }, -}: PluginRoutePageProps<AdvancedArticlePageData>) => { - const t = useTranslations("@vitnode/example.articles"); +}: PluginRoutePageProps<ExplainerPageData>) => { + const t = useTranslations("newsroom.stories"); const faq = item.faq ?? []; return ( - <ContentArticle + <ContentStory lang={metadata.locale ?? undefined} lead={item.seo?.description} publishedAt={item.publishedAt} @@ -99,12 +97,12 @@ const AdvancedArticlePage = ({ > {faq.length > 0 ? ( <section - aria-labelledby="advanced-article-faq" + aria-labelledby="explainer-faq" className="flex flex-col gap-4" > <h2 className="text-foreground text-xl font-semibold tracking-tight text-balance" - id="advanced-article-faq" + id="explainer-faq" > {t("faq")} </h2> @@ -123,8 +121,8 @@ const AdvancedArticlePage = ({ </dl> </section> ) : null} - </ContentArticle> + </ContentStory> ); }; -export default AdvancedArticlePage; +export default ExplainerPage; diff --git a/plugins/example/src/pages/guide-index-page.tsx b/plugins/newsroom/src/pages/guide-index-page.tsx similarity index 90% rename from plugins/example/src/pages/guide-index-page.tsx rename to plugins/newsroom/src/pages/guide-index-page.tsx index b6035c6115..62e2f1cf80 100644 --- a/plugins/example/src/pages/guide-index-page.tsx +++ b/plugins/newsroom/src/pages/guide-index-page.tsx @@ -1,7 +1,7 @@ import { useTranslations } from "use-intl"; const GuideIndexPage = () => { - const t = useTranslations("@vitnode/example.guide"); + const t = useTranslations("newsroom.guide"); return ( <div className="flex flex-col gap-4"> diff --git a/plugins/example/src/pages/guide-layout.tsx b/plugins/newsroom/src/pages/guide-layout.tsx similarity index 88% rename from plugins/example/src/pages/guide-layout.tsx rename to plugins/newsroom/src/pages/guide-layout.tsx index 35bfbf3240..ac4739ef0e 100644 --- a/plugins/example/src/pages/guide-layout.tsx +++ b/plugins/newsroom/src/pages/guide-layout.tsx @@ -2,7 +2,7 @@ import { definePluginRoute } from "@vitnode/core/routing"; import { useTranslations } from "use-intl"; const GuideLayout = ({ children }: { children: React.ReactNode }) => { - const t = useTranslations("@vitnode/example.guide"); + const t = useTranslations("newsroom.guide"); return ( <div className="container mx-auto flex max-w-2xl flex-col gap-6 p-4"> @@ -22,7 +22,7 @@ const GuideLayout = ({ children }: { children: React.ReactNode }) => { }; const GuideBreadcrumb = () => { - const t = useTranslations("@vitnode/example.guide"); + const t = useTranslations("newsroom.guide"); return <span>{t("title")}</span>; }; diff --git a/plugins/example/src/pages/guide-topic-page.tsx b/plugins/newsroom/src/pages/guide-topic-page.tsx similarity index 97% rename from plugins/example/src/pages/guide-topic-page.tsx rename to plugins/newsroom/src/pages/guide-topic-page.tsx index 446580e375..256de764ab 100644 --- a/plugins/example/src/pages/guide-topic-page.tsx +++ b/plugins/newsroom/src/pages/guide-topic-page.tsx @@ -25,7 +25,7 @@ const GuideTopicPage = ({ loaderData, search, }: PluginRoutePageProps<Topic, TopicSearch>) => { - const t = useTranslations("@vitnode/example.guide"); + const t = useTranslations("newsroom.guide"); return ( <article className="flex flex-col gap-3"> diff --git a/plugins/newsroom/src/pages/home-page.tsx b/plugins/newsroom/src/pages/home-page.tsx new file mode 100644 index 0000000000..123cbdca22 --- /dev/null +++ b/plugins/newsroom/src/pages/home-page.tsx @@ -0,0 +1,47 @@ +import type { PluginRoutePageProps } from "@vitnode/core/routing"; + +import { definePluginRoute } from "@vitnode/core/routing"; +import { fetcher } from "@vitnode/core/tanstack/fetcher"; +import { useTranslations } from "use-intl"; + +import { manifest } from "@/manifest.gen"; + +interface HelloMessage { + message: string; +} + +export const route = definePluginRoute<HelloMessage>({ + load: async () => { + const response = await fetcher({ + plugin: manifest.id, + method: "get", + module: "hello", + path: "/", + }); + + return await response.json(); + }, +}); + +const HomePage = ({ loaderData }: PluginRoutePageProps<HelloMessage>) => { + const t = useTranslations("newsroom"); + + return ( + <div className="container mx-auto flex max-w-2xl flex-col gap-4 p-4"> + <h1 className="text-2xl font-semibold tracking-tight text-balance"> + {t("home.title")} + </h1> + + <p className="text-muted-foreground leading-relaxed text-pretty"> + {t("home.desc")} + </p> + + <div className="bg-card text-card-foreground flex flex-col gap-1 rounded-lg border p-4"> + <span className="text-muted-foreground text-sm">{t("home.api")}</span> + <span className="font-medium">{loaderData.message}</span> + </div> + </div> + ); +}; + +export default HomePage; diff --git a/plugins/newsroom/src/pages/latest-articles.tsx b/plugins/newsroom/src/pages/latest-articles.tsx new file mode 100644 index 0000000000..33b6accf53 --- /dev/null +++ b/plugins/newsroom/src/pages/latest-articles.tsx @@ -0,0 +1,70 @@ +import { useQuery } from "@tanstack/react-query"; +import { Skeleton } from "@vitnode/core/components/ui/skeleton"; +import { ContentListResults } from "@vitnode/core/tanstack/content"; +import { HrefLink } from "@vitnode/core/tanstack/link"; +import { useLocale, useTranslations } from "use-intl"; + +import { articlesQueryOptions } from "./article-list"; + +/** How many articles the box shows, out of the first page of `/news`. */ +const LATEST_COUNT = 5; + +/** The newest published articles, without the one being read. */ +export const LatestArticles = ({ currentId }: { currentId?: number }) => { + const t = useTranslations("newsroom.articles.latest"); + const locale = useLocale(); + const { data, isError, isFetching } = useQuery( + articlesQueryOptions({ locale, search: {} }), + ); + const items = data?.edges + .filter(article => article.id !== currentId) + .slice(0, LATEST_COUNT); + + return ( + <section aria-labelledby="latest-articles" className="flex flex-col gap-3"> + <div className="flex items-center justify-between gap-4"> + <h2 className="text-xl font-semibold text-balance" id="latest-articles"> + {t("title")} + </h2> + <HrefLink className="text-sm font-medium hover:underline" href="/news"> + {t("all")} + </HrefLink> + </div> + + <ContentListResults + className="flex flex-col divide-y rounded-lg border" + empty={<p className="text-muted-foreground text-sm">{t("empty")}</p>} + error={<p className="text-muted-foreground text-sm">{t("error")}</p>} + getKey={article => article.id} + isError={isError} + isFetching={isFetching} + isFiltered={false} + itemClassName="px-4 py-3" + items={items} + label={t("title")} + loading={ + <div + aria-busy="true" + className="flex flex-col gap-3 rounded-lg border p-4" + > + <span className="sr-only" role="status"> + {t("loading")} + </span> + {[0, 1, 2].map(key => ( + <Skeleton className="h-5 w-2/3" key={key} /> + ))} + </div> + } + noResults={null} + renderItem={article => ( + <HrefLink + className="font-medium hover:underline" + href={`/news/${article.slug}`} + > + {article.title} + </HrefLink> + )} + /> + </section> + ); +}; diff --git a/plugins/example/src/pages/latest-discussions.tsx b/plugins/newsroom/src/pages/latest-letters.tsx similarity index 64% rename from plugins/example/src/pages/latest-discussions.tsx rename to plugins/newsroom/src/pages/latest-letters.tsx index 7608fdc5b1..61d7885188 100644 --- a/plugins/example/src/pages/latest-discussions.tsx +++ b/plugins/newsroom/src/pages/latest-letters.tsx @@ -4,35 +4,32 @@ import { ContentListResults } from "@vitnode/core/tanstack/content"; import { HrefLink } from "@vitnode/core/tanstack/link"; import { useLocale, useTranslations } from "use-intl"; -import { DISCUSSIONS_HREF, discussionsQueryOptions } from "./discussion-list"; -import { discussionHref } from "./discussion-search"; +import { LETTERS_HREF, lettersQueryOptions } from "./letter-list"; +import { letterHref } from "./letter-search"; /** - * The five newest discussions, for any page. Shares its cache entry with the - * first page of `/discussions`, so warming either warms both. + * The five newest letters, for any page. Shares its cache entry with the + * first page of `/letters`, so warming either warms both. */ -export const LatestDiscussions = () => { - const t = useTranslations("@vitnode/example.boards.latest"); +export const LatestLetters = () => { + const t = useTranslations("newsroom.desks.latest"); const locale = useLocale(); const { data, isError, isFetching } = useQuery( - discussionsQueryOptions({ locale, search: {} }), + lettersQueryOptions({ locale, search: {} }), ); return ( - <section - aria-labelledby="latest-discussions" - className="flex flex-col gap-3" - > + <section aria-labelledby="latest-letters" className="flex flex-col gap-3"> <div className="flex items-center justify-between gap-4"> <h2 className="text-foreground text-xl font-semibold text-balance" - id="latest-discussions" + id="latest-letters" > {t("title")} </h2> <HrefLink className={buttonVariants({ size: "sm", variant: "ghost" })} - href={DISCUSSIONS_HREF} + href={LETTERS_HREF} > {t("all")} </HrefLink> @@ -49,7 +46,7 @@ export const LatestDiscussions = () => { {t("error")} </p> } - getKey={discussion => discussion.id} + getKey={letter => letter.id} isError={isError} isFetching={isFetching} isFiltered={false} @@ -62,17 +59,15 @@ export const LatestDiscussions = () => { </p> } noResults={null} - renderItem={discussion => - discussion.slug === null ? ( - <span className="text-foreground font-medium"> - {discussion.title} - </span> + renderItem={letter => + letter.slug === null ? ( + <span className="text-foreground font-medium">{letter.title}</span> ) : ( <HrefLink className="text-foreground font-medium hover:underline" - href={discussionHref(discussion.slug)} + href={letterHref(letter.slug)} > - {discussion.title} + {letter.title} </HrefLink> ) } diff --git a/plugins/example/src/pages/discussion-actions.tsx b/plugins/newsroom/src/pages/letter-actions.tsx similarity index 70% rename from plugins/example/src/pages/discussion-actions.tsx rename to plugins/newsroom/src/pages/letter-actions.tsx index 231425db6c..356c77c355 100644 --- a/plugins/example/src/pages/discussion-actions.tsx +++ b/plugins/newsroom/src/pages/letter-actions.tsx @@ -7,13 +7,13 @@ import { ReportButton } from "@vitnode/core/tanstack/moderation"; import { PencilIcon } from "lucide-react"; import { useTranslations } from "use-intl"; -import { discussionContentType } from "@/content/discussion"; +import { letterContentType } from "@/content/letter"; import { manifest } from "@/manifest.gen"; -import { discussionEditHref } from "./discussion-search"; +import { letterEditHref } from "./letter-search"; -/** Edit and Report for one discussion, as the Content Engine allows them. */ -export const DiscussionActions = ({ +/** Edit and Report for one letter, as the Content Engine allows them. */ +export const LetterActions = ({ can, describedBy, id, @@ -21,13 +21,13 @@ export const DiscussionActions = ({ title, }: { can: ContentPermissions; - /** The id of the element that names the discussion, for screen readers. */ + /** The id of the element that names the letter, for screen readers. */ describedBy?: string; id: number; size?: "default" | "sm"; title: string; }) => { - const t = useTranslations("@vitnode/example.discussions.detail"); + const t = useTranslations("newsroom.letters.detail"); return ( <div className="flex flex-wrap items-center gap-2"> @@ -35,7 +35,7 @@ export const DiscussionActions = ({ <HrefLink aria-describedby={describedBy} className={buttonVariants({ size, variant: "outline" })} - href={discussionEditHref(id)} + href={letterEditHref(id)} > <PencilIcon aria-hidden="true" /> {t("edit")} @@ -46,7 +46,7 @@ export const DiscussionActions = ({ className="text-muted-foreground" describedBy={describedBy} preview={{ title }} - target={contentTarget(manifest.id, discussionContentType, id)} + target={contentTarget(manifest.id, letterContentType, id)} typeLabel={t("report_type")} /> ) : null} diff --git a/plugins/example/src/pages/discussion-edit-page.tsx b/plugins/newsroom/src/pages/letter-edit-page.tsx similarity index 70% rename from plugins/example/src/pages/discussion-edit-page.tsx rename to plugins/newsroom/src/pages/letter-edit-page.tsx index 00ecf26fc4..b3dbc436a8 100644 --- a/plugins/example/src/pages/discussion-edit-page.tsx +++ b/plugins/newsroom/src/pages/letter-edit-page.tsx @@ -25,31 +25,31 @@ import { useTranslations } from "use-intl"; import { manifest } from "@/manifest.gen"; import { - DISCUSSION_FIELDS, - DiscussionForm, - type DiscussionFormSchema, -} from "./discussion-form"; -import { invalidateDiscussionLists } from "./discussion-list"; -import { discussionEditHref, discussionHref } from "./discussion-search"; -import { DiscussionShell } from "./discussion-shell"; + LETTER_FIELDS, + LetterForm, + type LetterFormSchema, +} from "./letter-form"; +import { invalidateLetterLists } from "./letter-list"; +import { letterEditHref, letterHref } from "./letter-search"; +import { LetterShell } from "./letter-shell"; // Answers only for the owner and moderators, drafts and hidden records included. -const fetchEditableDiscussion = async (id: number) => +const fetchEditableLetter = async (id: number) => await fetcher({ plugin: manifest.id, method: "get", - module: "content/discussions", + module: "content/letters", path: "/{id}/edit", args: { params: { id } }, }); -const loadEditableDiscussion = async (param: string) => { +const loadEditableLetter = async (param: string) => { const id = Number(param); if (!Number.isSafeInteger(id) || id < 1) { return { state: "notFound" as const }; } - const res = await fetchEditableDiscussion(id); + const res = await fetchEditableLetter(id); if (res.status === 200) { return { record: await res.json(), state: "ready" as const }; @@ -58,41 +58,38 @@ const loadEditableDiscussion = async (param: string) => { if (res.status === 403) return { state: "forbidden" as const }; if (res.status === 404) return { state: "notFound" as const }; - throw new Error(`Loading discussion ${id} for editing failed.`); + throw new Error(`Loading letter ${id} for editing failed.`); }; -type DiscussionEditData = Awaited<ReturnType<typeof loadEditableDiscussion>>; +type LetterEditData = Awaited<ReturnType<typeof loadEditableLetter>>; -type EditableDiscussion = Extract< - DiscussionEditData, - { state: "ready" } ->["record"]; +type EditableLetter = Extract<LetterEditData, { state: "ready" }>["record"]; -const DiscussionEditBreadcrumb = () => { - const t = useTranslations("@vitnode/example.discussions.edit"); +const LetterEditBreadcrumb = () => { + const t = useTranslations("newsroom.letters.edit"); return <span>{t("title")}</span>; }; export const route = definePluginRoute({ - load: async ({ params }) => await loadEditableDiscussion(params.id), + load: async ({ params }) => await loadEditableLetter(params.id), head: ({ t }) => ({ robots: "noindex, nofollow", - title: t("@vitnode/example.discussions.edit.title"), + title: t("newsroom.letters.edit.title"), }), - breadcrumb: DiscussionEditBreadcrumb, + breadcrumb: LetterEditBreadcrumb, }); -const DiscussionEditForm = ({ record }: { record: EditableDiscussion }) => { - const t = useTranslations("@vitnode/example.discussions"); +const LetterEditForm = ({ record }: { record: EditableLetter }) => { + const t = useTranslations("newsroom.letters"); const queryClient = useQueryClient(); const [slug, setSlug] = useState(record.slug); const write = useContentPublicWrite({ - fields: DISCUSSION_FIELDS, + fields: LETTER_FIELDS, latestVersion: async () => { - const res = await fetchEditableDiscussion(record.id); + const res = await fetchEditableLetter(record.id); return res.status === 200 ? (await res.json()).version : null; }, @@ -100,17 +97,14 @@ const DiscussionEditForm = ({ record }: { record: EditableDiscussion }) => { version: record.version, }); - const onSubmit: AutoFormOnSubmit<DiscussionFormSchema> = async ( - values, - form, - ) => { + const onSubmit: AutoFormOnSubmit<LetterFormSchema> = async (values, form) => { const updated = await write.submit( form, async expectedVersion => await fetcher({ plugin: manifest.id, method: "patch", - module: "content/discussions", + module: "content/letters", path: "/{id}", args: { params: { id: record.id }, @@ -122,7 +116,7 @@ const DiscussionEditForm = ({ record }: { record: EditableDiscussion }) => { setSlug(updated.slug); form.reset(values); - await invalidateDiscussionLists(queryClient); + await invalidateLetterLists(queryClient); toast.success(t("edit.success.title"), { description: t("edit.success.desc", { title: updated.values.title }), }); @@ -132,7 +126,7 @@ const DiscussionEditForm = ({ record }: { record: EditableDiscussion }) => { <> {write.conflict} - <DiscussionForm + <LetterForm defaultValues={record.values} onSubmit={onSubmit} submitLabel={t("edit.submit")} @@ -144,7 +138,7 @@ const DiscussionEditForm = ({ record }: { record: EditableDiscussion }) => { className: "self-start", variant: "link", })} - href={discussionHref(slug)} + href={letterHref(slug)} > {t("edit.view")} </HrefLink> @@ -176,10 +170,10 @@ const RefusedState = ({ </div> ); -const DiscussionEditPage = ({ +const LetterEditPage = ({ loaderData, -}: PluginRoutePageProps<DiscussionEditData>) => { - const t = useTranslations("@vitnode/example.discussions"); +}: PluginRoutePageProps<LetterEditData>) => { + const t = useTranslations("newsroom.letters"); if (loaderData.state === "unauthenticated") { return ( @@ -187,7 +181,7 @@ const DiscussionEditPage = ({ action={ <HrefLink className={buttonVariants()} - href={`/login?returnTo=${encodeURIComponent(discussionEditHref(loaderData.id))}`} + href={`/login?returnTo=${encodeURIComponent(letterEditHref(loaderData.id))}`} > {t("state.unauthenticated.action")} </HrefLink> @@ -207,7 +201,7 @@ const DiscussionEditPage = ({ action={ <HrefLink className={buttonVariants({ variant: "outline" })} - href="/discussions/new" + href="/letters/new" > {t("state.back")} </HrefLink> @@ -226,10 +220,10 @@ const DiscussionEditPage = ({ } return ( - <DiscussionShell description={t("edit.desc")} title={t("edit.title")}> - <DiscussionEditForm record={loaderData.record} /> - </DiscussionShell> + <LetterShell description={t("edit.desc")} title={t("edit.title")}> + <LetterEditForm record={loaderData.record} /> + </LetterShell> ); }; -export default DiscussionEditPage; +export default LetterEditPage; diff --git a/plugins/example/src/pages/discussion-form.tsx b/plugins/newsroom/src/pages/letter-form.tsx similarity index 62% rename from plugins/example/src/pages/discussion-form.tsx rename to plugins/newsroom/src/pages/letter-form.tsx index d3a9718161..7ebc74b93c 100644 --- a/plugins/example/src/pages/discussion-form.tsx +++ b/plugins/newsroom/src/pages/letter-form.tsx @@ -17,16 +17,16 @@ import { useTranslations } from "use-intl"; import { z } from "zod"; /** The fields the forms render, so server issues on them show in place. */ -export const DISCUSSION_FIELDS = ["title", "body", "board"] as const; +export const LETTER_FIELDS = ["title", "body", "desk"] as const; -export interface DiscussionFormValues { +export interface LetterFormValues { body: RichTextDocument; title: string; } -const createDiscussionFormSchema = ( +const createLetterFormSchema = ( t: (key: "body.required" | "title.max" | "title.min") => string, - defaults: DiscussionFormValues, + defaults: LetterFormValues, ) => z.object({ title: z @@ -40,41 +40,39 @@ const createDiscussionFormSchema = ( .default(defaults.body), }); -export type DiscussionFormSchema = ReturnType< - typeof createDiscussionFormSchema ->; +export type LetterFormSchema = ReturnType<typeof createLetterFormSchema>; -export interface DiscussionBoardChoice { +export interface LetterDeskChoice { options: { label: string; value: string }[]; selected?: string; } -const createDiscussionCreateFormSchema = ( +const createLetterCreateFormSchema = ( t: ( - key: "board.required" | "body.required" | "title.max" | "title.min", + key: "body.required" | "desk.required" | "title.max" | "title.min", ) => string, ids: [string, ...string[]], selected: string | undefined, ) => { - const board = z.enum(ids, { error: t("board.required") }); + const desk = z.enum(ids, { error: t("desk.required") }); - return createDiscussionFormSchema(t, { + return createLetterFormSchema(t, { body: emptyRichTextDocument(), title: "", }).extend({ - board: + desk: selected !== undefined && ids.includes(selected) - ? board.default(selected) - : board, + ? desk.default(selected) + : desk, }); }; -export type DiscussionCreateFormSchema = ReturnType< - typeof createDiscussionCreateFormSchema +export type LetterCreateFormSchema = ReturnType< + typeof createLetterCreateFormSchema >; -const useDiscussionFields = () => { - const t = useTranslations("@vitnode/example.discussions.form"); +const useLetterFields = () => { + const t = useTranslations("newsroom.letters.form"); return [ { @@ -97,25 +95,25 @@ const useDiscussionFields = () => { ]; }; -export const DiscussionForm = ({ +export const LetterForm = ({ defaultValues = { body: emptyRichTextDocument(), title: "" }, isLeaving = false, onSubmit, submitLabel, }: { - defaultValues?: DiscussionFormValues; + defaultValues?: LetterFormValues; isLeaving?: boolean; - onSubmit: AutoFormOnSubmit<DiscussionFormSchema>; + onSubmit: AutoFormOnSubmit<LetterFormSchema>; submitLabel: string; }) => { - const t = useTranslations("@vitnode/example.discussions.form"); - const fields = useDiscussionFields(); + const t = useTranslations("newsroom.letters.form"); + const fields = useLetterFields(); return ( <AutoForm aria-busy={isLeaving} fields={fields} - formSchema={createDiscussionFormSchema(t, defaultValues)} + formSchema={createLetterFormSchema(t, defaultValues)} onSubmit={onSubmit} submitButtonProps={{ children: submitLabel, @@ -126,20 +124,20 @@ export const DiscussionForm = ({ ); }; -export const DiscussionCreateForm = ({ - boards, +export const LetterCreateForm = ({ + desks, isLeaving = false, onSubmit, submitLabel, }: { - boards: DiscussionBoardChoice; + desks: LetterDeskChoice; isLeaving?: boolean; - onSubmit: AutoFormOnSubmit<DiscussionCreateFormSchema>; + onSubmit: AutoFormOnSubmit<LetterCreateFormSchema>; submitLabel: string; }) => { - const t = useTranslations("@vitnode/example.discussions.form"); - const fields = useDiscussionFields(); - const [first, ...rest] = boards.options.map(option => option.value); + const t = useTranslations("newsroom.letters.form"); + const fields = useLetterFields(); + const [first, ...rest] = desks.options.map(option => option.value); if (first === undefined) return null; return ( @@ -147,23 +145,23 @@ export const DiscussionCreateForm = ({ aria-busy={isLeaving} fields={[ { - id: "board", + id: "desk", component: props => ( <AutoFormSelect {...props} - description={t("board.desc")} - label={t("board.label")} - labels={boards.options} - placeholder={t("board.placeholder")} + description={t("desk.desc")} + label={t("desk.label")} + labels={desks.options} + placeholder={t("desk.placeholder")} /> ), }, ...fields, ]} - formSchema={createDiscussionCreateFormSchema( + formSchema={createLetterCreateFormSchema( t, [first, ...rest], - boards.selected, + desks.selected, )} onSubmit={onSubmit} submitButtonProps={{ diff --git a/plugins/newsroom/src/pages/letter-list.ts b/plugins/newsroom/src/pages/letter-list.ts new file mode 100644 index 0000000000..4ca7c5ee69 --- /dev/null +++ b/plugins/newsroom/src/pages/letter-list.ts @@ -0,0 +1,95 @@ +import type { QueryClient } from "@tanstack/react-query"; +import type { ContentListSearch } from "@vitnode/core/tanstack/content"; + +import { + contentListIdFilter, + contentListQueryOptions, + defineContentList, + useContentPermissions, +} from "@vitnode/core/tanstack/content"; +import { fetcher } from "@vitnode/core/tanstack/fetcher"; + +import { manifest } from "@/manifest.gen"; + +export const LETTERS_HREF = "/letters"; + +/** `/letters`: newest first, 10 a page, searchable and filterable by desk. */ +export const letterList = defineContentList({ + defaultSort: "newest", + filters: { desk: contentListIdFilter }, + pageSize: 10, + sorts: { + newest: { order: "desc", orderBy: "publishedAt" }, + oldest: { order: "asc", orderBy: "publishedAt" }, + title: { order: "asc", orderBy: "title" }, + }, +}); + +export type LetterListSort = (typeof letterList.sorts)[number]; + +export type LetterListSearch = ContentListSearch< + LetterListSort, + { desk: typeof contentListIdFilter } +>; + +export const letterListSearch = ( + input: Record<string, unknown>, +): LetterListSearch => letterList.parseSearch(input); + +const letterQueryKeys = { + all: [manifest.id, "letters"] as const, +}; + +export const lettersQueryOptions = (request: { + locale: string; + search: LetterListSearch; +}) => + contentListQueryOptions({ + ...request, + queryKey: letterQueryKeys.all, + fetch: async ({ search }) => { + const res = await fetcher({ + plugin: manifest.id, + method: "get", + module: "content/letters", + path: "/", + args: { + query: { + ...letterList.apiQuery(search), + ...(search.desk === undefined + ? {} + : { desk: String(search.desk), includeDescendants: "true" }), + }, + }, + }); + if (res.status !== 200) { + throw new Error(`Loading letters answered ${res.status}.`); + } + + return await res.json(); + }, + }); + +/** Expires every cached letter list, after a member posts or edits one. */ +export const invalidateLetterLists = async (queryClient: QueryClient) => { + await queryClient.invalidateQueries({ queryKey: letterQueryKeys.all }); +}; + +/** What the visitor may do with each letter on screen: `canEdit`, `canReport`. */ +export const useLetterPermissions = (ids: readonly number[]) => + useContentPermissions({ + ids, + queryKey: letterQueryKeys.all, + fetch: async request => { + const res = await fetcher({ + plugin: manifest.id, + method: "get", + module: "content/letters", + path: "/permissions", + args: { query: { ids: request.join(",") } }, + }); + if (res.status !== 200) throw new Error("Loading permissions failed."); + + return await res.json(); + }, + }); diff --git a/plugins/newsroom/src/pages/letter-new-page.tsx b/plugins/newsroom/src/pages/letter-new-page.tsx new file mode 100644 index 0000000000..d3d58c427d --- /dev/null +++ b/plugins/newsroom/src/pages/letter-new-page.tsx @@ -0,0 +1,141 @@ +import type { AutoFormOnSubmit } from "@vitnode/core/components/form/auto-form"; +import type { PluginRoutePageProps } from "@vitnode/core/routing"; + +import { useQueryClient } from "@tanstack/react-query"; +import { buttonVariants } from "@vitnode/core/components/ui/button"; +import { + Empty, + EmptyContent, + EmptyDescription, + EmptyHeader, + EmptyMedia, + EmptyTitle, +} from "@vitnode/core/components/ui/empty"; +import { useContentPublicWrite } from "@vitnode/core/tanstack/content"; +import { fetcher } from "@vitnode/core/tanstack/fetcher"; +import { HrefLink, useHrefNavigate } from "@vitnode/core/tanstack/link"; +import { + defineRoute, + type RouteLoadContext, +} from "@vitnode/core/tanstack/plugin-routes"; +import { FolderTreeIcon } from "lucide-react"; +import { useState } from "react"; +import { useTranslations } from "use-intl"; + +import { manifest } from "@/manifest.gen"; + +import type { LetterNewSearch } from "./letter-search"; + +import { desksQueryOptions } from "./desk-queries"; +import { DESKS_HREF } from "./desk-search"; +import { + LETTER_FIELDS, + LetterCreateForm, + type LetterCreateFormSchema, +} from "./letter-form"; +import { invalidateLetterLists } from "./letter-list"; +import { letterEditHref, letterHref } from "./letter-search"; +import { LetterShell } from "./letter-shell"; + +const loadDeskOptions = async (queryClient: RouteLoadContext["queryClient"]) => + (await queryClient.query(desksQueryOptions())).map(desk => ({ + label: `${"— ".repeat(desk.depth - 1)}${desk.name}`, + value: String(desk.id), + })); + +type LetterNewData = Awaited<ReturnType<typeof loadDeskOptions>>; + +const LetterNewBreadcrumb = () => { + const t = useTranslations("newsroom.letters.new"); + + return <span>{t("title")}</span>; +}; + +export const route = defineRoute<LetterNewData, LetterNewSearch>({ + load: async ({ context }) => await loadDeskOptions(context.queryClient), + + head: ({ t }) => ({ + description: t("newsroom.letters.new.desc"), + robots: "noindex, nofollow", + title: t("newsroom.letters.new.title"), + }), + + breadcrumb: LetterNewBreadcrumb, +}); + +const LetterNewPage = ({ + loaderData: options, + search, +}: PluginRoutePageProps<LetterNewData, LetterNewSearch>) => { + const t = useTranslations("newsroom.letters"); + const write = useContentPublicWrite({ + fields: LETTER_FIELDS, + uniqueField: "title", + }); + const [isLeaving, setIsLeaving] = useState(false); + const navigate = useHrefNavigate(); + const queryClient = useQueryClient(); + + const onSubmit: AutoFormOnSubmit<LetterCreateFormSchema> = async ( + { desk, ...values }, + form, + ) => { + const record = await write.submit( + form, + async () => + await fetcher({ + plugin: manifest.id, + method: "post", + module: "content/letters", + path: "/", + args: { body: { values: { ...values, desk: Number(desk) } } }, + }), + ); + if (!record) return; + + setIsLeaving(true); + await invalidateLetterLists(queryClient); + await navigate( + record.slug === null + ? letterEditHref(record.id) + : letterHref(record.slug, { created: true }), + ); + }; + + return ( + <LetterShell description={t("new.desc")} title={t("new.title")}> + {options.length === 0 ? ( + <Empty className="border"> + <EmptyHeader> + <EmptyMedia variant="icon"> + <FolderTreeIcon aria-hidden="true" /> + </EmptyMedia> + <EmptyTitle render={<h2 />}>{t("new.no_desks.title")}</EmptyTitle> + <EmptyDescription>{t("new.no_desks.desc")}</EmptyDescription> + </EmptyHeader> + <EmptyContent> + <HrefLink + className={buttonVariants({ variant: "outline" })} + href={DESKS_HREF} + > + {t("new.no_desks.action")} + </HrefLink> + </EmptyContent> + </Empty> + ) : ( + <LetterCreateForm + desks={{ + options, + selected: + search.desk === undefined ? undefined : String(search.desk), + }} + isLeaving={isLeaving} + onSubmit={onSubmit} + submitLabel={t("new.submit")} + /> + )} + </LetterShell> + ); +}; + +export default LetterNewPage; diff --git a/plugins/example/src/pages/discussion-page.tsx b/plugins/newsroom/src/pages/letter-page.tsx similarity index 65% rename from plugins/example/src/pages/discussion-page.tsx rename to plugins/newsroom/src/pages/letter-page.tsx index befdddda89..8d6e2b80cb 100644 --- a/plugins/example/src/pages/discussion-page.tsx +++ b/plugins/newsroom/src/pages/letter-page.tsx @@ -28,75 +28,75 @@ import { z } from "zod"; import { manifest } from "@/manifest.gen"; -import type { DiscussionSearch } from "./discussion-search"; +import type { LetterSearch } from "./letter-search"; -import { boardHref, BOARDS_HREF } from "./board-search"; -import { ContentArticle } from "./content-article"; -import { DiscussionActions } from "./discussion-actions"; -import { useDiscussionPermissions } from "./discussion-list"; -import { discussionNewHref } from "./discussion-search"; -import { queryBoard } from "./example-queries"; +import { ContentStory } from "./content-story"; +import { queryDesk } from "./desk-queries"; +import { deskHref, DESKS_HREF } from "./desk-search"; +import { LetterActions } from "./letter-actions"; +import { useLetterPermissions } from "./letter-list"; +import { letterNewHref } from "./letter-search"; -const zodDiscussion = z.object({ +const zodLetter = z.object({ author: zodContentPublicPerson.nullable(), - board: z.number(), + desk: z.number(), body: zodRichTextDocument, id: z.number(), publishedAt: z.string().nullable(), title: z.string(), }); -const loadDiscussion = async ( +const loadLetter = async ( queryClient: RouteLoadContext["queryClient"], slug: string, ) => { const detail = await fetcher({ plugin: manifest.id, method: "get", - module: "content/discussions", + module: "content/letters", path: "/{slug}", args: { params: { slug: encodeURIComponent(slug) } }, }); const item = contentPageItem( - detail.status === 200 ? zodDiscussion.parse(await detail.json()) : null, + detail.status === 200 ? zodLetter.parse(await detail.json()) : null, ); - // Only the author and moderators get a hidden discussion; readers get 404. + // Only the author and moderators get a hidden letter; readers get 404. const hidden = detail.headers.get(CONTENT_HIDDEN_HEADER) === "true"; - // Needs `item.board`, so it cannot start any sooner - but it is the same - // cached entry the board page reads, so moving between the two is free. - const board = await queryBoard(queryClient, String(item.board)).catch( + // Needs `item.desk`, so it cannot start any sooner - but it is the same + // cached entry the desk page reads, so moving between the two is free. + const desk = await queryDesk(queryClient, String(item.desk)).catch( () => null, ); - if (!board) return { ...item, boardTrail: [], hidden }; + if (!desk) return { ...item, deskTrail: [], hidden }; return { ...item, - boardTrail: [...board.ancestors, board.container], + deskTrail: [...desk.ancestors, desk.container], hidden, }; }; -type DiscussionPageData = Awaited<ReturnType<typeof loadDiscussion>>; +type LetterPageData = Awaited<ReturnType<typeof loadLetter>>; -export const route = defineRoute<DiscussionPageData, DiscussionSearch>({ +export const route = defineRoute<LetterPageData, LetterSearch>({ load: async ({ context, params }) => - await loadDiscussion(context.queryClient, params.slug), + await loadLetter(context.queryClient, params.slug), head: ({ loaderData }) => ({ title: loaderData?.title }), breadcrumb: ({ loaderData }) => <span>{loaderData.title}</span>, }); -const DiscussionPage = ({ +const LetterPage = ({ loaderData: item, navigate, search, -}: PluginRoutePageProps<DiscussionPageData, DiscussionSearch>) => { - const t = useTranslations("@vitnode/example.discussions.detail"); - const permissions = useDiscussionPermissions([item.id]); +}: PluginRoutePageProps<LetterPageData, LetterSearch>) => { + const t = useTranslations("newsroom.letters.detail"); + const permissions = useLetterPermissions([item.id]); const created = search.created === true; const announcedRef = useRef(false); @@ -110,23 +110,23 @@ const DiscussionPage = ({ }, [created, navigate, t]); return ( - <ContentArticle publishedAt={item.publishedAt} title={item.title}> - {item.boardTrail.length > 0 ? ( - <nav aria-label={t("board")}> + <ContentStory publishedAt={item.publishedAt} title={item.title}> + {item.deskTrail.length > 0 ? ( + <nav aria-label={t("desk")}> <ol className="text-muted-foreground flex flex-wrap items-center gap-2 text-sm leading-relaxed"> <li> - <HrefLink className="hover:text-foreground" href={BOARDS_HREF}> - {t("boards")} + <HrefLink className="hover:text-foreground" href={DESKS_HREF}> + {t("desks")} </HrefLink> </li> - {item.boardTrail.map(board => ( - <li className="flex items-center gap-2" key={board.id}> + {item.deskTrail.map(desk => ( + <li className="flex items-center gap-2" key={desk.id}> <span aria-hidden="true">/</span> <HrefLink className="hover:text-foreground" - href={boardHref(board.slug)} + href={deskHref(desk.slug)} > - {board.name} + {desk.name} </HrefLink> </li> ))} @@ -151,14 +151,14 @@ const DiscussionPage = ({ ) : null} <nav aria-label={t("actions")} className="flex flex-wrap gap-2"> - <DiscussionActions + <LetterActions can={permissions.of(item.id)} id={item.id} title={item.title} /> <HrefLink className={buttonVariants({ variant: "ghost" })} - href={discussionNewHref(item.board)} + href={letterNewHref(item.desk)} > {t("new")} </HrefLink> @@ -168,8 +168,8 @@ const DiscussionPage = ({ className="text-foreground leading-relaxed text-pretty" content={item.body} /> - </ContentArticle> + </ContentStory> ); }; -export default DiscussionPage; +export default LetterPage; diff --git a/plugins/newsroom/src/pages/letter-search.ts b/plugins/newsroom/src/pages/letter-search.ts new file mode 100644 index 0000000000..e5c7e8d6be --- /dev/null +++ b/plugins/newsroom/src/pages/letter-search.ts @@ -0,0 +1,41 @@ +export interface LetterSearch { + created?: true; +} + +export const letterSearch = (input: Record<string, unknown>): LetterSearch => + input.created === true || input.created === "true" ? { created: true } : {}; + +export const letterHref = ( + slug: string, + { created }: LetterSearch = {}, +): string => + `/letters/${encodeURIComponent(slug)}${created ? "?created=true" : ""}`; + +export const letterEditHref = (id: number | string): string => + `/letters/${encodeURIComponent(String(id))}/edit`; + +const positiveId = (raw: unknown): number | undefined => { + const value = + typeof raw === "number" + ? raw + : typeof raw === "string" && /^[1-9]\d{0,15}$/.test(raw) + ? Number(raw) + : Number.NaN; + + return Number.isSafeInteger(value) && value > 0 ? value : undefined; +}; + +export interface LetterNewSearch { + desk?: number; +} + +export const letterNewSearch = ( + input: Record<string, unknown>, +): LetterNewSearch => { + const desk = positiveId(input.desk); + + return desk === undefined ? {} : { desk }; +}; + +export const letterNewHref = (desk?: number): string => + desk === undefined ? "/letters/new" : `/letters/new?desk=${desk}`; diff --git a/plugins/example/src/pages/discussion-shell.tsx b/plugins/newsroom/src/pages/letter-shell.tsx similarity index 93% rename from plugins/example/src/pages/discussion-shell.tsx rename to plugins/newsroom/src/pages/letter-shell.tsx index d6b921b5d9..b96b812f59 100644 --- a/plugins/example/src/pages/discussion-shell.tsx +++ b/plugins/newsroom/src/pages/letter-shell.tsx @@ -1,4 +1,4 @@ -export const DiscussionShell = ({ +export const LetterShell = ({ children, description, title, diff --git a/plugins/example/src/pages/discussions-page.tsx b/plugins/newsroom/src/pages/letters-page.tsx similarity index 65% rename from plugins/example/src/pages/discussions-page.tsx rename to plugins/newsroom/src/pages/letters-page.tsx index 6d96c42be3..09bc6bcb40 100644 --- a/plugins/example/src/pages/discussions-page.tsx +++ b/plugins/newsroom/src/pages/letters-page.tsx @@ -36,51 +36,51 @@ import { import React from "react"; import { useTranslations } from "use-intl"; -import type { DiscussionListSearch } from "./discussion-list"; +import type { LetterListSearch } from "./letter-list"; -import { DiscussionActions } from "./discussion-actions"; +import { desksQueryOptions } from "./desk-queries"; +import { LetterActions } from "./letter-actions"; import { - discussionList, - DISCUSSIONS_HREF, - discussionsQueryOptions, - useDiscussionPermissions, -} from "./discussion-list"; -import { discussionHref, discussionNewHref } from "./discussion-search"; -import { boardsQueryOptions } from "./example-queries"; + letterList, + LETTERS_HREF, + lettersQueryOptions, + useLetterPermissions, +} from "./letter-list"; +import { letterHref, letterNewHref } from "./letter-search"; -const loadDiscussions = async ( +const loadLetters = async ( queryClient: RouteLoadContext["queryClient"], - { locale, search }: { locale: string; search: DiscussionListSearch }, + { locale, search }: { locale: string; search: LetterListSearch }, ) => { // A failed list is an error state on the page, not an error page. - const [discussions, boards] = await Promise.all([ + const [letters, desks] = await Promise.all([ queryClient .query({ - ...discussionsQueryOptions({ locale, search }), + ...lettersQueryOptions({ locale, search }), meta: LOADER_DATA_QUERY_META, }) .catch(() => null), - queryClient.query(boardsQueryOptions()).catch(() => []), + queryClient.query(desksQueryOptions()).catch(() => []), ]); - return { boards, discussions }; + return { desks, letters }; }; -type DiscussionsPageData = Awaited<ReturnType<typeof loadDiscussions>>; +type LettersPageData = Awaited<ReturnType<typeof loadLetters>>; -export const route = defineRoute<DiscussionsPageData, DiscussionListSearch>({ +export const route = defineRoute<LettersPageData, LetterListSearch>({ load: async ({ context, search }) => - await loadDiscussions(context.queryClient, { + await loadLetters(context.queryClient, { locale: context.locale, search, }), head: ({ search, t }) => contentListPageHead({ - description: t("@vitnode/example.discussions.list.desc"), - pathname: DISCUSSIONS_HREF, + description: t("newsroom.letters.list.desc"), + pathname: LETTERS_HREF, search, - title: t("@vitnode/example.discussions.list.title"), + title: t("newsroom.letters.list.title"), }), }); @@ -105,21 +105,21 @@ const ListState = ({ </Empty> ); -const DiscussionsPage = ({ - loaderData: { boards, discussions }, +const LettersPage = ({ + loaderData: { desks, letters }, navigate, search, -}: PluginRoutePageProps<DiscussionsPageData, DiscussionListSearch>) => { - const t = useTranslations("@vitnode/example.discussions.list"); - const permissions = useDiscussionPermissions( - discussions?.edges.map(discussion => discussion.id) ?? [], +}: PluginRoutePageProps<LettersPageData, LetterListSearch>) => { + const t = useTranslations("newsroom.letters.list"); + const permissions = useLetterPermissions( + letters?.edges.map(letter => letter.id) ?? [], ); const [isPending, startTransition] = React.useTransition(); - const update = (patch: Partial<DiscussionListSearch>) => { + const update = (patch: Partial<LetterListSearch>) => { startTransition(async () => { await navigate({ resetScroll: false, - search: discussionList.withSearch(search, patch), + search: letterList.withSearch(search, patch), }); }); }; @@ -143,7 +143,7 @@ const DiscussionsPage = ({ </div> <HrefLink className={buttonVariants({ className: "self-start" })} - href={discussionNewHref(search.board)} + href={letterNewHref(search.desk)} > {t("new")} </HrefLink> @@ -169,17 +169,17 @@ const DiscussionsPage = ({ value={input.value} /> <NativeSelect - aria-label={t("board_label")} + aria-label={t("desk_label")} onChange={event => { - const board = Number(event.target.value); - update({ board: board > 0 ? board : undefined }); + const desk = Number(event.target.value); + update({ desk: desk > 0 ? desk : undefined }); }} - value={String(search.board ?? "")} + value={String(search.desk ?? "")} > - <NativeSelectOption value="">{t("all_boards")}</NativeSelectOption> - {boards.map(board => ( - <NativeSelectOption key={board.id} value={String(board.id)}> - {`${"— ".repeat(board.depth - 1)}${board.name}`} + <NativeSelectOption value="">{t("all_desks")}</NativeSelectOption> + {desks.map(desk => ( + <NativeSelectOption key={desk.id} value={String(desk.id)}> + {`${"— ".repeat(desk.depth - 1)}${desk.name}`} </NativeSelectOption> ))} </NativeSelect> @@ -187,14 +187,12 @@ const DiscussionsPage = ({ aria-label={t("sort_label")} onChange={event => { update({ - sort: discussionList.sorts.find( - sort => sort === event.target.value, - ), + sort: letterList.sorts.find(sort => sort === event.target.value), }); }} - value={search.sort ?? discussionList.defaultSort} + value={search.sort ?? letterList.defaultSort} > - {discussionList.sorts.map(sort => ( + {letterList.sorts.map(sort => ( <NativeSelectOption key={sort} value={sort}> {t(`sort.${sort}`)} </NativeSelectOption> @@ -209,7 +207,7 @@ const DiscussionsPage = ({ action={ <HrefLink className={buttonVariants({ variant: "outline" })} - href={discussionNewHref()} + href={letterNewHref()} > {t("new")} </HrefLink> @@ -226,12 +224,12 @@ const DiscussionsPage = ({ title={t("error.title")} /> } - getKey={discussion => discussion.id} - isError={discussions === null} + getKey={letter => letter.id} + isError={letters === null} isFetching={isPending} - isFiltered={discussionList.isFiltered(search)} + isFiltered={letterList.isFiltered(search)} itemClassName="flex flex-col gap-2 p-4 md:flex-row md:items-center md:justify-between" - items={discussions?.edges} + items={letters?.edges} label={t("title")} loading={null} noResults={ @@ -239,7 +237,7 @@ const DiscussionsPage = ({ action={ <HrefLink className={buttonVariants({ variant: "outline" })} - href={DISCUSSIONS_HREF} + href={LETTERS_HREF} > {t("no_results.clear")} </HrefLink> @@ -249,71 +247,67 @@ const DiscussionsPage = ({ title={t("no_results.title")} /> } - renderItem={discussion => { - const titleId = `discussion-${discussion.id}-title`; + renderItem={letter => { + const titleId = `letter-${letter.id}-title`; return ( <> <div className="flex min-w-0 flex-col gap-1"> - {discussion.slug === null ? ( + {letter.slug === null ? ( <span className="text-foreground font-medium text-balance" id={titleId} > - {discussion.title} + {letter.title} </span> ) : ( <HrefLink className="text-foreground font-medium text-balance hover:underline" - href={discussionHref(discussion.slug)} + href={letterHref(letter.slug)} id={titleId} > - {discussion.title} + {letter.title} </HrefLink> )} <p className="text-muted-foreground flex flex-wrap items-center gap-x-2 text-sm leading-relaxed"> - {discussion.author ? ( - <UserFormat format user={discussion.author} /> + {letter.author ? ( + <UserFormat format user={letter.author} /> ) : ( <span>{t("deleted_user")}</span> )} - {discussion.publishedAt ? ( + {letter.publishedAt ? ( <> <span aria-hidden="true">·</span> <time - dateTime={new Date( - discussion.publishedAt, - ).toISOString()} + dateTime={new Date(letter.publishedAt).toISOString()} > - <DateFormat date={discussion.publishedAt} /> + <DateFormat date={letter.publishedAt} /> </time> </> ) : null} </p> </div> - <DiscussionActions - can={permissions.of(discussion.id)} + <LetterActions + can={permissions.of(letter.id)} describedBy={titleId} - id={discussion.id} + id={letter.id} size="sm" - title={discussion.title} + title={letter.title} /> </> ); }} /> - {discussions ? ( + {letters ? ( <ContentListPagination - hrefFor={page => - discussionList.href(DISCUSSIONS_HREF, { ...search, page }) - } + hrefFor={page => letterList.href(LETTERS_HREF, { ...search, page })} label={t("pagination")} - pageInfo={discussions.pageInfo} + pageInfo={letters.pageInfo} /> ) : null} </div> ); }; -export default DiscussionsPage; +export default LettersPage; diff --git a/plugins/example/src/pages/example-page.tsx b/plugins/newsroom/src/pages/note-page.tsx similarity index 82% rename from plugins/example/src/pages/example-page.tsx rename to plugins/newsroom/src/pages/note-page.tsx index 7cfdcb73c5..8301f9de28 100644 --- a/plugins/example/src/pages/example-page.tsx +++ b/plugins/newsroom/src/pages/note-page.tsx @@ -3,12 +3,12 @@ import { type PluginRoutePageProps, } from "@vitnode/core/routing"; -export const EXAMPLE_TABS = ["summary", "details"] as const; +export const NOTE_TABS = ["summary", "details"] as const; -export type ExampleTab = (typeof EXAMPLE_TABS)[number]; +export type NoteTab = (typeof NOTE_TABS)[number]; -export interface ExampleSearch { - tab: ExampleTab; +export interface NoteSearch { + tab: NoteTab; } interface Note { @@ -16,7 +16,7 @@ interface Note { title: string; } -export const route = definePluginRoute<Note, ExampleSearch>({ +export const route = definePluginRoute<Note, NoteSearch>({ load: ({ params, search }) => ({ lines: search.tab === "summary" @@ -34,14 +34,14 @@ const NotePage = ({ loaderData, navigate, search, -}: PluginRoutePageProps<Note, ExampleSearch>) => ( +}: PluginRoutePageProps<Note, NoteSearch>) => ( <article className="container mx-auto flex max-w-3xl flex-col gap-4 p-4"> <h1 className="text-3xl font-semibold tracking-tight text-balance capitalize"> {loaderData.title} </h1> <nav aria-label="Note view" className="flex items-center gap-2"> - {EXAMPLE_TABS.map(tab => ( + {NOTE_TABS.map(tab => ( <button aria-pressed={search.tab === tab} className="aria-pressed:bg-accent aria-pressed:text-accent-foreground rounded-md border px-3 py-2 capitalize" diff --git a/plugins/newsroom/src/pages/site-page.tsx b/plugins/newsroom/src/pages/site-page.tsx new file mode 100644 index 0000000000..934d15cc83 --- /dev/null +++ b/plugins/newsroom/src/pages/site-page.tsx @@ -0,0 +1,52 @@ +import { + definePluginRoute, + type PluginRoutePageProps, +} from "@vitnode/core/routing"; +import { contentPageItem } from "@vitnode/core/tanstack/content"; +import { fetcher } from "@vitnode/core/tanstack/fetcher"; +import { createBlockRegistry } from "@vitnode/core/widgets"; +import { blocks as coreBlocks } from "@vitnode/core/widgets/built-in"; +import { ContentZone } from "@vitnode/core/widgets/zone"; + +import { PAGE_BLOCKS_ALLOWED } from "@/content/page-blocks"; +import { manifest } from "@/manifest.gen"; +import { widgets } from "@/widgets"; + +const blocksRegistry = createBlockRegistry([coreBlocks, widgets]); + +const loadSitePage = async (slug: string) => { + const response = await fetcher({ + plugin: manifest.id, + method: "get", + module: "content/pages", + path: "/{slug}", + args: { params: { slug: encodeURIComponent(slug) } }, + }); + + return contentPageItem(response.ok ? await response.json() : null); +}; + +type SitePageData = Awaited<ReturnType<typeof loadSitePage>>; + +export const route = definePluginRoute({ + load: async ({ params }) => await loadSitePage(params.slug), + + head: ({ loaderData }) => ({ title: loaderData?.title }), +}); + +const SitePage = ({ loaderData: page }: PluginRoutePageProps<SitePageData>) => ( + <article className="container mx-auto flex max-w-3xl flex-col gap-8 px-4 py-8 md:py-12"> + <h1 className="text-foreground text-3xl font-semibold tracking-tight text-balance md:text-4xl"> + {page.title} + </h1> + + <ContentZone + allowedBlocks={PAGE_BLOCKS_ALLOWED} + blocks={page.content} + id="content" + registry={blocksRegistry} + /> + </article> +); + +export default SitePage; diff --git a/plugins/example/src/pages/article-page.tsx b/plugins/newsroom/src/pages/story-page.tsx similarity index 71% rename from plugins/example/src/pages/article-page.tsx rename to plugins/newsroom/src/pages/story-page.tsx index cf7c21eb76..5254250e3e 100644 --- a/plugins/example/src/pages/article-page.tsx +++ b/plugins/newsroom/src/pages/story-page.tsx @@ -11,27 +11,27 @@ import { z } from "zod"; import { manifest } from "@/manifest.gen"; -import { ContentArticle } from "./content-article"; +import { ContentStory } from "./content-story"; -const zodArticle = z.object({ +const zodStory = z.object({ excerpt: z.string().nullable(), publishedAt: z.string().nullable(), title: z.string(), }); -const loadArticle = async (slug: string) => { +const loadStory = async (slug: string) => { const [resolution, detail] = await Promise.all([ fetcher({ plugin: manifest.id, method: "get", - module: "content/articles", + module: "content/stories", path: "/delivery/resolve/{slug}", args: { params: { slug: encodeURIComponent(slug) } }, }), fetcher({ plugin: manifest.id, method: "get", - module: "content/articles", + module: "content/stories", path: "/{slug}", args: { params: { slug: encodeURIComponent(slug) } }, }), @@ -44,15 +44,15 @@ const loadArticle = async (slug: string) => { } return contentDeliveryPage({ - item: detail.status === 200 ? zodArticle.parse(await detail.json()) : null, + item: detail.status === 200 ? zodStory.parse(await detail.json()) : null, resolution: await resolution.json(), }); }; -type ArticlePageData = Awaited<ReturnType<typeof loadArticle>>; +type StoryPageData = Awaited<ReturnType<typeof loadStory>>; export const route = definePluginRoute({ - load: async ({ params }) => await loadArticle(params.slug), + load: async ({ params }) => await loadStory(params.slug), head: ({ loaderData }) => contentDeliveryPageHead(loaderData?.metadata, { @@ -60,14 +60,14 @@ export const route = definePluginRoute({ }), }); -const ArticlePage = ({ +const StoryPage = ({ loaderData: { item }, -}: PluginRoutePageProps<ArticlePageData>) => ( - <ContentArticle +}: PluginRoutePageProps<StoryPageData>) => ( + <ContentStory lead={item.excerpt} publishedAt={item.publishedAt} title={item.title} /> ); -export default ArticlePage; +export default StoryPage; diff --git a/plugins/example/src/pages/zones-page.tsx b/plugins/newsroom/src/pages/zones-page.tsx similarity index 90% rename from plugins/example/src/pages/zones-page.tsx rename to plugins/newsroom/src/pages/zones-page.tsx index f788d7dbb1..93f56cb958 100644 --- a/plugins/example/src/pages/zones-page.tsx +++ b/plugins/newsroom/src/pages/zones-page.tsx @@ -27,11 +27,11 @@ import { toast } from "sonner"; import { z } from "zod"; import { - EXAMPLE_ZONE_IDS, mayEditSettingsPage, + NEWSROOM_ZONE_IDS, settingsPage, } from "@/content/settings-page"; -import { widgets as exampleWidgets } from "@/widgets"; +import { widgets as newsroomWidgets } from "@/widgets"; import type { ZonesSearch } from "./zones-search"; @@ -53,7 +53,7 @@ const serverRefusal = async (response: Response): Promise<string> => { } }; -const blocksRegistry = createBlockRegistry([coreBlocks, exampleWidgets]); +const blocksRegistry = createBlockRegistry([coreBlocks, newsroomWidgets]); const zodLayout = z.object({ pageId: z.string(), @@ -162,19 +162,19 @@ const ProfileForm = () => { const VIEW_MODE_CHECKS = [ "before-profile - three blocks, no wrapper: no element around them in the DOM.", "after-profile - one area with two blocks in it, one empty area, and one block beside them. The filled area renders a two-column grid; the empty one renders nothing at all, not even a gap.", - 'example:features stored with variant: "list" renders one item per row. Nothing in its data says "list".', + 'newsroom:features stored with variant: "list" renders one item per row. Nothing in its data says "list".', 'sidebar - wrapped in <aside>, so it carries data-vitnode-zone and a narrower data-vitnode-zone-allowed. The page writes neither: <ContentZone id="sidebar" /> takes both from the page definition around it.', "before-footer - empty: no markup at all, and no gap below the sidebar.", - "Network: one GET /api/vitnode/core/pages/layout?pageId=example:settings, made by the route loader. The zones themselves request nothing.", + "Network: one GET /api/vitnode/core/pages/layout?pageId=newsroom:settings, made by the route loader. The zones themselves request nothing.", "Until somebody saves, that read answers with the blocks defineEditablePage ships and updatedAt: null. They are real content, not a placeholder - which is why the first Save is allowed to write over them.", "Network, JS filter: nothing under /src/editor/ is requested. The @dnd-kit deps you may also see come from the AdminCP dashboard grid on every development route, not from this page.", ]; const EDIT_MODE_CHECKS = [ - "Edit widgets lives in the user menu in the site header, not on this page, so it is one click away wherever you are. It is shown only to a member holding the moderator permission example.widgets/can_edit - or to a root role - and only while an editable page is on screen. Sign out, or walk to a page that declares no zones, and the entry is gone.", - "Arrive at /example/zones?edit=true and the page opens in edit mode already - but only if that same permission says so. The URL asks; it never authorizes, and PUT /pages/layout asks the permission again on every save.", + "Edit widgets lives in the user menu in the site header, not on this page, so it is one click away wherever you are. It is shown only to a member holding the moderator permission newsroom:widgets:can_edit - or to a root role - and only while an editable page is on screen. Sign out, or walk to a page that declares no zones, and the entry is gone.", + "Arrive at /newsroom/zones?edit=true and the page opens in edit mode already - but only if that same permission says so. The URL asks; it never authorizes, and PUT /pages/layout asks the permission again on every save.", "The editor chunk is fetched on the first Edit widgets click and never before it. Use Finish editing and click Edit widgets again: the second click fetches nothing.", - "createContentEditorAdapter is imported normally, not lazily: it belongs to the page definition, not to the editor, and its whole graph is two files with no third-party package in it. A boundary test in this plugin asserts nothing under core's src/editor/ is in this page's eager graph even so.", + "createContentEditorAdapter is imported normally, not lazily: it belongs to the page definition, not to the editor, and its whole graph is two files with no third-party package in it. A boundary test in core asserts that nothing under src/editor/ reaches an eager graph like this one.", "One sidebar opens on the right and the whole site is padded, never covered: the header, this page and the footer all move left together, exactly as the AdminCP dashboard does when its widget panel opens. Narrow the window below md and the same sidebar becomes a bottom sheet, with the padding moving underneath the site so the footer still scrolls clear of it.", "before-footer becomes visible only in edit mode, as a large dashed placeholder with its own Add widget button. Finish editing and it disappears again.", "The empty area in after-profile becomes visible only in edit mode too, as a two-column drop target. Finish editing and it leaves no trace.", @@ -182,12 +182,12 @@ const EDIT_MODE_CHECKS = [ "A catalog entry is both draggable and clickable. Drag one between two blocks and an insertion line shows exactly where it will land; click one instead and it goes to the targeted zone, or to the first zone that accepts it.", "Available Widgets opens with a Layout section above the plugin groups, holding a single Area entry. Click it and an empty area lands in the targeted zone; drag it and it lands exactly where the insertion line says, the same way a block does. Drag it over an area and the drop is refused, because an area cannot hold another one.", "Target a zone from inside an area (Add widget on the area) and the header reads For: an area in that zone - and the Layout section disappears, because an area cannot hold another area.", - "Target the sidebar and the catalog offers core:text alone; clear the target and core:cta, core:hero, example:callout and example:features come back.", + "Target the sidebar and the catalog offers core:text alone; clear the target and core:cta, core:hero, newsroom:callout and newsroom:features come back.", "Dropping onto a block inserts before or after it, by which half of it the pointer is over. Nothing ever lands inside a block.", "Drag a root block into the filled area: it becomes a child of it and the area re-flows. Drag a child back out to the zone root and the area keeps the rest. Reorder two children inside one area and only their order changes.", - "Drag example:callout over the sidebar: the zone turns red and the drop is refused, from the same allowlist the catalog filters by - and the API refuses it again from the zone's own allowlist, which it reads off the registered page rather than off the request.", + "Drag newsroom:callout over the sidebar: the zone turns red and the drop is refused, from the same allowlist the catalog filters by - and the API refuses it again from the zone's own allowlist, which it reads off the registered page rather than off the request.", "Select a block and the same sidebar switches to Properties. Back to Available Widgets clears the selection and returns to the catalog.", - "Select the example:features block: a Variant control sits above its fields. Switch grid, list and compact and the page re-renders instantly while every word in the fields stays exactly as it was, and the block keeps its id.", + "Select the newsroom:features block: a Variant control sits above its fields. Switch grid, list and compact and the page re-renders instantly while every word in the fields stays exactly as it was, and the block keeps its id.", "Select an area and the sidebar shows Area properties instead: Columns, Gap, Alignment and Distribution, then Duplicate area, Ungroup and Delete area. Each control re-lays the area out as you pick it.", "Ungroup keeps the children and drops them into the zone at the area's own position. Delete area on a non-empty area asks first, and the same dialog offers Ungroup, keep blocks. Delete an empty area and it says so instead of counting blocks.", "Every block sits in an inert container, so a block's own links and buttons cannot be clicked or tabbed to. Preview gives them back and collapses the sidebar to a slim bar with Back to editing and Finish editing.", @@ -359,14 +359,14 @@ const SettingsScreen = ({ <div className="flex flex-col gap-6 lg:flex-row lg:items-start"> <div className="flex flex-1 flex-col gap-6"> <ContentZone - id={EXAMPLE_ZONE_IDS.beforeProfile} + id={NEWSROOM_ZONE_IDS.beforeProfile} registry={blocksRegistry} /> <ProfileForm /> <ContentZone - id={EXAMPLE_ZONE_IDS.afterProfile} + id={NEWSROOM_ZONE_IDS.afterProfile} registry={blocksRegistry} /> </div> @@ -375,14 +375,14 @@ const SettingsScreen = ({ <ContentZone as="aside" className="border-border w-full rounded-lg border p-4 lg:w-64" - id={EXAMPLE_ZONE_IDS.sidebar} + id={NEWSROOM_ZONE_IDS.sidebar} registry={blocksRegistry} /> ) : null} </div> <ContentZone - id={EXAMPLE_ZONE_IDS.beforeFooter} + id={NEWSROOM_ZONE_IDS.beforeFooter} registry={blocksRegistry} /> diff --git a/plugins/example/src/pages/zones-search.ts b/plugins/newsroom/src/pages/zones-search.ts similarity index 100% rename from plugins/example/src/pages/zones-search.ts rename to plugins/newsroom/src/pages/zones-search.ts diff --git a/plugins/example/src/plugin-info.ts b/plugins/newsroom/src/plugin-info.ts similarity index 71% rename from plugins/example/src/plugin-info.ts rename to plugins/newsroom/src/plugin-info.ts index 526718e03c..d33b525d2a 100644 --- a/plugins/example/src/plugin-info.ts +++ b/plugins/newsroom/src/plugin-info.ts @@ -1,6 +1,6 @@ import type { PluginInfo } from "@vitnode/core/lib/plugin-manifest"; export const pluginInfo: PluginInfo = { - title: "Example", - icon: "flask-conical", + title: "Newsroom", + icon: "puzzle", }; diff --git a/plugins/newsroom/src/routes.ts b/plugins/newsroom/src/routes.ts new file mode 100644 index 0000000000..cca25c7fee --- /dev/null +++ b/plugins/newsroom/src/routes.ts @@ -0,0 +1,156 @@ +import { + definePluginRoutes, + index, + layout, + lazy, + page, +} from "@vitnode/core/routing"; +import { + FeedPendingSkeleton, + RoutePendingSkeleton, +} from "@vitnode/core/tanstack/pending"; + +import type { NoteSearch } from "./pages/note-page"; + +import { manifest } from "./manifest.gen"; +import { articleList } from "./pages/article-list"; +import { browseSearch } from "./pages/browse-search"; +import { deskSearch } from "./pages/desk-search"; +import { letterListSearch } from "./pages/letter-list"; +import { letterNewSearch, letterSearch } from "./pages/letter-search"; +import { zonesSearch } from "./pages/zones-search"; + +export const routes = definePluginRoutes([ + page("/newsroom", { + component: lazy(() => import("./pages/home-page")), + messages: [`${manifest.id}.home`], + }), + + page("/news", { + component: lazy(() => import("./pages/articles-page")), + messages: ["newsroom.articles"], + search: articleList.parseSearch, + }), + + page("/news/new", { + component: lazy(() => import("./pages/article-new-page")), + messages: ["newsroom.articles"], + requires: "authenticated", + }), + + page("/news/:id/edit", { + component: lazy(() => import("./pages/article-edit-page")), + messages: ["newsroom.articles"], + }), + + page("/news/:slug", { + component: lazy(() => import("./pages/article-page")), + messages: ["newsroom.articles", "newsroom.comments"], + }), + + page("/newsroom/notes/:slug", { + component: lazy(() => import("./pages/note-page")), + search: (input): NoteSearch => ({ + tab: input.tab === "details" ? "details" : "summary", + }), + }), + + page("/newsroom/browse", { + component: lazy(() => import("./pages/browse-page")), + messages: ["newsroom.browse"], + search: browseSearch, + }), + + page("/newsroom/pages/:slug", { + component: lazy(() => import("./pages/site-page")), + }), + + page("/newsroom/zones", { + component: lazy(() => import("./pages/zones-page")), + search: zonesSearch, + }), + + page("/newsroom/embed", { + area: "blank", + component: lazy(() => import("./pages/embed-page")), + }), + + layout("/newsroom/guide", { + component: lazy(() => import("./pages/guide-layout")), + messages: ["newsroom.guide"], + children: [ + /** The page at the layout's own URL - `/newsroom/guide`. */ + index({ + component: lazy(() => import("./pages/guide-index-page")), + }), + + page(":topic", { + component: lazy(() => import("./pages/guide-topic-page")), + }), + ], + }), + + page("/stories/:slug", { + component: lazy(() => import("./pages/story-page")), + messages: ["newsroom.stories"], + }), + + page("/explainers/:slug", { + component: lazy(() => import("./pages/explainer-page")), + messages: ["newsroom.stories"], + }), + + page("/events/:slug", { + component: lazy(() => import("./pages/event-page")), + messages: ["newsroom.stories", "newsroom.events"], + }), + + page("/bulletins/:slug", { + component: lazy(() => import("./pages/bulletin-page")), + messages: ["newsroom.stories"], + }), + + page("/desks", { + component: lazy(() => import("./pages/desks-page")), + messages: ["newsroom.desks"], + pendingComponent: RoutePendingSkeleton, + }), + + page("/desks/:slug", { + component: lazy(() => import("./pages/desk-page")), + messages: ["newsroom.desks"], + pendingComponent: FeedPendingSkeleton, + search: deskSearch, + }), + + page("/letters", { + component: lazy(() => import("./pages/letters-page")), + messages: ["newsroom.letters"], + pendingComponent: FeedPendingSkeleton, + search: letterListSearch, + }), + + page("/letters/new", { + component: lazy(() => import("./pages/letter-new-page")), + messages: ["newsroom.letters"], + requires: "authenticated", + search: letterNewSearch, + }), + + page("/letters/:id/edit", { + component: lazy(() => import("./pages/letter-edit-page")), + messages: ["newsroom.letters"], + }), + + page("/letters/:slug", { + component: lazy(() => import("./pages/letter-page")), + messages: ["newsroom.stories", "newsroom.letters"], + search: letterSearch, + }), + + page("/admin/newsroom", { + area: "admin", + component: lazy(() => import("./pages/admin-overview-page")), + messages: ["newsroom.admin.overview"], + }), +]); diff --git a/plugins/example/src/widgets.tsx b/plugins/newsroom/src/widgets.tsx similarity index 100% rename from plugins/example/src/widgets.tsx rename to plugins/newsroom/src/widgets.tsx diff --git a/plugins/example/src/widgets/callout.tsx b/plugins/newsroom/src/widgets/callout.tsx similarity index 100% rename from plugins/example/src/widgets/callout.tsx rename to plugins/newsroom/src/widgets/callout.tsx diff --git a/plugins/example/src/widgets/features.tsx b/plugins/newsroom/src/widgets/features.tsx similarity index 100% rename from plugins/example/src/widgets/features.tsx rename to plugins/newsroom/src/widgets/features.tsx diff --git a/plugins/example/tsconfig.build.json b/plugins/newsroom/tsconfig.build.json similarity index 100% rename from plugins/example/tsconfig.build.json rename to plugins/newsroom/tsconfig.build.json diff --git a/plugins/example/tsconfig.json b/plugins/newsroom/tsconfig.json similarity index 100% rename from plugins/example/tsconfig.json rename to plugins/newsroom/tsconfig.json diff --git a/plugins/example/types/api-registry.gen.d.ts b/plugins/newsroom/types/api-registry.gen.d.ts similarity index 95% rename from plugins/example/types/api-registry.gen.d.ts rename to plugins/newsroom/types/api-registry.gen.d.ts index 7c796dda0a..ce70efda76 100644 --- a/plugins/example/types/api-registry.gen.d.ts +++ b/plugins/newsroom/types/api-registry.gen.d.ts @@ -15,7 +15,7 @@ import type { VitNodeApiPlugin } from '../src/config.api' declare module '@vitnode/core/lib/fetcher/registry' { interface ApiPluginRegistry { - '@vitnode/example': VitNodeApiPlugin + 'newsroom': VitNodeApiPlugin } } diff --git a/plugins/example/vitest.config.ts b/plugins/newsroom/vitest.config.ts similarity index 100% rename from plugins/example/vitest.config.ts rename to plugins/newsroom/vitest.config.ts diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 49e6e19020..eebb7b7669 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -114,9 +114,6 @@ importers: '@vitnode/config': specifier: workspace:* version: link:../../packages/config - '@vitnode/example': - specifier: workspace:* - version: link:../../plugins/example '@vitnode/forum': specifier: workspace:* version: link:../../plugins/forum @@ -126,6 +123,9 @@ importers: dotenv: specifier: ^18.0.6 version: 18.0.6 + newsroom: + specifier: workspace:* + version: link:../../plugins/newsroom oxlint: specifier: ^1.87.0 version: 1.87.0(oxlint-tsgolint@7.0.2003) @@ -186,9 +186,6 @@ importers: '@vitnode/core': specifier: workspace:* version: link:../../packages/vitnode - '@vitnode/example': - specifier: workspace:* - version: link:../../plugins/example '@vitnode/forum': specifier: workspace:* version: link:../../plugins/forum @@ -225,6 +222,9 @@ importers: motion: specifier: ^14.0.0 version: 14.0.0(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + newsroom: + specifier: workspace:* + version: link:../../plugins/newsroom nitro: specifier: 3.0.260903-beta version: 3.0.260903-beta @@ -639,9 +639,6 @@ importers: d3-shape: specifier: ^3.2.0 version: 3.2.0 - date-fns: - specifier: ^4.4.0 - version: 4.4.0 dotenv: specifier: ^18.0.6 version: 18.0.6 @@ -678,9 +675,6 @@ importers: react-resizable-panels: specifier: ^4.14.3 version: 4.14.3(react-dom@19.3.0(react@19.3.0))(react@19.3.0) - react-scan: - specifier: ^0.5.7 - version: 0.5.7(@opentelemetry/core@2.12.0(@opentelemetry/api@1.9.1))(esbuild@0.28.2)(eslint@10.11.0(jiti@2.7.0))(oxlint-tsgolint@7.0.2003)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(rolldown@1.2.12)(rollup@4.63.5)(vite@8.3.4(@types/node@26.6.4)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1)) redis: specifier: ^6.3.0 version: 6.3.0(@opentelemetry/api@1.9.1) @@ -921,7 +915,7 @@ importers: specifier: ^5.0.3 version: 5.0.3(@opentelemetry/api@1.9.1)(@types/node@26.6.4)(@vitest/coverage-v8@5.0.3)(happy-dom@20.14.6)(jsdom@30.1.2)(vite@8.3.4(@types/node@26.6.4)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1)) - plugins/example: + plugins/forum: dependencies: '@hono/zod-openapi': specifier: ^1.6.3 @@ -932,6 +926,9 @@ importers: '@tanstack/react-query': specifier: ^5.104.1 version: 5.104.1(react@19.3.0) + '@tanstack/react-router': + specifier: ^1.170.44 + version: 1.170.44(react-dom@19.3.0(react@19.3.0))(react@19.3.0) '@vitnode/core': specifier: workspace:* version: link:../../packages/vitnode @@ -994,7 +991,7 @@ importers: specifier: ^5.0.3 version: 5.0.3(@opentelemetry/api@1.9.1)(@types/node@26.6.4)(@vitest/coverage-v8@5.0.3)(happy-dom@20.14.6)(jsdom@30.1.2)(vite@8.3.4(@types/node@26.6.4)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1)) - plugins/forum: + plugins/newsroom: dependencies: '@hono/zod-openapi': specifier: ^1.6.3 @@ -1005,9 +1002,6 @@ importers: '@tanstack/react-query': specifier: ^5.104.1 version: 5.104.1(react@19.3.0) - '@tanstack/react-router': - specifier: ^1.170.44 - version: 1.170.44(react-dom@19.3.0(react@19.3.0))(react@19.3.0) '@vitnode/core': specifier: workspace:* version: link:../../packages/vitnode @@ -2534,6 +2528,7 @@ packages: '@opentelemetry/sdk-trace-base@2.12.0': resolution: {integrity: sha512-JOERgZCxRM06FEJ5kEWxlSRChah6MmXDx3KwCruqEv/iEyLkTokCSemf2y1w8oIHtfrr8EVTpzebq/31vn2Cpg==} engines: {node: ^18.19.0 || >=20.6.0} + deprecated: Use @opentelemetry/sdk-trace package peerDependencies: '@opentelemetry/api': '>=1.3.0 <1.10.0' @@ -3371,14 +3366,6 @@ packages: engines: {node: '>=18'} hasBin: true - '@preact/signals-core@1.14.4': - resolution: {integrity: sha512-HNB6HYeYKhQbJ1aKl+YRjrS4+QWHLKX6qKoUsfS/m0vqzsVaEBiZiaKbG/e+NKk2ch5ALQr/ihWaMHxiCuuWHA==} - - '@preact/signals@2.11.3': - resolution: {integrity: sha512-mttwFM98G/JiriTgMwU+UWygL6sfVmYEiNcPSKkUnv4PxPpZhmP2ugGC4qi360+ZIHyQ8NY05bfJT3b4RUIGlw==} - peerDependencies: - preact: '>= 10.25.0 || >=11.0.0-0' - '@quansync/fs@1.1.0': resolution: {integrity: sha512-qAPG/t3HqML1TlN7sY/pTbEjzFVAKsMjNNMGheyDosro+kT4iw2KCUoHcVdmliwWjorm4elZbgNQyU2eD97eDg==} @@ -3554,10 +3541,6 @@ packages: '@react-email/ui@6.11.1': resolution: {integrity: sha512-Toy+g7u+9USdFV3YKIUEK66c356+/LDumDnpz7eVdjgCC3TCDb/v6WcfTSoU+RSHRkpUQjl7B9MOtQ/dgj5yYQ==} - '@react-grab/cli@0.2.0': - resolution: {integrity: sha512-LVfA+j5cFT0Szd958WRD8W/B8ucCtz4ViiveDKWLqAwBkZFW9VRW1ZvwOUhp/M3SmVPtjDaRkW12GvAuTArP+Q==} - hasBin: true - '@redis/bloom@6.3.0': resolution: {integrity: sha512-NQ5poYpZr0jv6zazN1cy6JY5kIipKFZ3R9GTL4kOUTX3a9YAfY29U8bsE4lWhVQEM+G4V0BXVC57mubIdDijeA==} engines: {node: '>= 20.0.0'} @@ -3789,15 +3772,6 @@ packages: '@rolldown/pluginutils@1.0.1': resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==} - '@rollup/pluginutils@5.4.0': - resolution: {integrity: sha512-MfPp06CjRLfXQ3wY0R8vJDYBy/MvVcc9OulEfR0B8Iv9ko+GCNaRZ+EpJYFl27LhKsZK0o420sYCRHCjfCgeUg==} - engines: {node: '>=14.0.0'} - peerDependencies: - rollup: ^1.20.0||^2.0.0||^3.0.0||^4.0.0 - peerDependenciesMeta: - rollup: - optional: true - '@rollup/rollup-android-arm-eabi@4.63.5': resolution: {integrity: sha512-J25QJU+B78T4FhhBsNpLJyVWOi31mwtpcMwywHmOKH65Q9IWGA81gPj+dnwlhU8wktVriYE+tFAaQgrnJRzAZg==} cpu: [arm] @@ -5361,10 +5335,6 @@ packages: resolution: {integrity: sha512-nHlms9BkP8ZiY79HrwCGiA2DcNaXrAaJrCM/BEqQ7MEsSKyCk+2A76xPGylIfASZSZE0SaU3T0bNSg4rBPIJAQ==} hasBin: true - agent-install@0.0.6: - resolution: {integrity: sha512-7NRMZ/ZDz2vHevQTgJsocBFpakB1/Wx5ip19YSJuj4VOXpraWztTerViNtdSyARKZT9e2yVwUUB5JXXCE7mNrA==} - hasBin: true - ai@7.0.134: resolution: {integrity: sha512-2m/hyNGsNB2Fe/BCZ/XwpjnfxEVCqS0TtAwjR/HpIIyMAtiLJJ8bQb6+uMih4MXwukDJr9AR4hxbFLUE9H3SjQ==} engines: {node: '>=22'} @@ -5495,11 +5465,6 @@ packages: resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==} engines: {node: '>=8'} - bippy@0.5.43: - resolution: {integrity: sha512-Tvu7b1M7+d8b9/YHaCeODEsi2CgbuoBql+dWSBrNnCuqJ1gMUeY3i0r+319hvjjl5GVBP6FFWxrKnq3fhZER0w==} - peerDependencies: - react: '>=17.0.1' - bippy@0.6.1: resolution: {integrity: sha512-ky4m94Y/KfsddjGkKTsV4uFjZqkJjpOjQ2t5gKPdX6XH1MNxMNX5FrVefsxV4lpjemEmEdwe0e0YbzAMNs3oUQ==} peerDependencies: @@ -6467,9 +6432,6 @@ packages: estree-util-visit@2.0.0: resolution: {integrity: sha512-m5KgiH85xAhhW8Wta0vShLcUvOsh3LLPI2YVwcbio1l7E09NTLL1EyMZFM1OyWowoH0skScNbhOPl4kcBgzTww==} - estree-walker@2.0.2: - resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==} - estree-walker@3.0.3: resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} @@ -6939,10 +6901,6 @@ packages: resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} engines: {node: '>= 4'} - ignore@7.0.12: - resolution: {integrity: sha512-/8UvqAPU9DGTI9k4mxtf49U37Isfwr8Uts96+SBHIkFxPJnHS0Ew4f00sM4Scd8V8EjM0jUNtVDdv1kPdt35lg==} - engines: {node: '>= 4'} - import-in-the-middle@3.5.2: resolution: {integrity: sha512-WseIA/4o56+GYO3RmlOPvejYi771OFeJEeK/ejROsr9bdjW+E5NXTOD1Di8TL40Qgg3JJX9aa97Hf8iiSVIKZw==} engines: {node: '>=18'} @@ -7884,10 +7842,6 @@ packages: resolution: {integrity: sha512-j6tdFoJbLztISYNlw7LMT+6naqhVsXTNn7drhoGRIqf0x2mCSD2Ur71hK0E/VrLens355finI6j+sMqHxHpEfA==} engines: {node: ^20.19.0 || >=22.13.0} - oxlint-plugin-react-doctor@0.9.18: - resolution: {integrity: sha512-1mfeb/jzXnduWOZJxrvdJjvKT0pFNKrNFYcPO4ncpFAnfW41M0mLz/23lAFzHb0knNLYayEgIQ5DjI4ZfFe+DQ==} - engines: {node: ^20.19.0 || >=22.13.0} - oxlint-tsgolint@7.0.2003: resolution: {integrity: sha512-VnK4zlqgmgq/7ZcjzCk/WpN8kKFsYGcB85Io9qT3wL4K8Un3RKmJkp793crNETieMusPMKOA4a+Mw2wbteI6TQ==} hasBin: true @@ -7938,9 +7892,6 @@ packages: resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==} engines: {node: '>=6'} - package-manager-detector@1.9.0: - resolution: {integrity: sha512-zAMfbta9nPA8AKkA4WdmNAT8R8s6Dk0/xwPsqNnRidMFcLo1YkLuPDTgs2f64XdzS8vbLNnoJ+uUIkgbz7oRHA==} - parse-entities@4.0.2: resolution: {integrity: sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==} @@ -8205,11 +8156,6 @@ packages: engines: {node: ^20.19.0 || >=22.13.0} hasBin: true - react-doctor@0.9.18: - resolution: {integrity: sha512-4Ach00KtfnfjTBGgr2PyPgHua85vT/UfPY5sR0bHgRAc/QSu0JInA0GHCL6a7WFGMeGUrxtGnI8oqZinG44BOA==} - engines: {node: ^20.19.0 || >=22.13.0} - hasBin: true - react-dom@19.3.0: resolution: {integrity: sha512-JDk8dgif51OjFoDE70+OT9ICyYr+69HlmihNwp1+Nsfbna3t5sIiCa9ZJktDmQ4/1b/rn26hIAR2uYXDMr5r0Q==} peerDependencies: @@ -8223,15 +8169,6 @@ packages: react: ^18.0 || ^19.0 || ^19.0.0-rc react-dom: ^18.0 || ^19.0 || ^19.0.0-rc - react-grab@0.2.0: - resolution: {integrity: sha512-ohhsfXD4qN0j6cMQd56aaVJBPDF3kUdviF/Od1Eak/rEIXQ3svQ4gjkwTfuTZwTBnxHRL16p9JqdlVQO5nUHqA==} - hasBin: true - peerDependencies: - react: '>=17.0.0' - peerDependenciesMeta: - react: - optional: true - react-is@17.0.2: resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==} @@ -8261,17 +8198,6 @@ packages: react: ^18.0.0 || ^19.0.0 react-dom: ^18.0.0 || ^19.0.0 - react-scan@0.5.7: - resolution: {integrity: sha512-KRlq734yN6q/f2CZmZi9CWHuiqSzoLhPFLtcJOL6XM4lR54myyFcY81pG9QOwj+eBC1hIHm5n+Ntbtqiilu8Rg==} - hasBin: true - peerDependencies: - esbuild: ^0.28.2 - react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - peerDependenciesMeta: - esbuild: - optional: true - react-style-singleton@2.2.3: resolution: {integrity: sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==} engines: {node: '>=10'} @@ -11335,13 +11261,6 @@ snapshots: playwright: 1.61.1 optional: true - '@preact/signals-core@1.14.4': {} - - '@preact/signals@2.11.3(preact@10.29.8)': - dependencies: - '@preact/signals-core': 1.14.4 - preact: 10.29.8 - '@quansync/fs@1.1.0': dependencies: quansync: 1.0.0 @@ -11508,17 +11427,6 @@ snapshots: - react-dom - sass - '@react-grab/cli@0.2.0': - dependencies: - agent-install: 0.0.6 - commander: 14.0.3 - ignore: 7.0.12 - ora: 9.4.1 - package-manager-detector: 1.9.0 - picocolors: 1.1.1 - prompts: 2.4.2 - tinyexec: 1.3.1 - '@redis/bloom@6.3.0(@redis/client@6.3.0(@opentelemetry/api@1.9.1))': dependencies: '@redis/client': 6.3.0(@opentelemetry/api@1.9.1) @@ -11631,14 +11539,6 @@ snapshots: '@rolldown/pluginutils@1.0.1': {} - '@rollup/pluginutils@5.4.0(rollup@4.63.5)': - dependencies: - '@types/estree': 1.0.9 - estree-walker: 2.0.2 - picomatch: 4.0.7 - optionalDependencies: - rollup: 4.63.5 - '@rollup/rollup-android-arm-eabi@4.63.5': optional: true @@ -13170,15 +13070,6 @@ snapshots: prompts: 2.4.2 yaml: 2.9.1 - agent-install@0.0.6: - dependencies: - '@iarna/toml': 2.2.5 - commander: 14.0.3 - jsonc-parser: 3.3.1 - picocolors: 1.1.1 - prompts: 2.4.2 - yaml: 2.9.1 - ai@7.0.134(zod@4.6.5): dependencies: '@ai-sdk/gateway': 4.0.108(zod@4.6.5) @@ -13302,10 +13193,6 @@ snapshots: binary-extensions@2.3.0: {} - bippy@0.5.43(react@19.3.0): - dependencies: - react: 19.3.0 - bippy@0.6.1(react@19.3.0): dependencies: react: 19.3.0 @@ -14206,8 +14093,6 @@ snapshots: '@types/estree-jsx': 1.0.5 '@types/unist': 3.0.3 - estree-walker@2.0.2: {} - estree-walker@3.0.3: dependencies: '@types/estree': 1.0.9 @@ -14738,8 +14623,6 @@ snapshots: ignore@5.3.2: {} - ignore@7.0.12: {} - import-in-the-middle@3.5.2: dependencies: cjs-module-lexer: 2.3.0 @@ -15904,15 +15787,6 @@ snapshots: lightningcss: 1.33.0 oxc-parser: 0.148.0 - oxlint-plugin-react-doctor@0.9.18: - dependencies: - '@shaderfrog/glsl-parser': 7.0.1 - '@typescript-eslint/types': 8.71.1 - eslint-scope: 9.1.2 - eslint-visitor-keys: 5.0.1 - lightningcss: 1.33.0 - oxc-parser: 0.148.0 - oxlint-tsgolint@7.0.2003: optionalDependencies: '@oxlint-tsgolint/darwin-arm64': 7.0.2003 @@ -15986,8 +15860,6 @@ snapshots: p-try@2.2.0: {} - package-manager-detector@1.9.0: {} - parse-entities@4.0.2: dependencies: '@types/unist': 2.0.11 @@ -16089,7 +15961,8 @@ snapshots: powershell-utils@0.2.1: {} - preact@10.29.8: {} + preact@10.29.8: + optional: true prelude-ls@1.2.1: {} @@ -16274,36 +16147,6 @@ snapshots: - supports-color - vite-plus - react-doctor@0.9.18(@opentelemetry/core@2.12.0(@opentelemetry/api@1.9.1))(eslint@10.11.0(jiti@2.7.0))(oxlint-tsgolint@7.0.2003)(react@19.3.0): - dependencies: - '@astrojs/compiler': 4.0.0 - '@sentry/node': 10.76.1(@opentelemetry/core@2.12.0(@opentelemetry/api@1.9.1)) - agent-install: 0.0.5 - bippy: 0.6.1(react@19.3.0) - conf: 15.1.0 - confbox: 0.2.4 - eslint-plugin-react-hooks: 7.1.1(eslint@10.11.0(jiti@2.7.0)) - is-unicode-supported: 2.1.0 - jiti: 2.7.0 - magicast: 0.5.5 - oxc-parser: 0.148.0 - oxc-resolver: 11.24.2 - oxlint: 1.81.0(oxlint-tsgolint@7.0.2003) - oxlint-plugin-react-doctor: 0.9.18 - playwright-core: 1.64.0 - prompts: 2.4.2 - typescript: 5.9.3 - yaml: 2.9.1 - yoga-layout: 3.2.1 - transitivePeerDependencies: - - '@opentelemetry/core' - - '@opentelemetry/exporter-trace-otlp-http' - - eslint - - oxlint-tsgolint - - react - - supports-color - - vite-plus - react-dom@19.3.0(react@19.3.0): dependencies: react: 19.3.0 @@ -16340,13 +16183,6 @@ snapshots: - supports-color - utf-8-validate - react-grab@0.2.0(react@19.3.0): - dependencies: - '@react-grab/cli': 0.2.0 - bippy: 0.6.1(react@19.3.0) - optionalDependencies: - react: 19.3.0 - react-is@17.0.2: {} react-remove-scroll-bar@2.3.8(@types/react@19.3.0)(react@19.3.0): @@ -16373,42 +16209,6 @@ snapshots: react: 19.3.0 react-dom: 19.3.0(react@19.3.0) - react-scan@0.5.7(@opentelemetry/core@2.12.0(@opentelemetry/api@1.9.1))(esbuild@0.28.2)(eslint@10.11.0(jiti@2.7.0))(oxlint-tsgolint@7.0.2003)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(rolldown@1.2.12)(rollup@4.63.5)(vite@8.3.4(@types/node@26.6.4)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1)): - dependencies: - '@babel/core': 7.29.7 - '@babel/types': 7.29.8 - '@preact/signals': 2.11.3(preact@10.29.8) - '@rollup/pluginutils': 5.4.0(rollup@4.63.5) - bippy: 0.5.43(react@19.3.0) - commander: 14.0.3 - picocolors: 1.1.1 - preact: 10.29.8 - prompts: 2.4.2 - react: 19.3.0 - react-doctor: 0.9.18(@opentelemetry/core@2.12.0(@opentelemetry/api@1.9.1))(eslint@10.11.0(jiti@2.7.0))(oxlint-tsgolint@7.0.2003)(react@19.3.0) - react-dom: 19.3.0(react@19.3.0) - react-grab: 0.2.0(react@19.3.0) - optionalDependencies: - esbuild: 0.28.2 - unplugin: 3.4.0(esbuild@0.28.2)(rolldown@1.2.12)(rollup@4.63.5)(vite@8.3.4(@types/node@26.6.4)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1)) - transitivePeerDependencies: - - '@farmfe/core' - - '@opentelemetry/core' - - '@opentelemetry/exporter-trace-otlp-http' - - '@rsbuild/core' - - '@rspack/core' - - bun-types-no-globals - - eslint - - oxlint-tsgolint - - preact-render-to-string - - rolldown - - rollup - - supports-color - - unloader - - vite - - vite-plus - - webpack - react-style-singleton@2.2.3(@types/react@19.3.0)(react@19.3.0): dependencies: get-nonce: 1.0.1 @@ -17264,18 +17064,6 @@ snapshots: unpipe@1.0.0: {} - unplugin@3.4.0(esbuild@0.28.2)(rolldown@1.2.12)(rollup@4.63.5)(vite@8.3.4(@types/node@26.6.4)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1)): - dependencies: - '@jridgewell/remapping': 2.3.5 - picomatch: 4.0.7 - webpack-virtual-modules: 0.6.2 - optionalDependencies: - esbuild: 0.28.2 - rolldown: 1.2.12 - rollup: 4.63.5 - vite: 8.3.4(@types/node@26.6.4)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1) - optional: true - unplugin@3.4.0(esbuild@0.28.2)(rolldown@1.2.13)(rollup@4.63.5)(vite@8.3.4(@types/node@26.6.4)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1)): dependencies: '@jridgewell/remapping': 2.3.5