From dd4327645f117ecc822ddec26c05119db2a8e607 Mon Sep 17 00:00:00 2001 From: aXenDeveloper Date: Mon, 10 Aug 2026 11:12:26 +0200 Subject: [PATCH 1/7] feat(content): support page and custom-layout admin forms Two additions to the generated AdminCP, both opt-in and both defaulting to exactly what a content type does today. `admin.create.mode` and `admin.edit.mode` take `"dialog"` or `"page"`, and default to `"dialog"` - so nothing about an existing content type moves. Page mode is served by the *same* catch-all route as the list, resolved exact-content-type-first so an id ending in `.create` keeps its own screen: /admin/content/blog/post list /admin/content/blog/post/create create /admin/content/blog/post/42/edit edit The Create button and the row pencil become links rather than dialogs that mount and redirect, and both pages check `can_view` plus their own permission on the server - a URL typed into the address bar answers the way the button would have. A create hands over to the new record's edit page when there is one, using the id the mutation now returns. `forms.layout` on the frontend registration lets a plugin place the fields itself. It is presentation only: the engine keeps the schema, the validation, the defaults, the mutation, the version precondition, the structured errors, the toast, the invalidation, the events, the search write and the delivery effects. `ContentFormField` renders the element the engine already built - including its field override - so overrides and layouts compose. The layout is a client component referenced from a *server* config, so its props are serialisable and everything else reaches it through client context. A `renderField(name)` callback would read better and would be a server closure, which cannot cross that boundary at all. Two fixes fall out of the same work: - the locale tabs rendered no inputs at all. `TranslationPanel` handed AutoForm bare field ids, and AutoForm renders nothing for a field with no component - so every localized content type had a form nobody could type into. - `GET /{id}` now answers with `labels`, the way the list already did. It is the read a form makes, and a relation picker showing `3` instead of a name was the only thing stopping page mode from opening on a complete record. Co-Authored-By: Claude Opus 5 (1M context) --- packages/vitnode/package.json | 5 + .../vitnode/src/components/form/auto-form.tsx | 68 ++++ .../vitnode/src/content/admin/route.test.ts | 125 +++++++ packages/vitnode/src/content/admin/route.ts | 89 +++++ packages/vitnode/src/content/const.ts | 21 ++ packages/vitnode/src/content/define.test-d.ts | 41 +++ packages/vitnode/src/content/define.test.ts | 34 ++ packages/vitnode/src/content/define.ts | 38 ++- packages/vitnode/src/content/index.ts | 6 + packages/vitnode/src/content/registry.test.ts | 16 + packages/vitnode/src/content/registry.ts | 30 ++ .../src/content/server/openapi-parity.test.ts | 3 +- .../vitnode/src/content/server/routes.test.ts | 11 +- packages/vitnode/src/content/server/routes.ts | 9 +- .../src/content/server/search-sync.test.ts | 1 + .../vitnode/src/content/server/service.ts | 41 +++ packages/vitnode/src/content/types.ts | 35 +- packages/vitnode/src/lib/plugin.test.ts | 39 +++ packages/vitnode/src/lib/plugin.ts | 79 +++++ packages/vitnode/src/locales/en.json | 3 + .../admin/content/[...slug]/page.tsx | 56 +++- .../views/content/actions/content-form.tsx | 170 ++++++---- .../views/content/actions/create-action.tsx | 20 +- .../views/content/actions/edit-action.tsx | 27 ++ .../content/actions/mutation-api.server.ts | 10 +- .../views/content/actions/page-links.test.tsx | 97 ++++++ .../actions/translations/locale-editor.tsx | 2 + .../translations/translation-panel.test.tsx | 126 +++++++ .../translations/translation-panel.tsx | 69 +++- .../views/content/content-admin-view.tsx | 143 +++++--- .../admin/views/content/form/context.tsx | 108 ++++++ .../views/admin/views/content/form/index.ts | 26 ++ .../admin/views/content/form/layout.test.tsx | 263 +++++++++++++++ .../admin/views/content/form/primitives.tsx | 200 ++++++++++++ .../views/content/form/publication-status.tsx | 44 +++ .../views/content/page/content-form-page.tsx | 93 ++++++ .../views/content/page/page-views.test.tsx | 309 ++++++++++++++++++ .../admin/views/content/page/page-views.tsx | 229 +++++++++++++ .../content/table/content-table-view.tsx | 9 +- 39 files changed, 2568 insertions(+), 127 deletions(-) create mode 100644 packages/vitnode/src/content/admin/route.test.ts create mode 100644 packages/vitnode/src/content/admin/route.ts create mode 100644 packages/vitnode/src/lib/plugin.test.ts create mode 100644 packages/vitnode/src/views/admin/views/content/actions/page-links.test.tsx create mode 100644 packages/vitnode/src/views/admin/views/content/actions/translations/translation-panel.test.tsx create mode 100644 packages/vitnode/src/views/admin/views/content/form/context.tsx create mode 100644 packages/vitnode/src/views/admin/views/content/form/index.ts create mode 100644 packages/vitnode/src/views/admin/views/content/form/layout.test.tsx create mode 100644 packages/vitnode/src/views/admin/views/content/form/primitives.tsx create mode 100644 packages/vitnode/src/views/admin/views/content/form/publication-status.tsx create mode 100644 packages/vitnode/src/views/admin/views/content/page/content-form-page.tsx create mode 100644 packages/vitnode/src/views/admin/views/content/page/page-views.test.tsx create mode 100644 packages/vitnode/src/views/admin/views/content/page/page-views.tsx diff --git a/packages/vitnode/package.json b/packages/vitnode/package.json index a2dfe6a95..045807316 100644 --- a/packages/vitnode/package.json +++ b/packages/vitnode/package.json @@ -101,6 +101,11 @@ "types": "./dist/src/content/next/revalidate-route.server.d.ts", "default": "./dist/src/content/next/revalidate-route.server.js" }, + "./content/admin-form": { + "import": "./dist/src/views/admin/views/content/form/index.js", + "types": "./dist/src/views/admin/views/content/form/index.d.ts", + "default": "./dist/src/views/admin/views/content/form/index.js" + }, "./api/config": { "import": "./dist/src/api/config.js", "types": "./dist/src/api/config.d.ts", diff --git a/packages/vitnode/src/components/form/auto-form.tsx b/packages/vitnode/src/components/form/auto-form.tsx index 79eb3a58d..24677827a 100644 --- a/packages/vitnode/src/components/form/auto-form.tsx +++ b/packages/vitnode/src/components/form/auto-form.tsx @@ -10,7 +10,9 @@ import { type FieldValues, type Mode, useForm, + useFormContext, type UseFormReturn, + useFormState, } from "react-hook-form"; import z from "zod"; @@ -98,6 +100,40 @@ function AutoFormField({ return ; } +/** + * The submit button of the surrounding `AutoForm`, for a `layout` that has to + * place it itself. + * + * Reads the form through context rather than taking props, so it stays in step + * with validity and submission exactly like the built-in one - and so a layout + * cannot wire up a button that submits a different form. + */ +export const AutoFormSubmitButton = ({ + children, + className, + variant, +}: { + children?: React.ReactNode; + className?: string; + variant?: React.ComponentProps["variant"]; +}) => { + const t = useTranslations("core.global"); + const { control } = useFormContext(); + const { isSubmitting, isValid } = useFormState({ control }); + + return ( + + ); +}; + export type AutoFormOnSubmit< T extends z.ZodObject, TContext = unknown, @@ -118,6 +154,7 @@ export function AutoForm< onSubmit: onSubmitProp, captcha, fields, + layout, tabs, submitButtonProps, children, @@ -126,6 +163,19 @@ export function AutoForm< captcha?: z.infer["captcha"]; fields: ItemAutoFormProps[]; formSchema: T; + /** + * Places the fields yourself instead of stacking them in declaration order. + * + * Called with every field already rendered and keyed by its `id`, so a layout + * puts an element where it wants it and each one stays wired into this form's + * validation, dirty state and error display. One `
`, one schema, one + * submit - a layout cannot accidentally create a second of any of them. + * + * The automatic submit button is **not** rendered in this mode: a layout that + * decides where the fields go has to decide where the button goes too. + * Mutually exclusive with `tabs`. + */ + layout?: (renderedFields: Record) => React.ReactNode; mode?: Mode; onSubmit?: AutoFormOnSubmit; submitButtonProps?: Omit< @@ -272,6 +322,24 @@ export function AutoForm< ); + if (layout) { + return ( + + {layout( + Object.fromEntries( + fields + .filter(isFieldVisible) + .map(item => [item.id, renderField(item)]), + ), + )} + + {children} + + {captcha &&
} + + ); + } + return (
{tabs?.length ? ( diff --git a/packages/vitnode/src/content/admin/route.test.ts b/packages/vitnode/src/content/admin/route.test.ts new file mode 100644 index 000000000..060bafbae --- /dev/null +++ b/packages/vitnode/src/content/admin/route.test.ts @@ -0,0 +1,125 @@ +// @vitest-environment node +import { describe, expect, it } from "vitest"; + +import type { AnyContentTypeDefinition } from "../types"; + +import { defineContentType } from "../define"; +import { field } from "../fields"; +import { resolveContentAdminRoute } from "./route"; + +const define = ( + id: string, + admin: Partial[0]["admin"]> = {}, +): AnyContentTypeDefinition => + defineContentType({ + id, + tableName: id.split(".").join("_"), + fields: { title: field.text({ required: true }) }, + admin: { label: { plural: "Posts", singular: "Post" }, ...admin }, + }) as AnyContentTypeDefinition; + +const dialogPost = define("blog.post"); +const pagePost = define("blog.post", { + create: { mode: "page" }, + edit: { mode: "page" }, +}); +const createOnly = define("blog.post", { create: { mode: "page" } }); + +const lookupOf = + (...definitions: AnyContentTypeDefinition[]) => + (id: string) => + definitions.find(definition => definition.id === id); + +describe("resolveContentAdminRoute", () => { + it("resolves the list of a registered content type", () => { + expect( + resolveContentAdminRoute(["blog", "post"], lookupOf(dialogPost)), + ).toEqual({ action: "list", contentTypeId: "blog.post" }); + }); + + it("resolves nothing for an unknown content type", () => { + expect( + resolveContentAdminRoute(["blog", "nope"], lookupOf(dialogPost)), + ).toBeUndefined(); + }); + + it("resolves nothing for an empty slug", () => { + expect(resolveContentAdminRoute([], lookupOf(dialogPost))).toBeUndefined(); + }); + + describe("page mode", () => { + it("resolves the create page", () => { + expect( + resolveContentAdminRoute( + ["blog", "post", "create"], + lookupOf(pagePost), + ), + ).toEqual({ action: "create", contentTypeId: "blog.post" }); + }); + + it("resolves the edit page", () => { + expect( + resolveContentAdminRoute( + ["blog", "post", "42", "edit"], + lookupOf(pagePost), + ), + ).toEqual({ action: "edit", contentTypeId: "blog.post", itemId: 42 }); + }); + + it("refuses a form URL of a dialog-mode content type", () => { + expect( + resolveContentAdminRoute( + ["blog", "post", "create"], + lookupOf(dialogPost), + ), + ).toBeUndefined(); + expect( + resolveContentAdminRoute( + ["blog", "post", "1", "edit"], + lookupOf(dialogPost), + ), + ).toBeUndefined(); + }); + + it("gates each action on its own mode", () => { + expect( + resolveContentAdminRoute( + ["blog", "post", "create"], + lookupOf(createOnly), + ), + ).toEqual({ action: "create", contentTypeId: "blog.post" }); + expect( + resolveContentAdminRoute( + ["blog", "post", "1", "edit"], + lookupOf(createOnly), + ), + ).toBeUndefined(); + }); + + it.each([ + ["a missing identifier", ["blog", "post", "edit"]], + ["a non-numeric identifier", ["blog", "post", "abc", "edit"]], + ["a zero identifier", ["blog", "post", "0", "edit"]], + ["a padded identifier", ["blog", "post", "01", "edit"]], + ["a negative identifier", ["blog", "post", "-1", "edit"]], + ["a fractional identifier", ["blog", "post", "1.5", "edit"]], + ])("resolves nothing for %s", (_name, slug) => { + expect( + resolveContentAdminRoute(slug, lookupOf(pagePost)), + ).toBeUndefined(); + }); + + it("prefers an exact content type id over a create page", () => { + // `blog.post.create` is a legal id, so the content type that really is + // called that keeps its own list screen. + const literal = define("blog.post.create"); + + expect( + resolveContentAdminRoute( + ["blog", "post", "create"], + lookupOf(pagePost, literal), + ), + ).toEqual({ action: "list", contentTypeId: "blog.post.create" }); + }); + }); +}); diff --git a/packages/vitnode/src/content/admin/route.ts b/packages/vitnode/src/content/admin/route.ts new file mode 100644 index 000000000..6b67506ef --- /dev/null +++ b/packages/vitnode/src/content/admin/route.ts @@ -0,0 +1,89 @@ +import type { AnyContentTypeDefinition } from "../types"; + +import { + CONTENT_ADMIN_CREATE_SEGMENT, + CONTENT_ADMIN_EDIT_SEGMENT, +} from "../const"; +import { pathToContentTypeId } from "../registry"; + +/** What `/admin/content/[...slug]` was actually asked for. */ +export type ContentAdminAction = "create" | "edit" | "list"; + +export interface ContentAdminRoute { + action: ContentAdminAction; + /** The content type id the slug resolved to. */ + contentTypeId: string; + /** The record being edited. Only ever set for `edit`. */ + itemId?: number; +} + +/** + * A "does this id exist" predicate, so the resolver stays a pure function. + * + * It has to be able to ask, rather than just split on the last segment: a + * content type is free to be called `blog.post.create`, and the exact match has + * to win over the create page of `blog.post`. + */ +export type ContentTypeLookup = ( + contentTypeId: string, +) => AnyContentTypeDefinition | undefined; + +/** Only a positive integer is a record id - `01`, `1.5` and `-1` are not. */ +const parseItemId = (segment: string | undefined): null | number => { + if (segment === undefined || !/^[1-9][0-9]*$/.test(segment)) return null; + + const id = Number(segment); + + return Number.isSafeInteger(id) ? id : null; +}; + +/** + * Maps the catch-all slug onto a content type and one of three screens. + * + * One route serves all of them, which is the same trade the list screen already + * made: a second Next.js router keyed on content type ids would mean two files + * per app per screen, and the whole point of the generated AdminCP is that a + * plugin adds a content type without adding a file. + * + * Resolution order is exact-match-first, and that matters. `blog.post.create` is + * a legal content type id, so `["blog", "post", "create"]` has two readings; the + * one where a registered content type keeps its own list screen wins, and the + * create page of `blog.post` is then simply unreachable - which is a name clash + * its author can see and fix, rather than a screen that silently disappeared. + * + * `undefined` for anything that resolves to nothing, and for a form URL of a + * content type that did not opt into page mode: a dialog-mode content type + * answering `/create` would be a second, unstyled way into the same form. + */ +export const resolveContentAdminRoute = ( + slug: readonly string[], + lookup: ContentTypeLookup, +): ContentAdminRoute | undefined => { + if (slug.length === 0) return undefined; + + const exact = pathToContentTypeId(slug); + if (lookup(exact)) return { action: "list", contentTypeId: exact }; + + const last = slug[slug.length - 1]; + + if (last === CONTENT_ADMIN_CREATE_SEGMENT) { + const contentTypeId = pathToContentTypeId(slug.slice(0, -1)); + const definition = lookup(contentTypeId); + if (definition?.admin.create.mode !== "page") return undefined; + + return { action: "create", contentTypeId }; + } + + if (last === CONTENT_ADMIN_EDIT_SEGMENT) { + const itemId = parseItemId(slug[slug.length - 2]); + if (itemId === null) return undefined; + + const contentTypeId = pathToContentTypeId(slug.slice(0, -2)); + const definition = lookup(contentTypeId); + if (definition?.admin.edit.mode !== "page") return undefined; + + return { action: "edit", contentTypeId, itemId }; + } + + return undefined; +}; diff --git a/packages/vitnode/src/content/const.ts b/packages/vitnode/src/content/const.ts index 1db79fcab..a55759f26 100644 --- a/packages/vitnode/src/content/const.ts +++ b/packages/vitnode/src/content/const.ts @@ -556,6 +556,27 @@ export const CONTENT_DELIVERY_CODES = { slugReserved: "CONTENT_DELIVERY_SLUG_RESERVED", } as const; +/** + * How the AdminCP may present a create or an edit form. + * + * `dialog` is first because it is the default, and the default is the whole + * point: a content type written before page mode existed keeps the screen it + * already had, and nothing about its behaviour moves until somebody says so. + */ +export const CONTENT_ADMIN_FORM_MODES = ["dialog", "page"] as const; + +/** + * The last URL segment of a generated create page, and of an edit one. + * + * Reserved rather than free-form: `/admin/content/[...slug]` resolves a content + * type id from the same slug, so these two words are what tells + * `/admin/content/blog/post` from `/admin/content/blog/post/create`. A content + * type that genuinely wants to be called `blog.post.create` still wins - the + * exact match is tried first. + */ +export const CONTENT_ADMIN_CREATE_SEGMENT = "create"; +export const CONTENT_ADMIN_EDIT_SEGMENT = "edit"; + /** * Every content type gets the first four staff permissions. `can_publish` is * generated only for content types with `publication: { enabled: true }`, diff --git a/packages/vitnode/src/content/define.test-d.ts b/packages/vitnode/src/content/define.test-d.ts index 659bec45e..e0959fd24 100644 --- a/packages/vitnode/src/content/define.test-d.ts +++ b/packages/vitnode/src/content/define.test-d.ts @@ -178,4 +178,45 @@ describe("content type inference", () => { expectTypeOf>().toEqualTypeOf(); }); }); + + describe("admin form presentation", () => { + it("accepts the two presentation modes", () => { + expectTypeOf( + defineContentType({ + id: "test.modes", + tableName: "test_modes", + fields: { title: field.text({ required: true }) }, + admin: { + create: { mode: "page" }, + edit: { mode: "dialog" }, + label: { plural: "Modes", singular: "Mode" }, + }, + }).admin.create.mode, + ).toEqualTypeOf<"dialog" | "page">(); + }); + + it("refuses anything else", () => { + defineContentType({ + id: "test.modes", + tableName: "test_modes", + fields: { title: field.text({ required: true }) }, + admin: { + // @ts-expect-error - only "dialog" and "page" are presentation modes + create: { mode: "drawer" }, + label: { plural: "Modes", singular: "Mode" }, + }, + }); + + defineContentType({ + id: "test.modes", + tableName: "test_modes", + fields: { title: field.text({ required: true }) }, + admin: { + // @ts-expect-error - only "dialog" and "page" are presentation modes + edit: { mode: "sheet" }, + label: { plural: "Modes", singular: "Mode" }, + }, + }); + }); + }); }); diff --git a/packages/vitnode/src/content/define.test.ts b/packages/vitnode/src/content/define.test.ts index 80e2fd8aa..3099565ed 100644 --- a/packages/vitnode/src/content/define.test.ts +++ b/packages/vitnode/src/content/define.test.ts @@ -462,6 +462,40 @@ describe("defineContentType", () => { }); }); + describe("admin form presentation", () => { + it("defaults create and edit to the dialog", () => { + const dialog = define(); + + expect(dialog.admin.create.mode).toBe("dialog"); + expect(dialog.admin.edit.mode).toBe("dialog"); + }); + + it("takes page mode for create and edit independently", () => { + const pageCreate = define({ + admin: { create: { mode: "page" }, label }, + }); + const pageEdit = define({ admin: { edit: { mode: "page" }, label } }); + + expect(pageCreate.admin.create.mode).toBe("page"); + expect(pageCreate.admin.edit.mode).toBe("dialog"); + expect(pageEdit.admin.create.mode).toBe("dialog"); + expect(pageEdit.admin.edit.mode).toBe("page"); + }); + + it.each(["create", "edit"] as const)("rejects an unknown %s mode", key => { + expect(() => + define({ + admin: { + label, + // Only reachable from JavaScript, or from a value that widened + // upstream - the type refuses it outright. + [key]: { mode: "drawer" as unknown as "dialog" }, + }, + }), + ).toThrow(ContentEngineError); + }); + }); + describe("admin validation", () => { it("rejects a searchable field that is not text-like", () => { expect(() => diff --git a/packages/vitnode/src/content/define.ts b/packages/vitnode/src/content/define.ts index 9be48f575..97efe4b47 100644 --- a/packages/vitnode/src/content/define.ts +++ b/packages/vitnode/src/content/define.ts @@ -1,6 +1,8 @@ import type { AnyContentTypeDefinition, + ContentAdminActionConfig, ContentAdminConfig, + ContentAdminFormMode, ContentDeliveryConfig, ContentDeliveryDescriptionField, ContentDeliveryEnabled, @@ -38,6 +40,7 @@ import { resolveContentAdvanced, } from "./advanced"; import { + CONTENT_ADMIN_FORM_MODES, CONTENT_EDITORIAL_FIELDS, CONTENT_ENUM_DEFAULT_LENGTH, CONTENT_FIELD_NAME_PATTERN, @@ -435,6 +438,33 @@ const isAdminColumnField = (fieldValue: ContentFieldDescriptor): boolean => !NON_COLUMN_KINDS.has(fieldValue.kind) && !isContentRelationCollection(fieldValue); +const adminFormModes: readonly string[] = CONTENT_ADMIN_FORM_MODES; + +/** + * `admin.create.mode` / `admin.edit.mode`, defaulted and checked. + * + * Defaults to `dialog`, which is what keeps every content type written before + * page mode existed behaving exactly as it did. The runtime check is here for a + * JavaScript caller and for a value that widened somewhere upstream - the type + * already refuses anything outside the union. + */ +const resolveFormMode = ( + id: string, + label: string, + action: ContentAdminActionConfig | undefined, +): ContentAdminFormMode => { + const mode = action?.mode ?? "dialog"; + + if (!adminFormModes.includes(mode)) { + throw new ContentEngineError( + `${label} is "${mode}". Expected one of ${adminFormModes.map(value => `"${value}"`).join(", ")}.`, + { contentTypeId: id }, + ); + } + + return mode; +}; + const resolveAdmin = ( id: string, fields: ContentFieldMap, @@ -553,7 +583,11 @@ const resolveAdmin = ( ? (columnFieldNames.find(name => SEARCHABLE_KINDS.has(fields[name].kind), ) ?? null) - : String(admin.titleField); + : // `null` is a decision, not an omission: it says this content type has + // no shared title rather than "pick one for me". + admin.titleField === null + ? null + : String(admin.titleField); if (titleField !== null && !columnFieldNames.includes(titleField)) { throw new ContentEngineError( `admin.titleField references unknown field "${titleField}".`, @@ -562,6 +596,8 @@ const resolveAdmin = ( } return { + create: { mode: resolveFormMode(id, "admin.create.mode", admin.create) }, + edit: { mode: resolveFormMode(id, "admin.edit.mode", admin.edit) }, form: { fields: formFields }, label: admin.label, list: { diff --git a/packages/vitnode/src/content/index.ts b/packages/vitnode/src/content/index.ts index eebb070dc..6e8fc8400 100644 --- a/packages/vitnode/src/content/index.ts +++ b/packages/vitnode/src/content/index.ts @@ -220,7 +220,11 @@ export { } from "./localization"; export type { ContentFieldPartition } from "./localization"; export { + CONTENT_EDIT_HREF_PLACEHOLDER, contentAdminHref, + contentCreateHref, + contentEditHref, + contentEditHrefTemplate, contentPermissionEntries, contentTypeToPath, findContentTypeById, @@ -269,7 +273,9 @@ export type { ContentSitemapEntry, ContentSitemapIndexEntry } from "./sitemap"; export { slugify } from "./slug"; export type { AnyContentTypeDefinition, + ContentAdminActionConfig, ContentAdminConfig, + ContentAdminFormMode, ContentAdminLabel, ContentAdminListConfig, ContentBooleanField, diff --git a/packages/vitnode/src/content/registry.test.ts b/packages/vitnode/src/content/registry.test.ts index 64afd614c..6f69035ce 100644 --- a/packages/vitnode/src/content/registry.test.ts +++ b/packages/vitnode/src/content/registry.test.ts @@ -13,7 +13,11 @@ import { defineContentType } from "./define"; import { ContentEngineError } from "./errors"; import { field } from "./fields"; import { + CONTENT_EDIT_HREF_PLACEHOLDER, contentAdminHref, + contentCreateHref, + contentEditHref, + contentEditHrefTemplate, contentTypeToPath, findContentTypeById, orderableColumns, @@ -276,6 +280,18 @@ describe("routing helpers", () => { ); }); + it("builds the generated form page URLs off the list one", () => { + expect(contentCreateHref("example.article")).toBe( + "/admin/content/example/article/create", + ); + expect(contentEditHref("example.article", 42)).toBe( + "/admin/content/example/article/42/edit", + ); + expect(contentEditHrefTemplate("example.article")).toBe( + `/admin/content/example/article/${CONTENT_EDIT_HREF_PLACEHOLDER}/edit`, + ); + }); + it("round-trips the catch-all slug", () => { expect(pathToContentTypeId(["example", "article"])).toBe("example.article"); }); diff --git a/packages/vitnode/src/content/registry.ts b/packages/vitnode/src/content/registry.ts index 05230e15b..038cceaf5 100644 --- a/packages/vitnode/src/content/registry.ts +++ b/packages/vitnode/src/content/registry.ts @@ -6,6 +6,8 @@ import type { import type { AnyContentTypeDefinition } from "./types"; import { + CONTENT_ADMIN_CREATE_SEGMENT, + CONTENT_ADMIN_EDIT_SEGMENT, CONTENT_EDITORIAL_FIELDS, CONTENT_PERMISSIONS, CONTENT_PUBLICATION_FIELDS, @@ -304,6 +306,34 @@ export const pathToContentTypeId = (slug: readonly string[]): string => export const contentAdminHref = (id: string): string => `/admin/content/${contentTypeToPath(id)}`; +/** + * `/admin/content/example/article/create` - the generated create **page**. + * + * Built off `contentAdminHref` rather than spelled out again, so the list URL + * and the two form URLs cannot drift apart. Only meaningful for a content type + * whose `admin.create.mode` is `page`; the resolver refuses it otherwise. + */ +export const contentCreateHref = (id: string): string => + `${contentAdminHref(id)}/${CONTENT_ADMIN_CREATE_SEGMENT}`; + +/** `/admin/content/example/article/42/edit` - the generated edit **page**. */ +export const contentEditHref = (id: string, itemId: number): string => + `${contentAdminHref(id)}/${itemId}/${CONTENT_ADMIN_EDIT_SEGMENT}`; + +/** + * The edit URL with `{id}` still in it. + * + * A create page is a server component and the identifier only exists once the + * mutation has answered, so the client half is handed a template rather than a + * callback - a function cannot cross an RSC boundary, and a second copy of the + * URL shape would be free to drift from {@link contentEditHref}. + */ +export const contentEditHrefTemplate = (id: string): string => + contentEditHref(id, CONTENT_EDIT_HREF_PLACEHOLDER as unknown as number); + +/** The token {@link contentEditHrefTemplate} leaves behind for the client. */ +export const CONTENT_EDIT_HREF_PLACEHOLDER = "{id}"; + /** * The permissions every content type gets. `can_view` gates the list and the * nav item; the writes depend on it so a role cannot create rows it cannot see. diff --git a/packages/vitnode/src/content/server/openapi-parity.test.ts b/packages/vitnode/src/content/server/openapi-parity.test.ts index 292b15d31..689904832 100644 --- a/packages/vitnode/src/content/server/openapi-parity.test.ts +++ b/packages/vitnode/src/content/server/openapi-parity.test.ts @@ -244,6 +244,7 @@ const adminService = () => ({ delete: vi.fn(), findById: vi.fn().mockResolvedValue(row), findDetail: vi.fn(), + findRowById: vi.fn().mockResolvedValue({ ...row, labels: {} }), findMany: vi.fn().mockResolvedValue({ edges: [{ ...row, labels: {} }], pageInfo: { @@ -343,7 +344,7 @@ describe("admin routes match their OpenAPI document", () => { it("answers 404 for a record that is not there", async () => { const suite = editorialSuite(); - service.findById.mockResolvedValue(null); + service.findRowById.mockResolvedValue(null); await expectParity(suite, { expected: 404, diff --git a/packages/vitnode/src/content/server/routes.test.ts b/packages/vitnode/src/content/server/routes.test.ts index 192ccf3f5..f8ad0f708 100644 --- a/packages/vitnode/src/content/server/routes.test.ts +++ b/packages/vitnode/src/content/server/routes.test.ts @@ -116,6 +116,7 @@ const harness = ({ allow = true }: { allow?: boolean } = {}): Harness => { advancedFields: vi.fn(), findDetail: vi.fn(), findById: vi.fn(), + findRowById: vi.fn(), findMany: vi.fn(), options: vi.fn(), update: vi.fn(), @@ -164,6 +165,7 @@ const publicationHarness = ({ allow = true }: { allow?: boolean } = {}) => { advancedFields: vi.fn(), findDetail: vi.fn(), findById: vi.fn(), + findRowById: vi.fn(), findMany: vi.fn(), options: vi.fn(), publish: vi.fn(), @@ -294,17 +296,19 @@ describe("generated content routes", () => { describe("detail", () => { it("returns the row", async () => { const { app, service } = harness(); - service.findById.mockResolvedValue(row); + // The detail route reads the row *with* its reference labels, so a form + // opening on it can show the name behind a relation rather than its id. + service.findRowById.mockResolvedValue({ ...row, labels: {} }); const res = await app.request("/7"); expect(res.status).toBe(200); - await expect(res.json()).resolves.toMatchObject({ id: 7 }); + await expect(res.json()).resolves.toMatchObject({ id: 7, labels: {} }); }); it("returns 404 for a missing row", async () => { const { app, service } = harness(); - service.findById.mockResolvedValue(null); + service.findRowById.mockResolvedValue(null); expect((await app.request("/7")).status).toBe(404); }); @@ -712,6 +716,7 @@ describe("generated content routes", () => { relations: {}, repeatable: {}, findById: vi.fn(), + findRowById: vi.fn(), findMany: vi.fn(), options: vi.fn(), publish: vi.fn(), diff --git a/packages/vitnode/src/content/server/routes.ts b/packages/vitnode/src/content/server/routes.ts index 451bf96bf..f030abb19 100644 --- a/packages/vitnode/src/content/server/routes.ts +++ b/packages/vitnode/src/content/server/routes.ts @@ -126,6 +126,7 @@ export const buildContentRoutes = < }) .nullable(); + const detailRow = schemas.selectObject.extend({ labels: zodLabels }); const listRow = schemas.selectObject.extend({ labels: zodLabels, ...(localized ? { translation: zodRowTranslation.optional() } : {}), @@ -426,13 +427,17 @@ export const buildContentRoutes = < description: `Get one ${label.singular}`, request: { params: schemas.params }, responses: { - 200: jsonResponse(schemas.selectObject, `${label.singular} found`), + // `labels` alongside the record, the same way the list returns them: + // a `relation` holds an identifier, and the form that edits it has to + // show the name behind it. Additive to the row every earlier client + // already parses. + 200: jsonResponse(detailRow, `${label.singular} found`), 400: invalidIdentifier, 404: { description: `${label.singular} not found` }, }, }, handler: async c => { - const row = await model.service(c).findById(identifier(c)); + const row = await model.service(c).findRowById(identifier(c)); if (!row) throw notFound(definition); return c.json(row, 200); diff --git a/packages/vitnode/src/content/server/search-sync.test.ts b/packages/vitnode/src/content/server/search-sync.test.ts index 2900c7dd5..df24826be 100644 --- a/packages/vitnode/src/content/server/search-sync.test.ts +++ b/packages/vitnode/src/content/server/search-sync.test.ts @@ -83,6 +83,7 @@ const harness = ({ advancedFields: vi.fn(), findDetail: vi.fn(), findById: vi.fn(), + findRowById: vi.fn(), relations: {}, repeatable: {}, findMany: vi.fn(), diff --git a/packages/vitnode/src/content/server/service.ts b/packages/vitnode/src/content/server/service.ts index 8feec0465..7b0206814 100644 --- a/packages/vitnode/src/content/server/service.ts +++ b/packages/vitnode/src/content/server/service.ts @@ -335,6 +335,22 @@ export interface ContentServiceBase { edges: ContentListRow[]; pageInfo: ContentPageInfo; }>; + /** + * One record **with its reference labels**, exactly as the list returns them. + * + * The read a form makes: a `relation` or `user` value is an identifier, and an + * editor has to be shown the name behind it. `findById` deliberately stays a + * plain row - the labels cost one LEFT JOIN per reference field, and the + * callers that only want the record should not pay for them. + * + * Administrative, like every label: it is read from the target's + * `admin.titleField`, which may name something the target never publishes. The + * public projection does not use it. + */ + findRowById: ( + id: number, + options?: ContentServiceOptions, + ) => Promise | null>; /** Options for a `user` or `relation` picker, filtered by a search term. */ options: ( field: ContentReferenceFieldName, @@ -695,6 +711,31 @@ export const createContentService = < return row ? toRow(row) : null; }, + findRowById: async (id, options) => { + const selection: Record> = { + ...ownSelection(), + ...Object.fromEntries( + Object.entries(references).map(([name, target]) => [ + `${LABEL_PREFIX}${name}`, + target.labelColumn, + ]), + ), + }; + + let builder = db(options).select(selection).from(table).$dynamic(); + + for (const target of Object.values(references)) { + builder = builder.leftJoin( + target.aliased, + eq(target.owner, target.idColumn), + ); + } + + const [row] = await builder.where(eq(primaryCursor, id)).limit(1); + + return row ? splitLabels(row) : null; + }, + findDetail: async (id, options) => { const database = db(options); const row = await readOne(id, database); diff --git a/packages/vitnode/src/content/types.ts b/packages/vitnode/src/content/types.ts index 3e3d89abe..7e2947d38 100644 --- a/packages/vitnode/src/content/types.ts +++ b/packages/vitnode/src/content/types.ts @@ -1,4 +1,5 @@ import type { + CONTENT_ADMIN_FORM_MODES, CONTENT_DELIVERY_DESCRIPTION_KINDS, CONTENT_DELIVERY_NO_INDEX_KINDS, CONTENT_DELIVERY_TITLE_KINDS, @@ -735,11 +736,34 @@ export interface ContentAdminListConfig< searchableFields?: ScalarColumnFieldKeys[]; } +/** + * How the AdminCP presents a create or an edit form. + * + * `dialog` is the default and always will be: every content type written before + * this existed keeps the screen it had, and opting into `page` is one line. + */ +export type ContentAdminFormMode = (typeof CONTENT_ADMIN_FORM_MODES)[number]; + +/** + * One AdminCP action's presentation. + * + * An object rather than a bare string so the shape has somewhere to grow - and + * so `create: { mode: "page" }` reads the same as every other block in the + * descriptor. + */ +export interface ContentAdminActionConfig { + mode?: ContentAdminFormMode; +} + export interface ContentAdminConfig< TFields = ContentFieldMap, TPublication extends boolean = boolean, TEditorial extends boolean = boolean, > { + /** Presentation of the create form. Defaults to `{ mode: "dialog" }`. */ + create?: ContentAdminActionConfig; + /** Presentation of the edit form. Defaults to `{ mode: "dialog" }`. */ + edit?: ContentAdminActionConfig; form?: { fields?: SharedFieldKeys[] }; label: ContentAdminLabel; list?: ContentAdminListConfig; @@ -754,9 +778,14 @@ export interface ContentAdminConfig< * * Shared fields only. A localized title has a different value per language, so * naming one here would make a toast depend on whose locale the reader is in; - * Stage 5B gives the AdminCP a locale-aware title of its own. + * the AdminCP's locale tabs are where a localized value appears. + * + * `null` says the content type genuinely has no shared title - which is the + * honest answer for one whose every text field is localized. Left `undefined` + * the first shared text field is picked, and that guess is wrong for, say, a + * category whose only shared column is a colour. */ - titleField?: ScalarColumnFieldKeys; + titleField?: null | ScalarColumnFieldKeys; } /** @@ -769,6 +798,8 @@ export interface ContentAdminConfig< * the narrower type bought nothing. */ export interface ResolvedContentAdminConfig { + create: { mode: ContentAdminFormMode }; + edit: { mode: ContentAdminFormMode }; form: { fields: string[] }; label: ContentAdminLabel; list: { diff --git a/packages/vitnode/src/lib/plugin.test.ts b/packages/vitnode/src/lib/plugin.test.ts new file mode 100644 index 000000000..9afa74711 --- /dev/null +++ b/packages/vitnode/src/lib/plugin.test.ts @@ -0,0 +1,39 @@ +// @vitest-environment node +import { describe, expect, it } from "vitest"; + +import type { ContentFormLayout } from "./plugin"; + +import { resolveContentFormLayout } from "./plugin"; + +const shared = (() => null) as ContentFormLayout; +const createOnly = (() => null) as ContentFormLayout; +const editOnly = (() => null) as ContentFormLayout; + +describe("resolveContentFormLayout", () => { + it("has no layout when a plugin registered none", () => { + expect(resolveContentFormLayout(undefined, "create")).toBeUndefined(); + expect(resolveContentFormLayout({}, "edit")).toBeUndefined(); + }); + + it("uses one shared layout for both actions", () => { + expect(resolveContentFormLayout({ layout: shared }, "create")).toBe(shared); + expect(resolveContentFormLayout({ layout: shared }, "edit")).toBe(shared); + }); + + it("lets one action override the shared layout", () => { + const forms = { create: { layout: createOnly }, layout: shared }; + + expect(resolveContentFormLayout(forms, "create")).toBe(createOnly); + expect(resolveContentFormLayout(forms, "edit")).toBe(shared); + }); + + it("takes per-action layouts with no shared fallback", () => { + const forms = { + create: { layout: createOnly }, + edit: { layout: editOnly }, + }; + + expect(resolveContentFormLayout(forms, "create")).toBe(createOnly); + expect(resolveContentFormLayout(forms, "edit")).toBe(editOnly); + }); +}); diff --git a/packages/vitnode/src/lib/plugin.ts b/packages/vitnode/src/lib/plugin.ts index 445eaf677..9959939e8 100644 --- a/packages/vitnode/src/lib/plugin.ts +++ b/packages/vitnode/src/lib/plugin.ts @@ -47,6 +47,73 @@ export interface ContentCellProps< row: ContentSelect; } +/** + * Which of a content type's two form surfaces a layout is being rendered in. + * + * A localized content type has both at once: `shared` holds the fields that are + * columns on the base table, and `translation` holds one language's own. The + * same layout is rendered in each, and a `ContentFormField` naming a field that + * is not in this surface renders nothing - so one layout can place `title` and + * `category` wherever it likes without knowing which table either lives on. + */ +export type ContentFormSurface = "shared" | "translation"; + +/** + * Everything a custom form layout is handed, and nothing more. + * + * Deliberately all serialisable: a layout is a client component referenced from + * `config.tsx`, which is a **server** module, so React props cross an RSC + * boundary to reach it. Field elements, the form instance and the submit action + * are not here for exactly that reason - they come from + * `useContentForm()`/`ContentFormField`, which are client context and therefore + * never cross anything. + * + * There is no database handle, Drizzle table, Hono context or mutation model in + * this shape, and there is not going to be: a layout decides where a field + * appears, and the Content Engine decides what happens when it is submitted. + */ +export interface ContentFormLayoutProps { + contentTypeId: string; + /** `undefined` while creating - the record does not exist yet. */ + itemId?: number; + /** The locale being written, on a `translation` surface. */ + locale?: string; + mode: "create" | "edit"; + pluginId: string; + /** Whether the content type has the draft/published lifecycle. */ + publication: boolean; + singular: string; + surface: ContentFormSurface; + /** The record's resolved title while editing, for headings. */ + title?: string; +} + +export type ContentFormLayout = ( + props: ContentFormLayoutProps, +) => React.ReactNode; + +/** + * Layout overrides for the generated create and edit forms. + * + * `layout` alone covers the common case - one editor screen used for both - and + * `create`/`edit` override it when they genuinely differ. Normalised by + * `resolveContentFormLayout`, so nothing downstream has to know about the + * fallback. + */ +export interface ContentTypeFormsRegistration { + create?: { layout?: ContentFormLayout }; + edit?: { layout?: ContentFormLayout }; + /** Used by both create and edit unless one of them overrides it. */ + layout?: ContentFormLayout; +} + +/** The layout for one action, or `undefined` for the generated one. */ +export const resolveContentFormLayout = ( + forms: ContentTypeFormsRegistration | undefined, + mode: "create" | "edit", +): ContentFormLayout | undefined => + forms?.[mode]?.layout ?? forms?.layout ?? undefined; + /** * A content type registration once its definition generic has been erased, so * one plugin can list content types with different field maps in one array. @@ -61,6 +128,8 @@ export interface ContentTypeFrontendRegistration { string, { component: (props: ItemAutoFormComponentProps) => React.ReactNode } >; + /** Custom create/edit form layouts. Presentation only - see `forms`. */ + forms?: ContentTypeFormsRegistration; icon?: React.ReactNode; } @@ -82,6 +151,16 @@ interface TypedContentTypeRegistration< { component: (props: ItemAutoFormComponentProps) => React.ReactNode } > >; + /** + * Replace the generated form **layout** - where the fields are, not what they + * do. + * + * The Content Engine still owns the form schema, the validation, the defaults, + * the mutation, the version precondition, the structured errors, the toast and + * the cache invalidation. A layout places `` + * and `` inside one shared form instance. + */ + forms?: ContentTypeFormsRegistration; /** Sidebar icon. Defaults to a generic document icon. */ icon?: React.ReactNode; } diff --git a/packages/vitnode/src/locales/en.json b/packages/vitnode/src/locales/en.json index 1042340bd..4056fb74d 100644 --- a/packages/vitnode/src/locales/en.json +++ b/packages/vitnode/src/locales/en.json @@ -426,6 +426,9 @@ "submit": "Save changes", "success": "{name} has been updated." }, + "page": { + "back": "Back to {name}" + }, "delete": { "title": "Delete {name}", "desc": "Are you sure you want to delete ? This action cannot be undone.", diff --git a/packages/vitnode/src/routes/breadcrumb/admin/content/[...slug]/page.tsx b/packages/vitnode/src/routes/breadcrumb/admin/content/[...slug]/page.tsx index 37262ff44..2a079abeb 100644 --- a/packages/vitnode/src/routes/breadcrumb/admin/content/[...slug]/page.tsx +++ b/packages/vitnode/src/routes/breadcrumb/admin/content/[...slug]/page.tsx @@ -1,22 +1,68 @@ +import { getTranslations } from "next-intl/server"; + +import { + CONTENT_ADMIN_CREATE_SEGMENT, + CONTENT_ADMIN_EDIT_SEGMENT, +} from "@/content/const"; +import { contentAdminHref, contentTypeToPath } from "@/content/registry"; import { BreadcrumbAdmin } from "@/views/admin/layouts/breadcrumb/breadcrumb-admin"; import { getContentLabels, - resolveContentType, + resolveContentRoute, } from "@/views/admin/views/content/content-admin-view"; +/** + * The breadcrumb of every generated Content Engine screen. + * + * The list keeps the trail it always had. A create or an edit **page** appends + * one more crumb, labelled from `core.content` with the content type's own + * singular - so it reads "Blog / Articles / Create article" in whatever language + * the AdminCP is in, and "Articles" becomes a link back to the list. + * + * The record id is deliberately **not** a crumb of its own: `/42/` would render + * as a dead "42" between two words, and the page it would point at is the one + * being read. + */ export default async function BreadcrumbSlot({ params, }: { params: Promise<{ slug: string[] }>; }) { const { slug } = await params; - const entry = await resolveContentType(params); - const labels = entry ? await getContentLabels(entry) : undefined; + const route = await resolveContentRoute(params); + const labels = route ? await getContentLabels(route.entry) : undefined; + + if (!route || route.action === "list") { + return ( + + ); + } + + const t = await getTranslations("core.content"); + const { definition } = route.entry; return ( ); } diff --git a/packages/vitnode/src/views/admin/views/content/actions/content-form.tsx b/packages/vitnode/src/views/admin/views/content/actions/content-form.tsx index f3e271fe5..810421de7 100644 --- a/packages/vitnode/src/views/admin/views/content/actions/content-form.tsx +++ b/packages/vitnode/src/views/admin/views/content/actions/content-form.tsx @@ -2,17 +2,15 @@ // `create-action`/`edit-action`, which are already client entries. Declaring // it again would make this a nested client entry, and `next/dynamic` cannot // resolve one from inside a published package - the dialog spins forever. -import { CircleCheckIcon, FileClockIcon } from "lucide-react"; import { useTranslations } from "next-intl"; import React from "react"; import { toast } from "sonner"; import type { ItemAutoFormComponentProps } from "@/components/form/auto-form"; import type { ContentFormSpec } from "@/content/admin/spec"; +import type { ContentFormLayout } from "@/lib/plugin"; -import { DateFormat } from "@/components/date-format"; import { AutoForm, type AutoFormOnSubmit } from "@/components/form/auto-form"; -import { Badge } from "@/components/ui/badge"; import { useDialog } from "@/components/ui/dialog"; import { buildFormSchemaFromSpec, @@ -23,6 +21,8 @@ import { usePathname, useRouter } from "@/lib/navigation"; import type { ContentConflictState } from "./conflict-notice"; +import { ContentFormProvider } from "../form/context"; +import { ContentFormPublication } from "../form/publication-status"; import { ContentField } from "../lib/field-component"; import { contentErrorKey } from "../lib/mutation-feedback"; import { ConflictNotice } from "./conflict-notice"; @@ -33,43 +33,6 @@ import { reloadContentRowAction, } from "./mutation-api.server"; -/** - * A read-only line saying where the row is in the lifecycle. - * - * Read-only on purpose: `status` and `publishedAt` are not in the form schema, - * and the one place that moves them is the table's publish action. Two - * competing mutation paths in one dialog is how a form ends up fighting its own - * optimistic state. - */ -const PublicationStatus = ({ - publishedAt, - status, -}: { - publishedAt: unknown; - status: unknown; -}) => { - const t = useTranslations("core.content.status"); - const published = status === "published"; - const date = typeof publishedAt === "string" ? new Date(publishedAt) : null; - - return ( -
- {t("label")} - - {published ? ( - - ) : ( - - )} - {published ? t("published") : t("draft")} - - - {date ? : t("never_published")} - -
- ); -}; - export interface ContentFormProps { /** Existing values when editing; absent when creating. */ data?: Record & { id: number }; @@ -78,6 +41,18 @@ export interface ContentFormProps { string, (props: ItemAutoFormComponentProps) => React.ReactNode >; + /** Custom layout declared in `buildPlugin`. Presentation only. */ + layout?: ContentFormLayout; + /** + * Where a page-mode create hands the new record over. Ignored in a dialog, + * which closes and refreshes the list instead. + */ + onCreated?: (id: number) => void; + /** + * Where the form is. A dialog closes itself and refreshes the list behind it; + * a page navigates instead, because there is nothing behind it to refresh. + */ + presentation?: "dialog" | "page"; /** Whether the content type has the draft/published lifecycle. */ publication?: boolean; /** The content type's singular label, used in the success toast. */ @@ -90,6 +65,9 @@ export interface ContentFormProps { export const ContentForm = ({ data, fieldOverrides = {}, + layout, + onCreated, + presentation = "dialog", publication = false, singular, spec, @@ -105,7 +83,7 @@ export const ContentForm = ({ null, ); - // The version this dialog opened with, and the one every save is checked + // The version this form opened with, and the one every save is checked // against - until a conflict is resolved, which replaces it with the version // the editor has now actually seen. const [expectedVersion, setExpectedVersion] = React.useState(() => @@ -148,7 +126,7 @@ export const ContentForm = ({ : await createContentAction(spec.contentTypeId, payload); if (mutation.error !== undefined) { - // A lost update is the one failure with somewhere to go: the dialog stays + // A lost update is the one failure with somewhere to go: the form stays // open with everything the editor typed, and the banner offers to show // what changed underneath them. if (mutation.conflict?.code === "CONTENT_VERSION_CONFLICT") { @@ -181,16 +159,63 @@ export const ContentForm = ({ }, ); + if (presentation === "page") { + // A page has nothing behind it to refresh, so a create hands over to + // whoever knows where the record should be opened next, and an edit stays + // put with fresh server data. + if (!data && mutation.id !== undefined) { + onCreated?.(mutation.id); + + return; + } + + push(pathname); + + return; + } + // Close first, then navigate: a refresh fired while the dialog is still // animating out leaves its overlay stranded over the page. setOpen?.(false); push(pathname); }; + const fields = spec.fields.map( + ( + fieldSpec, + ): { + component: (props: ItemAutoFormComponentProps) => React.ReactNode; + id: string; + } => ({ + id: fieldSpec.name, + + // MUST NOT be async: `AutoForm` calls this to get an element, and an + // async function hands it a fresh Promise every render - React 19 + // suspends on promise children, so the dialog spins forever. + // eslint-disable-next-line @typescript-eslint/promise-function-async -- see above + component: props => { + const override = fieldOverrides[fieldSpec.name]; + if (override) return override(props); + + return ( + + await loadContentOptionsAction(spec.contentTypeId, field, search) + } + spec={fieldSpec} + {...props} + /> + ); + }, + }), + ); + + const Layout = layout; + return ( <> - {publication && data ? ( - @@ -206,33 +231,38 @@ export const ContentForm = ({ ) : null} ({ - id: fieldSpec.name, - - // MUST NOT be async: `AutoForm` calls this to get an element, and an - // async function hands it a fresh Promise every render - React 19 - // suspends on promise children, so the dialog spins forever. - // eslint-disable-next-line @typescript-eslint/promise-function-async -- see above - component: props => { - const override = fieldOverrides[fieldSpec.name]; - if (override) return override(props); - - return ( - - await loadContentOptionsAction( - spec.contentTypeId, - field, - search, - ) - } - spec={fieldSpec} - {...props} - /> - ); - }, - }))} + fields={fields} formSchema={formSchema} + layout={ + Layout + ? renderedFields => ( + field.name), + fields: renderedFields, + mode: data ? "edit" : "create", + publication: { + enabled: publication, + publishedAt: data?.publishedAt, + status: data?.status, + }, + surface: "shared", + }} + > + + + ) + : undefined + } onSubmit={onSubmit} submitButtonProps={{ children: t(data ? "edit.submit" : "create.submit"), diff --git a/packages/vitnode/src/views/admin/views/content/actions/create-action.tsx b/packages/vitnode/src/views/admin/views/content/actions/create-action.tsx index bd4930e02..038ae404c 100644 --- a/packages/vitnode/src/views/admin/views/content/actions/create-action.tsx +++ b/packages/vitnode/src/views/admin/views/content/actions/create-action.tsx @@ -15,6 +15,7 @@ import { DialogTrigger, } from "@/components/ui/dialog"; import { Loader } from "@/components/ui/loader"; +import { Link } from "@/lib/navigation"; import type { ContentFormProps } from "./content-form"; @@ -24,12 +25,29 @@ const ContentForm = dynamic(async () => import("./content-form").then(mod => ({ default: mod.ContentForm })), ); +/** + * The Create button. + * + * With `admin.create.mode: "page"` the content type is given `href`, and this is + * an ordinary link - not a dialog that mounts and immediately redirects. Nothing + * of the form is downloaded until the page it points at is actually requested. + */ export const CreateContentAction = ({ + href, singular, ...props -}: Omit) => { +}: Omit & { href?: string }) => { const t = useTranslations("core.content.create"); + if (href) { + return ( + + ); + } + return ( }> diff --git a/packages/vitnode/src/views/admin/views/content/actions/edit-action.tsx b/packages/vitnode/src/views/admin/views/content/actions/edit-action.tsx index c3b6bc8d4..75060ab29 100644 --- a/packages/vitnode/src/views/admin/views/content/actions/edit-action.tsx +++ b/packages/vitnode/src/views/admin/views/content/actions/edit-action.tsx @@ -25,6 +25,7 @@ import { TooltipTrigger, } from "@/components/ui/tooltip"; import { CONTENT_PERMISSIONS } from "@/content/const"; +import { Link } from "@/lib/navigation"; import type { ContentFormProps } from "./content-form"; @@ -52,6 +53,7 @@ const LocaleEditor = dynamic(async () => export const EditContentAction = ({ defaultLocale, editorial = false, + href, permissionModule, pluginId, singular, @@ -61,6 +63,8 @@ export const EditContentAction = ({ /** The content type's default locale. Required when `translationSpec` is set. */ defaultLocale?: string; editorial?: boolean; + /** Set by `admin.edit.mode: "page"` - navigates instead of opening a dialog. */ + href?: string; permissionModule: string; pluginId: string; /** Localized-field form spec, or `null` when the content type is not localized. */ @@ -82,6 +86,29 @@ export const EditContentAction = ({ if (!canEdit && !(localized && canTranslate)) return null; + if (href) { + return ( + + + } + size="icon" + variant="ghost" + > + + + } + /> + + {t("title", { name: singular })} + + + ); + } + return ( diff --git a/packages/vitnode/src/views/admin/views/content/actions/mutation-api.server.ts b/packages/vitnode/src/views/admin/views/content/actions/mutation-api.server.ts index 806c2d441..fd02e7006 100644 --- a/packages/vitnode/src/views/admin/views/content/actions/mutation-api.server.ts +++ b/packages/vitnode/src/views/admin/views/content/actions/mutation-api.server.ts @@ -64,6 +64,14 @@ interface MutationResult { */ delivery?: ContentDeliveryConflict; error?: string; + /** + * The identifier of a newly created record. + * + * Only set by `createContentAction`, and only on success - a page-mode create + * navigates to the record's own edit page, and guessing at the id would open + * the wrong one. + */ + id?: number; /** Why a schedule was refused, when the API said. */ rejection?: ContentScheduleRejection; /** Lets the UI tell a restricted delete (409) from a generic failure. */ @@ -305,7 +313,7 @@ export const createContentAction = async ( before: [], }); - return {}; + return { id: created }; }; export const editContentAction = async ( diff --git a/packages/vitnode/src/views/admin/views/content/actions/page-links.test.tsx b/packages/vitnode/src/views/admin/views/content/actions/page-links.test.tsx new file mode 100644 index 000000000..207582c20 --- /dev/null +++ b/packages/vitnode/src/views/admin/views/content/actions/page-links.test.tsx @@ -0,0 +1,97 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; + +import type { ContentFormSpec } from "@/content/admin/spec"; + +vi.mock("server-only", () => ({})); + +vi.mock("next-intl", () => ({ + useLocale: () => "en", + useTranslations: () => (key: string) => key, +})); + +vi.mock("@/lib/navigation", () => ({ + Link: ({ children, href }: { children: React.ReactNode; href: string }) => ( + + {children} + + ), + getPathname: () => "", + redirect: () => undefined, + usePathname: () => "", + useRouter: () => ({ push: () => undefined, refresh: () => undefined }), +})); + +vi.mock("@/components/staff-permission/provider", () => ({ + useAdminStaffPermission: () => true, +})); + +const { CreateContentAction } = await import("./create-action"); +const { EditContentAction } = await import("./edit-action"); + +const spec: ContentFormSpec = { + contentTypeId: "blog.post", + fields: [], + pluginId: "@vitnode/blog", + titleField: null, +}; + +/** + * Page mode has to be a **link**, not a dialog that redirects. + * + * A dialog that mounted and then navigated would download the whole form - every + * field component, the editor, the lot - to show it for one frame. + */ +describe("page-mode actions", () => { + it("creates through a link when the content type asked for a page", () => { + render( + , + ); + + expect(screen.getByTestId("link").getAttribute("href")).toBe( + "/admin/content/blog/post/create", + ); + }); + + it("keeps the dialog when it did not", () => { + render(); + + expect(screen.queryByTestId("link")).toBeNull(); + expect(screen.getByRole("button")).toBeTruthy(); + }); + + it("edits through a link when the content type asked for a page", () => { + render( + , + ); + + expect(screen.getByTestId("link").getAttribute("href")).toBe( + "/admin/content/blog/post/42/edit", + ); + }); + + it("keeps the edit dialog when it did not", () => { + render( + , + ); + + expect(screen.queryByTestId("link")).toBeNull(); + }); +}); diff --git a/packages/vitnode/src/views/admin/views/content/actions/translations/locale-editor.tsx b/packages/vitnode/src/views/admin/views/content/actions/translations/locale-editor.tsx index 090ed775b..ee1b8caee 100644 --- a/packages/vitnode/src/views/admin/views/content/actions/translations/locale-editor.tsx +++ b/packages/vitnode/src/views/admin/views/content/actions/translations/locale-editor.tsx @@ -124,11 +124,13 @@ export const LocaleEditor = ({ { setReloads(count => count + 1); diff --git a/packages/vitnode/src/views/admin/views/content/actions/translations/translation-panel.test.tsx b/packages/vitnode/src/views/admin/views/content/actions/translations/translation-panel.test.tsx new file mode 100644 index 000000000..55f422b27 --- /dev/null +++ b/packages/vitnode/src/views/admin/views/content/actions/translations/translation-panel.test.tsx @@ -0,0 +1,126 @@ +import { render, screen, waitFor } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; + +import type { ContentFormSpec } from "@/content/admin/spec"; + +vi.mock("server-only", () => ({})); + +vi.mock("next-intl", () => ({ + useLocale: () => "en", + useTranslations: () => (key: string) => key, +})); + +vi.mock("@/lib/navigation", () => ({ + Link: () => null, + getPathname: () => "", + redirect: () => undefined, + usePathname: () => "", + useRouter: () => ({ push: () => undefined, refresh: () => undefined }), +})); + +vi.mock("@/components/staff-permission/provider", () => ({ + useAdminStaffPermission: () => true, +})); + +vi.mock("../translation-api.server", () => ({ + createContentTranslationAction: vi.fn(), + deleteContentTranslationAction: vi.fn(), + editContentTranslationAction: vi.fn(), + getContentTranslationAction: async () => { + await Promise.resolve(); + + return { row: null }; + }, + publishContentTranslationAction: vi.fn(), + unpublishContentTranslationAction: vi.fn(), +})); + +vi.mock("../mutation-api.server", () => ({ + loadContentOptionsAction: async () => await Promise.resolve([]), +})); + +const { TranslationPanel } = await import("./translation-panel"); + +const spec: ContentFormSpec = { + contentTypeId: "test.localized", + pluginId: "@vitnode/test", + titleField: "title", + fields: [ + { + kind: "text", + label: "Title", + name: "title", + nullable: false, + required: true, + }, + { + kind: "textarea", + label: "Body", + name: "body", + nullable: false, + required: true, + }, + ], +}; + +const renderPanel = ( + props: Partial> = {}, +) => + render( + undefined} + permissionModule="pages" + pluginId="@vitnode/test" + publication={false} + spec={spec} + {...props} + />, + ); + +/** + * A locale tab has to render **inputs**. + * + * It once did not: the panel handed `AutoForm` a list of bare field ids, and + * `AutoForm` renders nothing for a field with no component - so every localized + * content type had a form with a submit button and no way to type into it. + */ +describe("TranslationPanel", () => { + it("renders an input for every localized field", async () => { + renderPanel(); + + await waitFor(() => { + expect(screen.getByLabelText("Title")).toBeTruthy(); + }); + expect(screen.getByLabelText("Body")).toBeTruthy(); + }); + + it("uses a registered field override, exactly as the shared form does", async () => { + renderPanel({ + fieldOverrides: { + body: () =>
, + }, + }); + + await waitFor(() => { + expect(screen.getByTestId("custom-editor")).toBeTruthy(); + }); + // The override replaced the generated input, and nothing else moved. + expect(screen.getByLabelText("Title")).toBeTruthy(); + }); + + it("hands a registered layout the localized surface", async () => { + renderPanel({ + layout: ({ surface }) =>
{surface}
, + }); + + await waitFor(() => { + expect(screen.getByTestId("layout").textContent).toBe("translation"); + }); + }); +}); diff --git a/packages/vitnode/src/views/admin/views/content/actions/translations/translation-panel.tsx b/packages/vitnode/src/views/admin/views/content/actions/translations/translation-panel.tsx index d47fb00c5..5f7babfce 100644 --- a/packages/vitnode/src/views/admin/views/content/actions/translations/translation-panel.tsx +++ b/packages/vitnode/src/views/admin/views/content/actions/translations/translation-panel.tsx @@ -3,8 +3,10 @@ import { useTranslations } from "next-intl"; import React from "react"; import { toast } from "sonner"; +import type { ItemAutoFormComponentProps } from "@/components/form/auto-form"; import type { ContentFormSpec } from "@/content/admin/spec"; import type { ContentTranslationConflict } from "@/content/conflicts"; +import type { ContentFormLayout } from "@/lib/plugin"; import { DateFormat } from "@/components/date-format"; import { AutoForm, type AutoFormOnSubmit } from "@/components/form/auto-form"; @@ -20,7 +22,10 @@ import type { TranslationRow, } from "../translation-api.server"; +import { ContentFormProvider } from "../../form/context"; +import { ContentField } from "../../lib/field-component"; import { contentErrorKey } from "../../lib/mutation-feedback"; +import { loadContentOptionsAction } from "../mutation-api.server"; import { createContentTranslationAction, deleteContentTranslationAction, @@ -39,11 +44,18 @@ export interface TranslationPanelProps { contentTypeId: string; /** Enables the history and restore sections. */ editorial: boolean; + /** Per-field component overrides declared in `buildPlugin`. */ + fieldOverrides?: Record< + string, + (props: ItemAutoFormComponentProps) => React.ReactNode + >; /** `true` when this locale is the content type's default - never deletable. */ isDefaultLocale: boolean; itemId: number; /** Human name of the language, for headings and toasts. */ languageName: string; + /** Custom layout declared in `buildPlugin`. Presentation only. */ + layout?: ContentFormLayout; locale: string; /** Reloads the tab strip after a mutation, so its badges stay honest. */ onMutated: () => void; @@ -97,7 +109,9 @@ const conflictMessage = ( export const TranslationPanel = ({ contentTypeId, editorial, + fieldOverrides = {}, isDefaultLocale, + layout, itemId, languageName, locale, @@ -276,6 +290,8 @@ export const TranslationPanel = ({ if (!settled) return ; + const Layout = layout; + const publishedAt = typeof row?.publishedAt === "string" ? new Date(row.publishedAt) : null; @@ -324,8 +340,59 @@ export const TranslationPanel = ({ {canTranslate ? ( ({ id: fieldSpec.name }))} + fields={spec.fields.map(fieldSpec => ({ + id: fieldSpec.name, + + // MUST NOT be async, for the same reason the shared form's is not: + // `AutoForm` calls this to get an element, and an async function + // hands it a fresh Promise every render. + // eslint-disable-next-line @typescript-eslint/promise-function-async -- see above + component: props => { + const override = fieldOverrides[fieldSpec.name]; + if (override) return override(props); + + return ( + + await loadContentOptionsAction(contentTypeId, field, search) + } + spec={fieldSpec} + {...props} + /> + ); + }, + }))} formSchema={formSchema} + layout={ + Layout + ? renderedFields => ( + field.name), + fields: renderedFields, + mode: present ? "edit" : "create", + publication: { + enabled: publication, + publishedAt: row?.publishedAt, + status: row?.status, + }, + surface: "translation", + }} + > + + + ) + : undefined + } onSubmit={onSubmit} submitButtonProps={{ children: present ? t("save") : t("create"), diff --git a/packages/vitnode/src/views/admin/views/content/content-admin-view.tsx b/packages/vitnode/src/views/admin/views/content/content-admin-view.tsx index b8099c8c8..0ee66537f 100644 --- a/packages/vitnode/src/views/admin/views/content/content-admin-view.tsx +++ b/packages/vitnode/src/views/admin/views/content/content-admin-view.tsx @@ -3,22 +3,25 @@ import { notFound } from "next/navigation"; import React from "react"; import type { RegisteredFrontendContentType } from "@/content/admin/config"; +import type { ContentAdminRoute } from "@/content/admin/route"; import { I18nProvider } from "@/components/i18n-provider"; import { DataTableSkeleton } from "@/components/table/data-table"; import { HeaderContent } from "@/components/ui/header-content"; import { findFrontendContentType } from "@/content/admin/config"; import { contentI18nKeys, humanizeFieldName } from "@/content/admin/labels"; +import { resolveContentAdminRoute } from "@/content/admin/route"; import { buildContentColumnSpec, buildContentFormSpec, buildContentTranslationFormSpec, } from "@/content/admin/spec"; import { CONTENT_PERMISSIONS } from "@/content/const"; -import { pathToContentTypeId } from "@/content/registry"; +import { contentCreateHref } from "@/content/registry"; import { checkAdminPermissionApi } from "@/lib/api/get-session-admin-api"; import { CreateContentAction } from "./actions/create-action"; +import { ContentCreatePageView, ContentEditPageView } from "./page/page-views"; import { ContentTableView } from "./table/content-table-view"; export interface ContentAdminViewProps { @@ -27,17 +30,40 @@ export interface ContentAdminViewProps { } /** - * Resolves a registered content type from the catch-all slug, or `undefined`. - * Shared with `generateMetadata` and the breadcrumb slot. + * Resolves what the catch-all slug was asking for: which content type, and + * whether it wants the list, the create page or an edit page. + * + * Shared with `generateMetadata` and the breadcrumb slot, so all three agree + * about a URL rather than each parsing it their own way. */ -export const resolveContentType = async ( +export const resolveContentRoute = async ( params: ContentAdminViewProps["params"], -): Promise => { +): Promise< + (ContentAdminRoute & { entry: RegisteredFrontendContentType }) | undefined +> => { const { slug } = await params; + const route = resolveContentAdminRoute( + slug, + contentTypeId => findFrontendContentType(contentTypeId)?.definition, + ); + if (!route) return undefined; + + const entry = findFrontendContentType(route.contentTypeId); - return findFrontendContentType(pathToContentTypeId(slug)); + return entry ? { ...route, entry } : undefined; }; +/** + * Resolves a registered content type from the catch-all slug, or `undefined`. + * + * Kept as its own export because that is what `generateMetadata` and the + * breadcrumb slot in every app already call. + */ +export const resolveContentType = async ( + params: ContentAdminViewProps["params"], +): Promise => + (await resolveContentRoute(params))?.entry; + /** * Resolves the display strings for a content type. * @@ -70,13 +96,19 @@ export const getContentLabels = async ( }; }; -export const ContentAdminView = async ({ - params, +/** + * The generated list screen. + * + * Split out from `ContentAdminView` so the dispatcher below reads as the three + * screens it serves rather than as one function with a mode flag in it. + */ +const ContentListView = async ({ + entry, searchParams, -}: ContentAdminViewProps) => { - const entry = await resolveContentType(params); - if (!entry) notFound(); - +}: { + entry: RegisteredFrontendContentType; + searchParams: ContentAdminViewProps["searchParams"]; +}) => { const { definition, pluginId, registration } = entry; const [labels, canView, canCreate, query] = await Promise.all([ @@ -117,34 +149,67 @@ export const ContentAdminView = async ({ }); return ( - -
- - {canCreate && ( - [name, override.component], - ), - )} - singular={definition.admin.label.singular} - spec={formSpec} - /> - )} - - - } - > - + + {canCreate && ( + [name, override.component], + ), + )} + // Page mode makes this a link. The dialog is not mounted at all, + // so none of the form's chunks are downloaded until the page is. + href={ + definition.admin.create.mode === "page" + ? contentCreateHref(definition.id) + : undefined + } + singular={definition.admin.label.singular} + spec={formSpec} /> - -
+ )} + + + } + > + + +
+ ); +}; + +/** + * One route, three screens. + * + * `/admin/content/blog/post` is the list, `.../create` and `.../42/edit` are the + * generated form pages - and the last two exist only for a content type that + * opted into `admin.create.mode` / `admin.edit.mode` of `page`, so nothing about + * an existing content type moves. + */ +export const ContentAdminView = async ({ + params, + searchParams, +}: ContentAdminViewProps) => { + const route = await resolveContentRoute(params); + if (!route) notFound(); + + return ( + + {route.action === "list" ? ( + + ) : route.action === "create" ? ( + + ) : ( + + )} ); }; diff --git a/packages/vitnode/src/views/admin/views/content/form/context.tsx b/packages/vitnode/src/views/admin/views/content/form/context.tsx new file mode 100644 index 000000000..c880e5378 --- /dev/null +++ b/packages/vitnode/src/views/admin/views/content/form/context.tsx @@ -0,0 +1,108 @@ +// No "use client" here on purpose: this module is only reached from +// `content-form` / `translation-panel`, which are already client entries. +// Declaring it again would make it a nested client entry, and `next/dynamic` +// cannot resolve one from inside a published package. +import React from "react"; + +import type { ContentFormSurface } from "@/lib/plugin"; + +export interface ContentFormContextValue { + /** Names in this surface, in declaration order. */ + fieldNames: string[]; + /** Every field of this surface, already rendered and keyed by name. */ + fields: Record; + /** Records what a layout actually placed, so nothing goes missing silently. */ + markRendered?: (name: string) => void; + mode: "create" | "edit"; + /** + * Where the record sits in the lifecycle, read-only. + * + * Values rather than controls: `status` and `publishedAt` are not in the form + * schema, and the publish action on the list is the one thing that moves them. + */ + publication: { + enabled: boolean; + publishedAt?: unknown; + status?: unknown; + }; + surface: ContentFormSurface; +} + +const ContentFormContext = React.createContext( + null, +); + +/** + * The state a custom layout reads, from inside the one `AutoForm` instance. + * + * Context rather than props, and that is the whole architecture decision: a + * layout is a client component *referenced* from `config.tsx`, which is a server + * module, so anything handed to it as a prop crosses an RSC boundary. Rendered + * field elements and a `renderField(name)` callback cannot cross one - the first + * is not serialisable and the second is a server closure. Both are perfectly + * ordinary values on the client, where the provider and the layout both run. + */ +export const useContentForm = (): ContentFormContextValue => { + const value = React.useContext(ContentFormContext); + + if (!value) { + throw new Error( + "useContentForm must be used inside a Content Engine form layout.", + ); + } + + return value; +}; + +/** + * Same value, but `null` outside a layout. + * + * For a primitive that is legitimately optional - `ContentFormActions` is used + * by layouts only, but a field component may be reused in a plain dialog. + */ +export const useContentFormOptional = (): ContentFormContextValue | null => + React.useContext(ContentFormContext); + +export const ContentFormProvider = ({ + children, + value, +}: { + children: React.ReactNode; + value: Omit; +}) => { + const rendered = React.useRef>(new Set()); + + const markRendered = React.useCallback((name: string) => { + rendered.current.add(name); + }, []); + + const { fieldNames } = value; + + /** + * A layout that forgets a field silently drops it from the payload, which is + * the one failure mode this API has that the generated form does not. Saying + * so in development costs nothing and turns a data-loss bug into a console + * line naming the field. + * + * Runs after the children, which is what makes the set complete - and clears + * it afterwards, so a layout that *stops* placing a field is noticed on the + * very next render rather than remembered as still placing it. + */ + React.useEffect(() => { + const missing = fieldNames.filter(name => !rendered.current.has(name)); + rendered.current.clear(); + + if (process.env.NODE_ENV === "production" || missing.length === 0) return; + + // eslint-disable-next-line no-console -- development-only diagnostic + console.warn( + `[vitnode] Content form layout did not render: ${missing.join(", ")}. Add for each, or remove them from admin.form.fields.`, + ); + }); + + return ( + + {children} + + ); +}; diff --git a/packages/vitnode/src/views/admin/views/content/form/index.ts b/packages/vitnode/src/views/admin/views/content/form/index.ts new file mode 100644 index 000000000..00048ec58 --- /dev/null +++ b/packages/vitnode/src/views/admin/views/content/form/index.ts @@ -0,0 +1,26 @@ +/** + * The primitives a custom Content Engine form layout is built from. + * + * Published as `@vitnode/core/content/admin-form`. Everything here runs inside + * the one `AutoForm` instance the Content Engine created: one schema, one submit + * path, one set of errors. A layout decides *where* a field appears and nothing + * else - validation, defaults, mutations, version preconditions, structured + * errors, publication state, translations, permissions, toasts, cache + * invalidation, events, search and delivery all stay where they were. + */ +export { + type ContentFormContextValue, + useContentForm, + useContentFormOptional, +} from "./context"; +export { + ContentFormActions, + ContentFormField, + ContentFormLayoutGrid, + ContentFormMain, + ContentFormRemainingFields, + ContentFormSection, + ContentFormSidebar, + ContentFormStatus, +} from "./primitives"; +export { ContentFormPublication } from "./publication-status"; diff --git a/packages/vitnode/src/views/admin/views/content/form/layout.test.tsx b/packages/vitnode/src/views/admin/views/content/form/layout.test.tsx new file mode 100644 index 000000000..6731abd07 --- /dev/null +++ b/packages/vitnode/src/views/admin/views/content/form/layout.test.tsx @@ -0,0 +1,263 @@ +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import { z } from "zod"; + +import type { ContentFormLayoutProps } from "@/lib/plugin"; + +import { AutoForm } from "@/components/form/auto-form"; +import { AutoFormInput } from "@/components/form/fields/input"; + +import { ContentFormProvider } from "./context"; +import { + ContentFormActions, + ContentFormField, + ContentFormMain, + ContentFormRemainingFields, + ContentFormSidebar, + ContentFormStatus, +} from "./primitives"; + +vi.mock("next-intl", () => ({ + useLocale: () => "en", + useTranslations: () => (key: string) => key, +})); + +vi.mock("@/lib/navigation", () => ({ + Link: ({ children, href }: { children: React.ReactNode; href: string }) => ( + {children} + ), + usePathname: () => "/admin/content/blog/post", + useRouter: () => ({ push: vi.fn(), replace: vi.fn() }), +})); + +const schema = z.object({ + category: z.string().default("Reference"), + content: z.string().default("Body"), + title: z.string().min(1).default("Hello"), +}); + +/** + * The whole point of the layout API, exercised end to end: two named fields in + * two different places, one form, one submit. + */ +const Harness = ({ + fieldNames = ["title", "content", "category"], + layout, + mode = "edit", + onSubmit = vi.fn(), + publication = { enabled: false }, +}: { + fieldNames?: string[]; + layout: (props: ContentFormLayoutProps) => React.ReactNode; + mode?: "create" | "edit"; + onSubmit?: () => void; + publication?: { enabled: boolean; publishedAt?: unknown; status?: unknown }; +}) => ( + , + }, + { + id: "content", + component: props => , + }, + { + id: "category", + component: props => , + }, + ]} + formSchema={schema} + layout={fields => ( + + {layout({ + contentTypeId: "blog.post", + mode, + pluginId: "@vitnode/blog", + publication: publication.enabled, + singular: "Article", + surface: "shared", + })} + + )} + onSubmit={onSubmit} + /> +); + +describe("content form layouts", () => { + it("places named fields wherever the layout puts them", () => { + render( + ( + <> + +
+ + +
+
+ +
+ +
+
+ + )} + />, + ); + + expect(screen.getByTestId("main").contains(screen.getByLabelText("Title"))); + expect( + screen.getByTestId("main").contains(screen.getByLabelText("Content")), + ).toBe(true); + expect( + screen.getByTestId("sidebar").contains(screen.getByLabelText("Category")), + ).toBe(true); + expect( + screen.getByTestId("main").contains(screen.getByLabelText("Category")), + ).toBe(false); + }); + + it("renders nothing for a field this surface does not have", () => { + render( + ( + <> + + + + )} + />, + ); + + expect(screen.getByLabelText("Title")).toBeTruthy(); + expect(screen.queryByLabelText("Content")).toBeNull(); + }); + + it("submits every placed field through one form", async () => { + const onSubmit = vi.fn(); + render( + ( + <> + + + + + + )} + onSubmit={onSubmit} + />, + ); + + // The submit button stays disabled until react-hook-form has validated + // once, which is what typing does - same as the generated form. + fireEvent.change(screen.getByLabelText("Title"), { + target: { value: "Hello world" }, + }); + + await waitFor(() => { + expect( + screen.getByRole("button", { name: "Save" }).getAttribute("disabled"), + ).toBeNull(); + }); + fireEvent.click(screen.getByRole("button", { name: "Save" })); + + await waitFor(() => { + expect(onSubmit).toHaveBeenCalledWith( + { category: "Reference", content: "Body", title: "Hello world" }, + expect.anything(), + expect.anything(), + ); + }); + }); + + it("keeps a validation error attached to its own field", async () => { + const onSubmit = vi.fn(); + render( + ( + <> + + + + + + )} + onSubmit={onSubmit} + />, + ); + + fireEvent.change(screen.getByLabelText("Title"), { + target: { value: "" }, + }); + fireEvent.submit(screen.getByLabelText("Title").closest("form") as Element); + + await waitFor(() => { + expect(screen.getByLabelText("Title").getAttribute("aria-invalid")).toBe( + "true", + ); + }); + expect( + screen.getByLabelText("Content").getAttribute("aria-invalid"), + ).not.toBe("true"); + expect(onSubmit).not.toHaveBeenCalled(); + }); + + it("fills in the fields a layout did not name", () => { + render( + ( + <> + +
+ +
+ + )} + />, + ); + + expect( + screen.getByTestId("rest").contains(screen.getByLabelText("Content")), + ).toBe(true); + expect( + screen.getByTestId("rest").contains(screen.getByLabelText("Category")), + ).toBe(true); + expect( + screen.getByTestId("rest").contains(screen.getByLabelText("Title")), + ).toBe(false); + }); + + it("shows the publication line only when there is one to show", () => { + const { rerender } = render( + } mode="create" />, + ); + + expect(screen.queryByText("draft")).toBeNull(); + + rerender( + } + mode="edit" + publication={{ enabled: true, publishedAt: null, status: "draft" }} + />, + ); + + expect(screen.getByText("draft")).toBeTruthy(); + }); + + it("warns in development about a field the layout forgot", () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => undefined); + + render( } />); + + expect(warn).toHaveBeenCalledWith( + expect.stringContaining("content, category"), + ); + warn.mockRestore(); + }); +}); diff --git a/packages/vitnode/src/views/admin/views/content/form/primitives.tsx b/packages/vitnode/src/views/admin/views/content/form/primitives.tsx new file mode 100644 index 000000000..63479754c --- /dev/null +++ b/packages/vitnode/src/views/admin/views/content/form/primitives.tsx @@ -0,0 +1,200 @@ +// No "use client": reached only from a layout, which is reached only from +// `content-form` / `translation-panel` - both already client entries. +import { useTranslations } from "next-intl"; +import React from "react"; + +import { AutoFormSubmitButton } from "@/components/form/auto-form"; +import { Button } from "@/components/ui/button"; +import { Link } from "@/lib/navigation"; +import { cn } from "@/lib/utils"; + +import { useContentForm } from "./context"; +import { ContentFormPublication } from "./publication-status"; + +/** + * One field of the surrounding form, wherever the layout puts it. + * + * Renders **nothing** for a name this surface does not have, and that is load + * bearing rather than lenient: a localized content type splits its fields across + * a shared surface and a per-language one, so one layout naming `title` and + * `category` places each on the tab it belongs to without ever asking which + * table it lives on. + * + * A field override registered in `buildPlugin` is already baked into the element + * this renders - overrides and layouts compose, neither replaces the other. + */ +export const ContentFormField = ({ name }: { name: string }) => { + const { fields, markRendered } = useContentForm(); + + markRendered?.(name); + + return <>{fields[name] ?? null}; +}; + +/** + * Every field this surface has that the layout has not named itself. + * + * The escape hatch for a layout that wants to place two fields deliberately and + * let the rest fall where they may - and the reason a field added to the + * definition later does not silently vanish from a layout written today. + */ +export const ContentFormRemainingFields = ({ + exclude = [], +}: { + exclude?: readonly string[]; +}) => { + const { fieldNames, fields, markRendered } = useContentForm(); + const skip = new Set(exclude); + const remaining = fieldNames.filter(name => !skip.has(name)); + + for (const name of remaining) markRendered?.(name); + + return ( + <> + {remaining.map(name => ( + {fields[name]} + ))} + + ); +}; + +/** + * The read-only publication line, for a layout that wants it in its sidebar. + * + * Renders nothing for a content type without `publication`, and nothing while + * creating - there is no lifecycle to report before the record exists. + */ +export const ContentFormStatus = () => { + const { mode, publication } = useContentForm(); + + if (!publication.enabled || mode === "create") return null; + + return ( + + ); +}; + +/** + * The submit row. + * + * The button is the surrounding `AutoForm`'s own, so it disables while + * submitting and while the schema is unsatisfied exactly like the generated + * one - a layout cannot accidentally ship a button that allows a double write. + */ +export const ContentFormActions = ({ + cancelHref, + children, + className, + submitLabel, + ...props +}: React.ComponentProps<"div"> & { + /** Renders a Cancel link back to the list. */ + cancelHref?: string; + submitLabel?: React.ReactNode; +}) => { + const t = useTranslations("core.global"); + const tContent = useTranslations("core.content"); + const { mode } = useContentForm(); + + return ( +
+ {children} + {cancelHref ? ( + + ) : null} + + {submitLabel ?? + tContent(mode === "create" ? "create.submit" : "edit.submit")} + +
+ ); +}; + +/** + * The two-column editor shell: a wide main column and a sidebar. + * + * Single column below `lg`, which is the only responsive decision worth making + * here - a metadata sidebar next to a 40-character-wide editor is worse than no + * sidebar at all. + */ +export const ContentFormLayoutGrid = ({ + children, + className, + ...props +}: React.ComponentProps<"div">) => ( +
+ {children} +
+); + +export const ContentFormMain = ({ + children, + className, + ...props +}: React.ComponentProps<"div">) => ( +
+ {children} +
+); + +/** + * The metadata column. Sticky on large screens so the actions stay reachable + * while a long body scrolls, and static below that, where sticky would eat the + * viewport. + */ +export const ContentFormSidebar = ({ + children, + className, + ...props +}: React.ComponentProps<"div">) => ( +
+ {children} +
+); + +/** A titled card. Renders no heading element when it has no title. */ +export const ContentFormSection = ({ + children, + className, + desc, + title, + ...props +}: Omit, "title"> & { + desc?: React.ReactNode; + title?: React.ReactNode; +}) => ( +
+ {title ? ( +
+

{title}

+ {desc ? ( +

+ {desc} +

+ ) : null} +
+ ) : null} + +
{children}
+
+); diff --git a/packages/vitnode/src/views/admin/views/content/form/publication-status.tsx b/packages/vitnode/src/views/admin/views/content/form/publication-status.tsx new file mode 100644 index 000000000..9f68bafa0 --- /dev/null +++ b/packages/vitnode/src/views/admin/views/content/form/publication-status.tsx @@ -0,0 +1,44 @@ +// No "use client": reached only from `content-form` / a layout, both of which +// are already inside a client entry. +import { CircleCheckIcon, FileClockIcon } from "lucide-react"; +import { useTranslations } from "next-intl"; + +import { DateFormat } from "@/components/date-format"; +import { Badge } from "@/components/ui/badge"; + +/** + * A read-only line saying where the record is in the lifecycle. + * + * Read-only on purpose, and the rule is the same in a dialog and on a page: + * `status` and `publishedAt` are not in the form schema, and the one thing that + * moves them is the publish action on the list. Two competing mutation paths in + * one form is how a form ends up fighting its own optimistic state. + */ +export const ContentFormPublication = ({ + publishedAt, + status, +}: { + publishedAt: unknown; + status: unknown; +}) => { + const t = useTranslations("core.content.status"); + const published = status === "published"; + const date = typeof publishedAt === "string" ? new Date(publishedAt) : null; + + return ( +
+ {t("label")} + + {published ? ( + + ) : ( + + )} + {published ? t("published") : t("draft")} + + + {date ? : t("never_published")} + +
+ ); +}; diff --git a/packages/vitnode/src/views/admin/views/content/page/content-form-page.tsx b/packages/vitnode/src/views/admin/views/content/page/content-form-page.tsx new file mode 100644 index 000000000..06a99cf43 --- /dev/null +++ b/packages/vitnode/src/views/admin/views/content/page/content-form-page.tsx @@ -0,0 +1,93 @@ +"use client"; + +import React from "react"; + +import type { ItemAutoFormComponentProps } from "@/components/form/auto-form"; +import type { ContentFormSpec } from "@/content/admin/spec"; +import type { ContentFormLayout } from "@/lib/plugin"; + +import { useRouter } from "@/lib/navigation"; + +import { ContentForm } from "../actions/content-form"; +import { LocaleEditor } from "../actions/translations/locale-editor"; + +export interface ContentFormPageProps { + /** Where Cancel goes, and where a create lands when there is no edit page. */ + backHref: string; + /** + * Where a successful create goes: the new record's edit page when the content + * type has one, and the list when it does not. + * + * A template rather than a callback, because this component is rendered from a + * server one - `{id}` is substituted with the identifier the mutation returned. + */ + createdHrefTemplate?: string; + /** Existing values when editing; absent when creating. */ + data?: Record & { id: number }; + /** The content type's default locale. Set when `translationSpec` is. */ + defaultLocale?: string; + editorial?: boolean; + fieldOverrides?: Record< + string, + (props: ItemAutoFormComponentProps) => React.ReactNode + >; + layout?: ContentFormLayout; + permissionModule: string; + pluginId: string; + publication?: boolean; + singular: string; + spec: ContentFormSpec; + title?: string; + /** Localized-field form spec, or `null` when the content type is not localized. */ + translationSpec?: ContentFormSpec | null; +} + +/** + * The client half of a generated create/edit **page**. + * + * Renders exactly what the dialog renders - the same `ContentForm`, the same + * `LocaleEditor` for a localized content type - so page mode is a change of + * where the form is, not of what it does. Every mutation, precondition, toast + * and invalidation still comes from the Content Engine. + */ +export const ContentFormPage = ({ + backHref, + createdHrefTemplate, + data, + defaultLocale, + editorial = false, + permissionModule, + pluginId, + translationSpec = null, + ...props +}: ContentFormPageProps) => { + const { push } = useRouter(); + + const onCreated = (id: number) => { + push( + createdHrefTemplate + ? createdHrefTemplate.replace("{id}", String(id)) + : backHref, + ); + }; + + const form = { ...props, data, onCreated, presentation: "page" as const }; + + // A localized record is edited one language at a time, and the tab strip needs + // a record to exist first - so a create page writes the shared fields, then + // hands over to the edit page where the locales live. + if (translationSpec && data) { + return ( + + ); + } + + return ; +}; diff --git a/packages/vitnode/src/views/admin/views/content/page/page-views.test.tsx b/packages/vitnode/src/views/admin/views/content/page/page-views.test.tsx new file mode 100644 index 000000000..a468151d3 --- /dev/null +++ b/packages/vitnode/src/views/admin/views/content/page/page-views.test.tsx @@ -0,0 +1,309 @@ +import type { ReactElement } from "react"; + +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import type { RegisteredFrontendContentType } from "@/content/admin/config"; +import type { AnyContentTypeDefinition } from "@/content/types"; +import type { ContentFormLayout } from "@/lib/plugin"; + +import { defineContentType } from "@/content/define"; +import { field } from "@/content/fields"; + +vi.mock("server-only", () => ({})); + +vi.mock("next-intl/server", () => ({ + getTranslations: async () => { + await Promise.resolve(); + + // `getContentLabels` asks `t.has` before reading, so a plugin that + // translates nothing still gets readable labels. + return Object.assign((key: string) => key, { has: () => false }); + }, +})); + +vi.mock("@/lib/navigation", () => ({ + Link: () => null, + getPathname: () => "", + redirect: () => undefined, + usePathname: () => "", + useRouter: () => ({ push: () => undefined, refresh: () => undefined }), +})); + +const permissions = new Set(); +vi.mock("@/lib/api/get-session-admin-api", () => ({ + checkAdminPermissionApi: async ({ permission }: { permission: string }) => { + await Promise.resolve(); + + return permissions.has(permission); + }, +})); + +const fetched = { data: undefined as unknown, status: 200 }; +vi.mock("@/content/admin/fetch.server", () => ({ + contentApiFetch: async () => { + await Promise.resolve(); + + return fetched; + }, +})); + +const notFoundCalls = { count: 0 }; +vi.mock("next/navigation", () => ({ + notFound: () => { + notFoundCalls.count += 1; + throw new Error("NEXT_NOT_FOUND"); + }, +})); + +const { ContentCreatePageView, ContentEditPageView } = + await import("./page-views"); + +const pageArticle = defineContentType({ + id: "test.page-article", + tableName: "test_page_articles", + fields: { + title: field.text({ required: true, minLength: 1 }), + excerpt: field.textarea({ nullable: true }), + }, + admin: { + label: { plural: "Page Articles", singular: "Page Article" }, + create: { mode: "page" }, + edit: { mode: "page" }, + }, +}); + +const entryOf = ( + registration: Partial = {}, +): RegisteredFrontendContentType => ({ + definition: pageArticle, + pluginId: "@vitnode/test", + registration: { + definition: pageArticle, + ...registration, + }, +}); + +/** The `ContentFormPage` element the view returns, wherever it sits. */ +const formPage = ( + element: ReactElement, +): ReactElement> => { + const walk = ( + node: unknown, + ): null | ReactElement> => { + if (node === null || typeof node !== "object") return null; + if (Array.isArray(node)) { + for (const child of node) { + const found = walk(child); + if (found) return found; + } + + return null; + } + if (!("props" in node)) return null; + + const element = node as ReactElement>; + if ("spec" in element.props && "backHref" in element.props) return element; + + return walk(element.props.children); + }; + + const found = walk(element); + if (!found) throw new Error("No ContentFormPage in the rendered tree."); + + return found; +}; + +const render = async (view: Promise) => + formPage(await view).props; + +beforeEach(() => { + permissions.clear(); + notFoundCalls.count = 0; + fetched.status = 200; + fetched.data = { id: 7, labels: {}, title: "Hello" }; +}); + +describe("the generated create page", () => { + it("renders the generated form for someone who may create", async () => { + permissions.add("can_view"); + permissions.add("can_create"); + + const props = await render(ContentCreatePageView({ entry: entryOf() })); + + expect(props.backHref).toBe("/admin/content/test/page-article"); + expect(props.layout).toBeUndefined(); + expect((props.spec as { fields: { name: string }[] }).fields).toHaveLength( + 2, + ); + }); + + it("hands a new record over to its own edit page", async () => { + permissions.add("can_view"); + permissions.add("can_create"); + + const props = await render(ContentCreatePageView({ entry: entryOf() })); + + expect(props.createdHrefTemplate).toBe( + "/admin/content/test/page-article/{id}/edit", + ); + }); + + it("goes back to the list when there is no edit page to go to", async () => { + permissions.add("can_view"); + permissions.add("can_create"); + const dialogEdit = defineContentType({ + id: "test.page-article", + tableName: "test_page_articles", + fields: { title: field.text({ required: true }) }, + admin: { + label: { plural: "Page Articles", singular: "Page Article" }, + create: { mode: "page" }, + }, + }); + + const props = await render( + ContentCreatePageView({ + entry: { + ...entryOf(), + definition: dialogEdit, + }, + }), + ); + + expect(props.createdHrefTemplate).toBeUndefined(); + }); + + it("404s without can_create, however the URL was reached", async () => { + permissions.add("can_view"); + + await expect(ContentCreatePageView({ entry: entryOf() })).rejects.toThrow( + "NEXT_NOT_FOUND", + ); + expect(notFoundCalls.count).toBe(1); + }); + + it("404s without can_view", async () => { + permissions.add("can_create"); + + await expect(ContentCreatePageView({ entry: entryOf() })).rejects.toThrow( + "NEXT_NOT_FOUND", + ); + }); + + it("uses the registered layout when there is one", async () => { + permissions.add("can_view"); + permissions.add("can_create"); + const layout: ContentFormLayout = () => null; + + const props = await render( + ContentCreatePageView({ entry: entryOf({ forms: { layout } }) }), + ); + + expect(props.layout).toBe(layout); + }); + + it("carries the field overrides into the layout's fields", async () => { + permissions.add("can_view"); + permissions.add("can_create"); + const component = () => null; + + const props = await render( + ContentCreatePageView({ + entry: entryOf({ + fields: { title: { component } }, + forms: { layout: () => null }, + }), + }), + ); + + expect(props.fieldOverrides).toEqual({ title: component }); + }); +}); + +describe("the generated edit page", () => { + it("opens on the record the URL named", async () => { + permissions.add("can_view"); + permissions.add("can_edit"); + + const props = await render( + ContentEditPageView({ entry: entryOf(), itemId: 7 }), + ); + + expect(props.data).toMatchObject({ id: 7 }); + expect(props.title).toBe("Hello"); + }); + + it("404s for a record that is not there", async () => { + permissions.add("can_view"); + permissions.add("can_edit"); + fetched.status = 404; + fetched.data = undefined; + + await expect( + ContentEditPageView({ entry: entryOf(), itemId: 99 }), + ).rejects.toThrow("NEXT_NOT_FOUND"); + }); + + it("404s without can_edit on a content type with no translations", async () => { + permissions.add("can_view"); + permissions.add("can_translate"); + + await expect( + ContentEditPageView({ entry: entryOf(), itemId: 7 }), + ).rejects.toThrow("NEXT_NOT_FOUND"); + }); + + it("opens for a translator on a localized content type", async () => { + permissions.add("can_view"); + permissions.add("can_translate"); + + const localized = defineContentType({ + id: "test.page-localized", + tableName: "test_page_localized", + localization: { enabled: true, defaultLocale: "en" }, + fields: { + featured: field.boolean({ defaultValue: false }), + title: field.text({ localized: true, required: true }), + }, + admin: { + label: { plural: "Pages", singular: "Page" }, + create: { mode: "page" }, + edit: { mode: "page" }, + }, + }); + + const props = await render( + ContentEditPageView({ + entry: { + ...entryOf(), + definition: localized, + }, + itemId: 7, + }), + ); + + // The locale tabs are what a translator came for, so the translation spec + // has to reach the client half. + expect(props.translationSpec).toMatchObject({ + fields: [{ name: "title" }], + }); + }); + + it("uses the edit layout, not the create one", async () => { + permissions.add("can_view"); + permissions.add("can_edit"); + const create: ContentFormLayout = () => null; + const edit: ContentFormLayout = () => null; + + const props = await render( + ContentEditPageView({ + entry: entryOf({ + forms: { create: { layout: create }, edit: { layout: edit } }, + }), + itemId: 7, + }), + ); + + expect(props.layout).toBe(edit); + }); +}); diff --git a/packages/vitnode/src/views/admin/views/content/page/page-views.tsx b/packages/vitnode/src/views/admin/views/content/page/page-views.tsx new file mode 100644 index 000000000..a144bb628 --- /dev/null +++ b/packages/vitnode/src/views/admin/views/content/page/page-views.tsx @@ -0,0 +1,229 @@ +import { ArrowLeftIcon } from "lucide-react"; +import { getTranslations } from "next-intl/server"; +import { notFound } from "next/navigation"; +import { z } from "zod"; + +import type { RegisteredFrontendContentType } from "@/content/admin/config"; +import type { ContentFormSpec } from "@/content/admin/spec"; + +import { Button } from "@/components/ui/button"; +import { HeaderContent } from "@/components/ui/header-content"; +import { contentApiFetch } from "@/content/admin/fetch.server"; +import { + buildContentFormSpec, + buildContentTranslationFormSpec, +} from "@/content/admin/spec"; +import { CONTENT_PERMISSIONS } from "@/content/const"; +import { contentAdminHref, contentEditHrefTemplate } from "@/content/registry"; +import { checkAdminPermissionApi } from "@/lib/api/get-session-admin-api"; +import { Link } from "@/lib/navigation"; +import { resolveContentFormLayout } from "@/lib/plugin"; + +import { getContentLabels } from "../content-admin-view"; +import { ContentFormPage } from "./content-form-page"; + +/** The row shape a form opens on: the record, plus its reference labels. */ +const zodDetail = z + .object({ + id: z.number(), + labels: z.record(z.string(), z.string().nullable()), + }) + .loose(); + +const fieldOverridesOf = (entry: RegisteredFrontendContentType) => + Object.fromEntries( + Object.entries(entry.registration.fields ?? {}).map(([name, override]) => [ + name, + override.component, + ]), + ); + +/** + * The specs a form page needs, and the labels its headings use. + * + * Identical to what the list screen builds for its dialogs - page mode changes + * where the form is, not what the form is. + */ +const buildPageSpecs = async (entry: RegisteredFrontendContentType) => { + const { definition, pluginId } = entry; + const labels = await getContentLabels(entry); + const shared = { + definition, + labelEnum: labels.labelEnum, + labelField: labels.labelField, + pluginId, + }; + + return { + labels, + spec: buildContentFormSpec(shared), + translationSpec: buildContentTranslationFormSpec(shared), + } satisfies { + labels: Awaited>; + spec: ContentFormSpec; + translationSpec: ContentFormSpec | null; + }; +}; + +/** + * The generated **create page**. + * + * Reachable only with `can_view` *and* `can_create`, checked here rather than + * inferred from whether a button was rendered - a URL typed into the address bar + * has to answer the same way the button would have. The generated `POST` checks + * again, which is the check that actually stops the write. + */ +export const ContentCreatePageView = async ({ + entry, +}: { + entry: RegisteredFrontendContentType; +}) => { + const { definition, pluginId, registration } = entry; + + const [t, tPage, canView, canCreate] = await Promise.all([ + getTranslations("core.content.create"), + getTranslations("core.content.page"), + checkAdminPermissionApi({ + module: definition.permissionModule, + permission: CONTENT_PERMISSIONS.view, + plugin: pluginId, + }), + checkAdminPermissionApi({ + module: definition.permissionModule, + permission: CONTENT_PERMISSIONS.create, + plugin: pluginId, + }), + ]); + + if (!canView || !canCreate) notFound(); + + const { spec } = await buildPageSpecs(entry); + const singular = definition.admin.label.singular; + const backHref = contentAdminHref(definition.id); + + return ( +
+ + + + + +
+ ); +}; + +/** + * The generated **edit page**. + * + * Reachable with `can_edit`, or with `can_translate` on a localized content type + * - the same pair the edit dialog opens for, because a translator who may not + * touch a shared field still needs somewhere to write the Polish copy. + * + * A record that does not exist is a 404, and so is one whose content type the + * session may not view: the read goes through the generated API, which enforces + * `can_view` itself, so a missing permission and a missing row are the same + * answer from here. + */ +export const ContentEditPageView = async ({ + entry, + itemId, +}: { + entry: RegisteredFrontendContentType; + itemId: number; +}) => { + const { definition, pluginId, registration } = entry; + const localized = definition.localization.enabled; + + const [t, tPage, canView, canEdit, canTranslate] = await Promise.all([ + getTranslations("core.content.edit"), + getTranslations("core.content.page"), + checkAdminPermissionApi({ + module: definition.permissionModule, + permission: CONTENT_PERMISSIONS.view, + plugin: pluginId, + }), + checkAdminPermissionApi({ + module: definition.permissionModule, + permission: CONTENT_PERMISSIONS.edit, + plugin: pluginId, + }), + checkAdminPermissionApi({ + module: definition.permissionModule, + permission: CONTENT_PERMISSIONS.translate, + plugin: pluginId, + }), + ]); + + if (!canView) notFound(); + if (!canEdit && !(localized && canTranslate)) notFound(); + + const result = await contentApiFetch({ + definition, + method: "get", + path: `/${itemId}`, + pluginId, + schema: zodDetail, + }); + + if (result.status !== 200 || !result.data) notFound(); + + const { spec, translationSpec } = await buildPageSpecs(entry); + const backHref = contentAdminHref(definition.id); + const singular = definition.admin.label.singular; + const data = result.data as Record & { id: number }; + const titleField = definition.admin.titleField; + const title = + titleField && typeof data[titleField] === "string" + ? data[titleField] + : `#${data.id}`; + + return ( +
+ + + + + +
+ ); +}; diff --git a/packages/vitnode/src/views/admin/views/content/table/content-table-view.tsx b/packages/vitnode/src/views/admin/views/content/table/content-table-view.tsx index 4adc5f130..869f6f512 100644 --- a/packages/vitnode/src/views/admin/views/content/table/content-table-view.tsx +++ b/packages/vitnode/src/views/admin/views/content/table/content-table-view.tsx @@ -8,7 +8,7 @@ import type { ContentColumnSpec, ContentFormSpec } from "@/content/admin/spec"; import { zodPaginationPageInfo } from "@/api/lib/with-pagination"; import { DataTable } from "@/components/table/data-table"; import { contentApiFetch } from "@/content/admin/fetch.server"; -import { orderableColumns } from "@/content/registry"; +import { contentEditHref, orderableColumns } from "@/content/registry"; import type { ContentRowData } from "./cells"; @@ -246,6 +246,13 @@ export const ContentTableView = async ({ ([name, override]) => [name, override.component], ), )} + // Page mode turns the pencil into a link. Nothing of the form is + // mounted, so a 25-row table stays 25 anchors. + href={ + definition.admin.edit.mode === "page" + ? contentEditHref(definition.id, row.id) + : undefined + } permissionModule={definition.permissionModule} pluginId={pluginId} publication={definition.publication.enabled} From 293c9746ba08d817fdf3bca964cde78a0402649d Mon Sep 17 00:00:00 2001 From: aXenDeveloper Date: Mon, 10 Aug 2026 11:12:57 +0200 Subject: [PATCH 2/7] refactor(blog): migrate the blog onto the Content Engine The blog becomes a Content Engine consumer and stops being a CRUD implementation. Two content types replace six route files, three lib files, two admin screens, two create/edit dialogs, a search indexer and a hand-written slug uniqueness check. blog.category dialog create/edit, colour field override, colour cell override, relation target blog.post page create/edit, custom layout, AutoFormEditor, category relation, author, publication, editorial, search, delivery The ids stay `blog.post` and `blog.category`, the tables stay `blog_posts` and `blog_categories`, the fields stay `categoryId` and `authorId`, and the permission modules stay `posts` and `categories` - so every existing role, every foreign key and every stored permission still addresses the right thing. "Article" is what the AdminCP calls it, because that is what people call it. The migration is additive. Nothing is dropped and no record moves: - the text moves out of `core_languages_words` into the two generated translation tables, one row per language that genuinely had one, - every existing article becomes `published` with `publishedAt = createdAt` - they were all publicly readable before, and that is the one publication fact the old schema can prove. No revision history is invented, - a record with no default-locale translation gets one built from a value it already has, rather than being left unreadable, - only rows that were actually copied are deleted from the old storage. A PostgreSQL suite seeds a pre-migration install - two categories with different colours, three articles, an author, rich bodies, existing slugs and a Polish translation - runs the committed migration over it, and reads everything back through the engine's own services. Search and events stop being duplicated. The `search` block replaces `api/lib/search.ts`, which emitted a document per *enabled language* whether or not a translation existed. The blog's own event names survive as adapters over `content.blog.*`, so one mutation still means one announcement - `blog.post.deleted` loses `categoryId`, because the row is gone by then and inventing one would be a lie in an audit trail. The legacy admin URLs redirect. The two public read routes are removed: they read `core_languages_words`, which no longer holds the data, and the article's generated public API is a better answer at the same `/blog/` prefix. Co-Authored-By: Claude Opus 5 (1M context) --- .../0035_migrate_blog_to_content_engine.sql | 192 + apps/docs/migrations/meta/0035_snapshot.json | 4456 +++++++++++++++++ apps/docs/migrations/meta/_journal.json | 7 + .../(vitnode-blog)/blog/categories/page.tsx | 68 +- .../(vitnode-blog)/blog/posts/page.tsx | 77 +- .../@breadcrumb/blog/categories/page.tsx | 5 - .../(auth)/@breadcrumb/blog/posts/page.tsx | 5 - apps/docs/src/locales/@vitnode/blog/pl.json | 123 +- plugins/blog/package.json | 13 +- .../blog/src/api/lib/categories-language.ts | 69 - plugins/blog/src/api/lib/events.ts | 154 +- plugins/blog/src/api/lib/posts-language.ts | 129 - plugins/blog/src/api/lib/search.ts | 150 - .../src/api/modules/admin/admin.module.ts | 33 +- .../categories/categories.admin.module.ts | 12 - .../admin/categories/routes/create.route.ts | 68 - .../admin/categories/routes/delete.route.ts | 58 - .../admin/categories/routes/edit.route.ts | 84 - .../modules/admin/posts/posts.admin.module.ts | 12 - .../admin/posts/routes/create.route.ts | 133 - .../admin/posts/routes/delete.route.ts | 51 - .../modules/admin/posts/routes/edit.route.ts | 139 - .../modules/categories/categories.module.ts | 11 - .../modules/categories/routes/get.route.ts | 137 - .../src/api/modules/posts/posts.module.ts | 11 - .../src/api/modules/posts/routes/get.route.ts | 150 - plugins/blog/src/config.api.ts | 57 +- plugins/blog/src/config.test-d.ts | 51 + plugins/blog/src/config.tsx | 57 +- plugins/blog/src/content/category.ts | 72 + .../blog/src/content/content-types.test.ts | 115 + plugins/blog/src/content/post.ts | 176 + plugins/blog/src/database/categories.ts | 20 +- plugins/blog/src/database/harness.ts | 312 ++ plugins/blog/src/database/index.ts | 3 - .../src/database/migration-postgres.test.ts | 368 ++ plugins/blog/src/database/posts.ts | 29 +- plugins/blog/src/database/relations.ts | 18 - plugins/blog/src/locales/en.json | 123 +- .../src/routes/admin/blog/categories/page.tsx | 67 +- .../blog/src/routes/admin/blog/posts/page.tsx | 78 +- .../breadcrumb/admin/blog/categories/page.tsx | 5 - .../breadcrumb/admin/blog/posts/page.tsx | 5 - .../src/views/admin/article/editor-field.tsx | 45 + .../views/admin/article/form-layout.test.tsx | 120 + .../src/views/admin/article/form-layout.tsx | 67 + .../admin/categories/actions/actions.tsx | 46 - .../actions/create-edit/create-edit.tsx | 90 - .../create-edit/mutation-api.server.ts | 60 - .../table/actions/delete/delete-action.tsx | 71 - .../actions/delete/mutation-api.server.ts | 29 - .../categories/table/actions/edit-action.tsx | 81 - .../table/categories-admin-view.tsx | 102 - .../views/admin/category/color-cell.test.tsx | 42 + .../src/views/admin/category/color-cell.tsx | 40 + .../src/views/admin/category/color-field.tsx | 27 + .../src/views/admin/posts/actions/actions.tsx | 46 - .../posts/actions/create-edit/create-edit.tsx | 174 - .../actions/create-edit/multi-lang-fields.tsx | 138 - .../create-edit/mutation-api.server.ts | 60 - .../table/actions/delete/delete-action.tsx | 71 - .../actions/delete/mutation-api.server.ts | 29 - .../admin/posts/table/actions/edit-action.tsx | 81 - .../admin/posts/table/posts-admin-view.tsx | 102 - plugins/blog/tsconfig.json | 14 +- plugins/blog/vitest.config.ts | 24 + 66 files changed, 6534 insertions(+), 2928 deletions(-) create mode 100644 apps/docs/migrations/0035_migrate_blog_to_content_engine.sql create mode 100644 apps/docs/migrations/meta/0035_snapshot.json delete mode 100644 apps/docs/src/app/[locale]/admin/(auth)/@breadcrumb/blog/categories/page.tsx delete mode 100644 apps/docs/src/app/[locale]/admin/(auth)/@breadcrumb/blog/posts/page.tsx delete mode 100644 plugins/blog/src/api/lib/categories-language.ts delete mode 100644 plugins/blog/src/api/lib/posts-language.ts delete mode 100644 plugins/blog/src/api/lib/search.ts delete mode 100644 plugins/blog/src/api/modules/admin/categories/categories.admin.module.ts delete mode 100644 plugins/blog/src/api/modules/admin/categories/routes/create.route.ts delete mode 100644 plugins/blog/src/api/modules/admin/categories/routes/delete.route.ts delete mode 100644 plugins/blog/src/api/modules/admin/categories/routes/edit.route.ts delete mode 100644 plugins/blog/src/api/modules/admin/posts/posts.admin.module.ts delete mode 100644 plugins/blog/src/api/modules/admin/posts/routes/create.route.ts delete mode 100644 plugins/blog/src/api/modules/admin/posts/routes/delete.route.ts delete mode 100644 plugins/blog/src/api/modules/admin/posts/routes/edit.route.ts delete mode 100644 plugins/blog/src/api/modules/categories/categories.module.ts delete mode 100644 plugins/blog/src/api/modules/categories/routes/get.route.ts delete mode 100644 plugins/blog/src/api/modules/posts/posts.module.ts delete mode 100644 plugins/blog/src/api/modules/posts/routes/get.route.ts create mode 100644 plugins/blog/src/config.test-d.ts create mode 100644 plugins/blog/src/content/category.ts create mode 100644 plugins/blog/src/content/content-types.test.ts create mode 100644 plugins/blog/src/content/post.ts create mode 100644 plugins/blog/src/database/harness.ts create mode 100644 plugins/blog/src/database/migration-postgres.test.ts delete mode 100644 plugins/blog/src/database/relations.ts delete mode 100644 plugins/blog/src/routes/breadcrumb/admin/blog/categories/page.tsx delete mode 100644 plugins/blog/src/routes/breadcrumb/admin/blog/posts/page.tsx create mode 100644 plugins/blog/src/views/admin/article/editor-field.tsx create mode 100644 plugins/blog/src/views/admin/article/form-layout.test.tsx create mode 100644 plugins/blog/src/views/admin/article/form-layout.tsx delete mode 100644 plugins/blog/src/views/admin/categories/actions/actions.tsx delete mode 100644 plugins/blog/src/views/admin/categories/actions/create-edit/create-edit.tsx delete mode 100644 plugins/blog/src/views/admin/categories/actions/create-edit/mutation-api.server.ts delete mode 100644 plugins/blog/src/views/admin/categories/table/actions/delete/delete-action.tsx delete mode 100644 plugins/blog/src/views/admin/categories/table/actions/delete/mutation-api.server.ts delete mode 100644 plugins/blog/src/views/admin/categories/table/actions/edit-action.tsx delete mode 100644 plugins/blog/src/views/admin/categories/table/categories-admin-view.tsx create mode 100644 plugins/blog/src/views/admin/category/color-cell.test.tsx create mode 100644 plugins/blog/src/views/admin/category/color-cell.tsx create mode 100644 plugins/blog/src/views/admin/category/color-field.tsx delete mode 100644 plugins/blog/src/views/admin/posts/actions/actions.tsx delete mode 100644 plugins/blog/src/views/admin/posts/actions/create-edit/create-edit.tsx delete mode 100644 plugins/blog/src/views/admin/posts/actions/create-edit/multi-lang-fields.tsx delete mode 100644 plugins/blog/src/views/admin/posts/actions/create-edit/mutation-api.server.ts delete mode 100644 plugins/blog/src/views/admin/posts/table/actions/delete/delete-action.tsx delete mode 100644 plugins/blog/src/views/admin/posts/table/actions/delete/mutation-api.server.ts delete mode 100644 plugins/blog/src/views/admin/posts/table/actions/edit-action.tsx delete mode 100644 plugins/blog/src/views/admin/posts/table/posts-admin-view.tsx create mode 100644 plugins/blog/vitest.config.ts diff --git a/apps/docs/migrations/0035_migrate_blog_to_content_engine.sql b/apps/docs/migrations/0035_migrate_blog_to_content_engine.sql new file mode 100644 index 000000000..cd66b71e3 --- /dev/null +++ b/apps/docs/migrations/0035_migrate_blog_to_content_engine.sql @@ -0,0 +1,192 @@ +CREATE TABLE "blog_categories_translations" ( + "itemId" integer NOT NULL, + "languageId" integer NOT NULL, + "version" integer DEFAULT 1 NOT NULL, + "createdAt" timestamp DEFAULT now() NOT NULL, + "updatedAt" timestamp DEFAULT now() NOT NULL, + "name" varchar(100) NOT NULL, + CONSTRAINT "blog_categories_translations_item_id_language_id_pk" PRIMARY KEY("itemId","languageId") +); +--> statement-breakpoint +ALTER TABLE "blog_categories_translations" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint +CREATE TABLE "blog_posts_translations" ( + "itemId" integer NOT NULL, + "languageId" integer NOT NULL, + "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(255) NOT NULL, + "friendlyUrl" varchar(255) NOT NULL, + "content" text NOT NULL, + CONSTRAINT "blog_posts_translations_item_id_language_id_pk" PRIMARY KEY("itemId","languageId") +); +--> statement-breakpoint +ALTER TABLE "blog_posts_translations" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint +ALTER TABLE "blog_posts" DROP CONSTRAINT "blog_posts_categoryId_blog_categories_id_fk"; +--> statement-breakpoint +ALTER TABLE "blog_categories" ALTER COLUMN "updatedAt" SET DEFAULT now();--> statement-breakpoint +ALTER TABLE "blog_posts" ALTER COLUMN "updatedAt" SET DEFAULT now();--> statement-breakpoint +ALTER TABLE "blog_posts" ADD COLUMN "publishedAt" timestamp;--> statement-breakpoint +ALTER TABLE "blog_posts" ADD COLUMN "status" varchar(32) DEFAULT 'draft' NOT NULL;--> statement-breakpoint +ALTER TABLE "blog_posts" ADD COLUMN "version" integer DEFAULT 1 NOT NULL;--> statement-breakpoint +ALTER TABLE "blog_categories_translations" ADD CONSTRAINT "blog_categories_translations_itemId_blog_categories_id_fk" FOREIGN KEY ("itemId") REFERENCES "public"."blog_categories"("id") ON DELETE cascade ON UPDATE cascade;--> statement-breakpoint +ALTER TABLE "blog_categories_translations" ADD CONSTRAINT "blog_categories_translations_languageId_core_languages_id_fk" FOREIGN KEY ("languageId") REFERENCES "public"."core_languages"("id") ON DELETE restrict ON UPDATE cascade;--> statement-breakpoint +ALTER TABLE "blog_posts_translations" ADD CONSTRAINT "blog_posts_translations_itemId_blog_posts_id_fk" FOREIGN KEY ("itemId") REFERENCES "public"."blog_posts"("id") ON DELETE cascade ON UPDATE cascade;--> statement-breakpoint +ALTER TABLE "blog_posts_translations" ADD CONSTRAINT "blog_posts_translations_languageId_core_languages_id_fk" FOREIGN KEY ("languageId") REFERENCES "public"."core_languages"("id") ON DELETE restrict ON UPDATE cascade;--> statement-breakpoint +CREATE INDEX "blog_categories_translations_language_id_idx" ON "blog_categories_translations" USING btree ("languageId");--> statement-breakpoint +CREATE INDEX "blog_posts_translations_language_id_status_idx" ON "blog_posts_translations" USING btree ("languageId","status");--> statement-breakpoint +CREATE UNIQUE INDEX "blog_posts_translations_language_id_friendly_url_key" ON "blog_posts_translations" USING btree ("languageId","friendlyUrl");--> statement-breakpoint +ALTER TABLE "blog_posts" ADD CONSTRAINT "blog_posts_categoryId_blog_categories_id_fk" FOREIGN KEY ("categoryId") REFERENCES "public"."blog_categories"("id") ON DELETE restrict ON UPDATE cascade;--> statement-breakpoint +CREATE INDEX "blog_categories_created_at_idx" ON "blog_categories" USING btree ("createdAt");--> statement-breakpoint +CREATE INDEX "blog_categories_updated_at_idx" ON "blog_categories" USING btree ("updatedAt");--> statement-breakpoint +CREATE INDEX "blog_posts_status_created_at_idx" ON "blog_posts" USING btree ("status","createdAt");--> statement-breakpoint +CREATE INDEX "blog_posts_category_id_idx" ON "blog_posts" USING btree ("categoryId");--> statement-breakpoint +CREATE INDEX "blog_posts_author_id_idx" ON "blog_posts" USING btree ("authorId");--> statement-breakpoint +CREATE INDEX "blog_posts_created_at_idx" ON "blog_posts" USING btree ("createdAt");--> statement-breakpoint +CREATE INDEX "blog_posts_updated_at_idx" ON "blog_posts" USING btree ("updatedAt");--> statement-breakpoint +CREATE INDEX "blog_posts_status_published_at_idx" ON "blog_posts" USING btree ("status","publishedAt");--> statement-breakpoint +-- +-- Data migration: the blog's own storage -> the Content Engine's. +-- +-- Nothing above this line dropped a table or a column, and nothing below moves a +-- record: ids, categories, authors and timestamps stay exactly where they are. +-- What moves is the *text*, out of `core_languages_words` and into the two +-- translation tables the engine reads. +-- + +-- 1. Publication. Every article that exists today is publicly readable - the old +-- public route returned every row and every search document was written +-- `isPublic: true` - so they all migrate as published. `publishedAt` is +-- `createdAt`, which is the only publication date the old schema can prove; +-- no revision history is fabricated, so `version` stays at its default of 1. +UPDATE "blog_posts" +SET "status" = 'published', "publishedAt" = "createdAt" +WHERE "status" = 'draft' AND "publishedAt" IS NULL;--> statement-breakpoint + +-- 2. Category names. One row per (category, language) that actually had a title, +-- so a language nobody translated into stays untranslated rather than being +-- invented. A stored empty title would break `name`'s minimum length, so it +-- falls back to a unique placeholder an editor can see and fix. +INSERT INTO "blog_categories_translations" + ("itemId", "languageId", "version", "createdAt", "updatedAt", "name") +SELECT + c."id", + l."id", + 1, + c."createdAt", + c."updatedAt", + LEFT(COALESCE(NULLIF(w."value", ''), 'category-' || c."id"), 100) +FROM "core_languages_words" w +JOIN "blog_categories" c ON c."id" = w."itemId" +JOIN "core_languages" l ON l."code" = w."languageCode" +WHERE w."pluginCode" = '@vitnode/blog' + AND w."tableName" = 'blog_categories' + AND w."variable" = 'title' +ON CONFLICT DO NOTHING;--> statement-breakpoint + +-- 3. Article text. The three variables the plugin kept side by side become one +-- row, for each (article, language) pair that had any of them. A missing +-- friendly URL falls back to something unique rather than to an empty string, +-- which the new UNIQUE (languageId, friendlyUrl) index would reject on the +-- second article. +INSERT INTO "blog_posts_translations" ( + "itemId", "languageId", "version", "createdAt", "updatedAt", + "publishedAt", "status", "title", "friendlyUrl", "content" +) +SELECT + p."id", + l."id", + 1, + p."createdAt", + p."updatedAt", + p."createdAt", + 'published', + LEFT(COALESCE(w."title", ''), 255), + LEFT( + COALESCE(NULLIF(w."friendlyUrl", ''), 'post-' || p."id" || '-' || l."code"), + 255 + ), + COALESCE(w."content", '') +FROM ( + SELECT + "itemId", + "languageCode", + MAX("value") FILTER (WHERE "variable" = 'title') AS "title", + MAX("value") FILTER (WHERE "variable" = 'content') AS "content", + MAX("value") FILTER (WHERE "variable" = 'friendlyUrl') AS "friendlyUrl" + FROM "core_languages_words" + WHERE "pluginCode" = '@vitnode/blog' + AND "tableName" = 'blog_posts' + AND "variable" IN ('title', 'content', 'friendlyUrl') + GROUP BY "itemId", "languageCode" +) w +JOIN "blog_posts" p ON p."id" = w."itemId" +JOIN "core_languages" l ON l."code" = w."languageCode" +ON CONFLICT DO NOTHING;--> statement-breakpoint + +-- 4. The default locale. A localized content type refuses to leave a record +-- without a translation in its `defaultLocale`, so a record that was only ever +-- written in another language gets an English row built from the name it +-- already has in whichever language it does have. Nothing is invented: the +-- value is one the record genuinely carries. +INSERT INTO "blog_categories_translations" + ("itemId", "languageId", "version", "createdAt", "updatedAt", "name") +SELECT + c."id", + l."id", + 1, + c."createdAt", + c."updatedAt", + LEFT( + COALESCE( + ( + SELECT NULLIF(t."name", '') + FROM "blog_categories_translations" t + WHERE t."itemId" = c."id" + ORDER BY t."languageId" + LIMIT 1 + ), + 'category-' || c."id" + ), + 100 + ) +FROM "blog_categories" c +JOIN "core_languages" l ON l."code" = 'en' +ON CONFLICT DO NOTHING;--> statement-breakpoint + +INSERT INTO "blog_posts_translations" ( + "itemId", "languageId", "version", "createdAt", "updatedAt", + "publishedAt", "status", "title", "friendlyUrl", "content" +) +SELECT + p."id", + l."id", + 1, + p."createdAt", + p."updatedAt", + p."createdAt", + 'published', + LEFT(COALESCE(NULLIF(source."title", ''), 'post-' || p."id"), 255), + LEFT('post-' || p."id" || '-en', 255), + COALESCE(source."content", '') +FROM "blog_posts" p +JOIN "core_languages" l ON l."code" = 'en' +LEFT JOIN LATERAL ( + SELECT t."title", t."content" + FROM "blog_posts_translations" t + WHERE t."itemId" = p."id" + ORDER BY t."languageId" + LIMIT 1 +) source ON TRUE +ON CONFLICT DO NOTHING;--> statement-breakpoint + +-- 5. The old storage, now that everything in it has a new home. Scoped to rows +-- that were genuinely migrated: a word in a language the install does not have +-- could not be copied, so it is left where it is rather than deleted. +DELETE FROM "core_languages_words" w +USING "core_languages" l +WHERE w."pluginCode" = '@vitnode/blog' + AND w."tableName" IN ('blog_categories', 'blog_posts') + AND l."code" = w."languageCode"; diff --git a/apps/docs/migrations/meta/0035_snapshot.json b/apps/docs/migrations/meta/0035_snapshot.json new file mode 100644 index 000000000..4b679e430 --- /dev/null +++ b/apps/docs/migrations/meta/0035_snapshot.json @@ -0,0 +1,4456 @@ +{ + "id": "42b7098a-c42b-4c70-8673-087b8ff56ce4", + "prevId": "0f660415-9144-44ed-9d96-78cd76711ebf", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.core_admin_permissions": { + "name": "core_admin_permissions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "roleId": { + "name": "roleId", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "userId": { + "name": "userId", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "protected": { + "name": "protected", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "unrestricted": { + "name": "unrestricted", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "permissions": { + "name": "permissions", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + } + }, + "indexes": { + "core_admin_permissions_role_id_idx": { + "name": "core_admin_permissions_role_id_idx", + "columns": [ + { + "expression": "roleId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "core_admin_permissions_user_id_idx": { + "name": "core_admin_permissions_user_id_idx", + "columns": [ + { + "expression": "userId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "core_admin_permissions_roleId_core_roles_id_fk": { + "name": "core_admin_permissions_roleId_core_roles_id_fk", + "tableFrom": "core_admin_permissions", + "tableTo": "core_roles", + "columnsFrom": [ + "roleId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "core_admin_permissions_userId_core_users_id_fk": { + "name": "core_admin_permissions_userId_core_users_id_fk", + "tableFrom": "core_admin_permissions", + "tableTo": "core_users", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.core_admin_sessions": { + "name": "core_admin_sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "token": { + "name": "token", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "userId": { + "name": "userId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "lastSeen": { + "name": "lastSeen", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expiresAt": { + "name": "expiresAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "deviceId": { + "name": "deviceId", + "type": "integer", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "core_admin_sessions_token_idx": { + "name": "core_admin_sessions_token_idx", + "columns": [ + { + "expression": "token", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "core_admin_sessions_user_id_idx": { + "name": "core_admin_sessions_user_id_idx", + "columns": [ + { + "expression": "userId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "core_admin_sessions_userId_core_users_id_fk": { + "name": "core_admin_sessions_userId_core_users_id_fk", + "tableFrom": "core_admin_sessions", + "tableTo": "core_users", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "core_admin_sessions_deviceId_core_sessions_known_devices_id_fk": { + "name": "core_admin_sessions_deviceId_core_sessions_known_devices_id_fk", + "tableFrom": "core_admin_sessions", + "tableTo": "core_sessions_known_devices", + "columnsFrom": [ + "deviceId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "core_admin_sessions_token_unique": { + "name": "core_admin_sessions_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.core_content_revisions": { + "name": "core_content_revisions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "pluginId": { + "name": "pluginId", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "contentTypeId": { + "name": "contentTypeId", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "itemId": { + "name": "itemId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "languageId": { + "name": "languageId", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "operation": { + "name": "operation", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true + }, + "snapshot": { + "name": "snapshot", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "changedFields": { + "name": "changedFields", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "actorType": { + "name": "actorType", + "type": "varchar(16)", + "primaryKey": false, + "notNull": true, + "default": "'system'" + }, + "actorUserId": { + "name": "actorUserId", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "restoredFromRevisionId": { + "name": "restoredFromRevisionId", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "core_content_revisions_item_version_unique": { + "name": "core_content_revisions_item_version_unique", + "columns": [ + { + "expression": "contentTypeId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "itemId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "version", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"languageId\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "core_content_revisions_translation_version_unique": { + "name": "core_content_revisions_translation_version_unique", + "columns": [ + { + "expression": "contentTypeId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "itemId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "languageId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "version", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"languageId\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "core_content_revisions_language_idx": { + "name": "core_content_revisions_language_idx", + "columns": [ + { + "expression": "contentTypeId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "itemId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "languageId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "version", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "core_content_revisions_plugin_id_idx": { + "name": "core_content_revisions_plugin_id_idx", + "columns": [ + { + "expression": "pluginId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "core_content_revisions_actor_user_id_idx": { + "name": "core_content_revisions_actor_user_id_idx", + "columns": [ + { + "expression": "actorUserId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "core_content_revisions_actorUserId_core_users_id_fk": { + "name": "core_content_revisions_actorUserId_core_users_id_fk", + "tableFrom": "core_content_revisions", + "tableTo": "core_users", + "columnsFrom": [ + "actorUserId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.core_content_schedules": { + "name": "core_content_schedules", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "pluginId": { + "name": "pluginId", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "contentTypeId": { + "name": "contentTypeId", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "itemId": { + "name": "itemId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "action": { + "name": "action", + "type": "varchar(16)", + "primaryKey": false, + "notNull": true + }, + "scheduledFor": { + "name": "scheduledFor", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "generation": { + "name": "generation", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "status": { + "name": "status", + "type": "varchar(16)", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "createdBy": { + "name": "createdBy", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completedAt": { + "name": "completedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "lastError": { + "name": "lastError", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "effectsError": { + "name": "effectsError", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "core_content_schedules_active_unique": { + "name": "core_content_schedules_active_unique", + "columns": [ + { + "expression": "contentTypeId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "itemId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "action", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "status = 'pending'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "core_content_schedules_due_idx": { + "name": "core_content_schedules_due_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "scheduledFor", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "core_content_schedules_item_idx": { + "name": "core_content_schedules_item_idx", + "columns": [ + { + "expression": "contentTypeId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "itemId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "core_content_schedules_plugin_id_idx": { + "name": "core_content_schedules_plugin_id_idx", + "columns": [ + { + "expression": "pluginId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "core_content_schedules_created_by_idx": { + "name": "core_content_schedules_created_by_idx", + "columns": [ + { + "expression": "createdBy", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "core_content_schedules_createdBy_core_users_id_fk": { + "name": "core_content_schedules_createdBy_core_users_id_fk", + "tableFrom": "core_content_schedules", + "tableTo": "core_users", + "columnsFrom": [ + "createdBy" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.core_content_slug_history": { + "name": "core_content_slug_history", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "pluginId": { + "name": "pluginId", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "contentTypeId": { + "name": "contentTypeId", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "itemId": { + "name": "itemId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "languageId": { + "name": "languageId", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "slug": { + "name": "slug", + "type": "varchar(160)", + "primaryKey": false, + "notNull": true + }, + "path": { + "name": "path", + "type": "varchar(512)", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "retiredAt": { + "name": "retiredAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "core_content_slug_history_shared_unique": { + "name": "core_content_slug_history_shared_unique", + "columns": [ + { + "expression": "contentTypeId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"languageId\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "core_content_slug_history_locale_unique": { + "name": "core_content_slug_history_locale_unique", + "columns": [ + { + "expression": "contentTypeId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "languageId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"languageId\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "core_content_slug_history_item_idx": { + "name": "core_content_slug_history_item_idx", + "columns": [ + { + "expression": "contentTypeId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "itemId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "languageId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "core_content_slug_history_plugin_id_idx": { + "name": "core_content_slug_history_plugin_id_idx", + "columns": [ + { + "expression": "pluginId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.core_cron": { + "name": "core_cron", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "lastRun": { + "name": "lastRun", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "pluginId": { + "name": "pluginId", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "module": { + "name": "module", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "nextRun": { + "name": "nextRun", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "schedule": { + "name": "schedule", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.core_admin_dashboard": { + "name": "core_admin_dashboard", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "userId": { + "name": "userId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "widgets": { + "name": "widgets", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "core_admin_dashboard_user_id_idx": { + "name": "core_admin_dashboard_user_id_idx", + "columns": [ + { + "expression": "userId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "core_admin_dashboard_userId_core_users_id_fk": { + "name": "core_admin_dashboard_userId_core_users_id_fk", + "tableFrom": "core_admin_dashboard", + "tableTo": "core_users", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "core_admin_dashboard_userId_unique": { + "name": "core_admin_dashboard_userId_unique", + "nullsNotDistinct": false, + "columns": [ + "userId" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.core_files": { + "name": "core_files", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "varchar(512)", + "primaryKey": false, + "notNull": true + }, + "folder": { + "name": "folder", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "mimeType": { + "name": "mimeType", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "userId": { + "name": "userId", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "pluginId": { + "name": "pluginId", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "core_files_user_id_idx": { + "name": "core_files_user_id_idx", + "columns": [ + { + "expression": "userId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "core_files_userId_core_users_id_fk": { + "name": "core_files_userId_core_users_id_fk", + "tableFrom": "core_files", + "tableTo": "core_users", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "core_files_key_unique": { + "name": "core_files_key_unique", + "nullsNotDistinct": false, + "columns": [ + "key" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.core_languages": { + "name": "core_languages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "code": { + "name": "code", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "timezone": { + "name": "timezone", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "default": "'UTC'" + }, + "protected": { + "name": "protected", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "default": { + "name": "default", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time24": { + "name": "time24", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": { + "core_languages_code_idx": { + "name": "core_languages_code_idx", + "columns": [ + { + "expression": "code", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "core_languages_name_idx": { + "name": "core_languages_name_idx", + "columns": [ + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "core_languages_code_unique": { + "name": "core_languages_code_unique", + "nullsNotDistinct": false, + "columns": [ + "code" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.core_languages_words": { + "name": "core_languages_words", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "languageCode": { + "name": "languageCode", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "pluginCode": { + "name": "pluginCode", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "itemId": { + "name": "itemId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tableName": { + "name": "tableName", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "variable": { + "name": "variable", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "core_languages_words_lang_code_idx": { + "name": "core_languages_words_lang_code_idx", + "columns": [ + { + "expression": "languageCode", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "core_languages_words_languageCode_core_languages_code_fk": { + "name": "core_languages_words_languageCode_core_languages_code_fk", + "tableFrom": "core_languages_words", + "tableTo": "core_languages", + "columnsFrom": [ + "languageCode" + ], + "columnsTo": [ + "code" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.core_logs": { + "name": "core_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "pluginId": { + "name": "pluginId", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "varchar(10)", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "ipAddress": { + "name": "ipAddress", + "type": "varchar(45)", + "primaryKey": false, + "notNull": true + }, + "method": { + "name": "method", + "type": "varchar(10)", + "primaryKey": false, + "notNull": true, + "default": "'GET'" + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'localhost'" + }, + "userAgent": { + "name": "userAgent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "statusCode": { + "name": "statusCode", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 500 + }, + "userId": { + "name": "userId", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "test123": { + "name": "test123", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": {}, + "foreignKeys": { + "core_logs_userId_core_users_id_fk": { + "name": "core_logs_userId_core_users_id_fk", + "tableFrom": "core_logs", + "tableTo": "core_users", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.core_moderators_permissions": { + "name": "core_moderators_permissions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "roleId": { + "name": "roleId", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "userId": { + "name": "userId", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "protected": { + "name": "protected", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "unrestricted": { + "name": "unrestricted", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "permissions": { + "name": "permissions", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + } + }, + "indexes": { + "core_moderators_permissions_role_id_idx": { + "name": "core_moderators_permissions_role_id_idx", + "columns": [ + { + "expression": "roleId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "core_moderators_permissions_user_id_idx": { + "name": "core_moderators_permissions_user_id_idx", + "columns": [ + { + "expression": "userId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "core_moderators_permissions_roleId_core_roles_id_fk": { + "name": "core_moderators_permissions_roleId_core_roles_id_fk", + "tableFrom": "core_moderators_permissions", + "tableTo": "core_roles", + "columnsFrom": [ + "roleId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "core_moderators_permissions_userId_core_users_id_fk": { + "name": "core_moderators_permissions_userId_core_users_id_fk", + "tableFrom": "core_moderators_permissions", + "tableTo": "core_users", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.core_queue": { + "name": "core_queue", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "pluginId": { + "name": "pluginId", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "queue": { + "name": "queue", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true, + "default": "'default'" + }, + "status": { + "name": "status", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "priority": { + "name": "priority", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "maxAttempts": { + "name": "maxAttempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 3 + }, + "availableAt": { + "name": "availableAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "reservedAt": { + "name": "reservedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "lastError": { + "name": "lastError", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completedAt": { + "name": "completedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "core_queue_status_available_at_idx": { + "name": "core_queue_status_available_at_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "availableAt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.core_roles": { + "name": "core_roles", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "protected": { + "name": "protected", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "default": { + "name": "default", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "root": { + "name": "root", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "guest": { + "name": "guest", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "color": { + "name": "color", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + }, + "allowUploadFiles": { + "name": "allowUploadFiles", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "totalMaxStorage": { + "name": "totalMaxStorage", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "maxStorageForSubmit": { + "name": "maxStorageForSubmit", + "type": "integer", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.core_search_index": { + "name": "core_search_index", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "pluginId": { + "name": "pluginId", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "itemType": { + "name": "itemType", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "itemId": { + "name": "itemId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "languageCode": { + "name": "languageCode", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "authorId": { + "name": "authorId", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "search_vector": { + "name": "search_vector", + "type": "tsvector", + "primaryKey": false, + "notNull": true, + "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" + } + }, + "containerType": { + "name": "containerType", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "containerId": { + "name": "containerId", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "isPublic": { + "name": "isPublic", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "indexedAt": { + "name": "indexedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "core_search_index_search_vector_idx": { + "name": "core_search_index_search_vector_idx", + "columns": [ + { + "expression": "search_vector", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + }, + "core_search_index_created_at_idx": { + "name": "core_search_index_created_at_idx", + "columns": [ + { + "expression": "createdAt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "core_search_index_author_id_idx": { + "name": "core_search_index_author_id_idx", + "columns": [ + { + "expression": "authorId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "core_search_index_item_type_idx": { + "name": "core_search_index_item_type_idx", + "columns": [ + { + "expression": "itemType", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "core_search_index_language_code_idx": { + "name": "core_search_index_language_code_idx", + "columns": [ + { + "expression": "languageCode", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "core_search_index_is_public_idx": { + "name": "core_search_index_is_public_idx", + "columns": [ + { + "expression": "isPublic", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "core_search_index_authorId_core_users_id_fk": { + "name": "core_search_index_authorId_core_users_id_fk", + "tableFrom": "core_search_index", + "tableTo": "core_users", + "columnsFrom": [ + "authorId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "core_search_index_item_unique": { + "name": "core_search_index_item_unique", + "nullsNotDistinct": false, + "columns": [ + "itemType", + "itemId", + "languageCode" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.core_sessions": { + "name": "core_sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "token": { + "name": "token", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "userId": { + "name": "userId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expiresAt": { + "name": "expiresAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "deviceId": { + "name": "deviceId", + "type": "integer", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "core_sessions_user_id_idx": { + "name": "core_sessions_user_id_idx", + "columns": [ + { + "expression": "userId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "core_sessions_userId_core_users_id_fk": { + "name": "core_sessions_userId_core_users_id_fk", + "tableFrom": "core_sessions", + "tableTo": "core_users", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "core_sessions_deviceId_core_sessions_known_devices_id_fk": { + "name": "core_sessions_deviceId_core_sessions_known_devices_id_fk", + "tableFrom": "core_sessions", + "tableTo": "core_sessions_known_devices", + "columnsFrom": [ + "deviceId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "core_sessions_token_unique": { + "name": "core_sessions_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.core_sessions_known_devices": { + "name": "core_sessions_known_devices", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "publicId": { + "name": "publicId", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true + }, + "ipAddress": { + "name": "ipAddress", + "type": "varchar(40)", + "primaryKey": false, + "notNull": true + }, + "userAgent": { + "name": "userAgent", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "lastSeen": { + "name": "lastSeen", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "core_sessions_known_devices_ip_address_idx": { + "name": "core_sessions_known_devices_ip_address_idx", + "columns": [ + { + "expression": "ipAddress", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "core_sessions_known_devices_publicId_unique": { + "name": "core_sessions_known_devices_publicId_unique", + "nullsNotDistinct": false, + "columns": [ + "publicId" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.core_users": { + "name": "core_users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "nameCode": { + "name": "nameCode", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "password": { + "name": "password", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "newsletter": { + "name": "newsletter", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "avatarColor": { + "name": "avatarColor", + "type": "varchar(6)", + "primaryKey": false, + "notNull": true + }, + "emailVerified": { + "name": "emailVerified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "roleId": { + "name": "roleId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "birthday": { + "name": "birthday", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "ipAddress": { + "name": "ipAddress", + "type": "varchar(40)", + "primaryKey": false, + "notNull": true + }, + "language": { + "name": "language", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true, + "default": "'en'" + } + }, + "indexes": { + "core_users_name_code_idx": { + "name": "core_users_name_code_idx", + "columns": [ + { + "expression": "nameCode", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "core_users_name_idx": { + "name": "core_users_name_idx", + "columns": [ + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "core_users_email_idx": { + "name": "core_users_email_idx", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "core_users_roleId_core_roles_id_fk": { + "name": "core_users_roleId_core_roles_id_fk", + "tableFrom": "core_users", + "tableTo": "core_roles", + "columnsFrom": [ + "roleId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "core_users_language_core_languages_code_fk": { + "name": "core_users_language_core_languages_code_fk", + "tableFrom": "core_users", + "tableTo": "core_languages", + "columnsFrom": [ + "language" + ], + "columnsTo": [ + "code" + ], + "onDelete": "set default", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "core_users_nameCode_unique": { + "name": "core_users_nameCode_unique", + "nullsNotDistinct": false, + "columns": [ + "nameCode" + ] + }, + "core_users_name_unique": { + "name": "core_users_name_unique", + "nullsNotDistinct": false, + "columns": [ + "name" + ] + }, + "core_users_email_unique": { + "name": "core_users_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.core_users_confirm_emails": { + "name": "core_users_confirm_emails", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "userId": { + "name": "userId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expiresAt": { + "name": "expiresAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ipAddress": { + "name": "ipAddress", + "type": "varchar(40)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "core_users_confirm_emails_userId_core_users_id_fk": { + "name": "core_users_confirm_emails_userId_core_users_id_fk", + "tableFrom": "core_users_confirm_emails", + "tableTo": "core_users", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "core_users_confirm_emails_token_unique": { + "name": "core_users_confirm_emails_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.core_users_forgot_password": { + "name": "core_users_forgot_password", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "userId": { + "name": "userId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "ipAddress": { + "name": "ipAddress", + "type": "varchar(40)", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expiresAt": { + "name": "expiresAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "core_users_forgot_password_userId_core_users_id_fk": { + "name": "core_users_forgot_password_userId_core_users_id_fk", + "tableFrom": "core_users_forgot_password", + "tableTo": "core_users", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "core_users_forgot_password_userId_unique": { + "name": "core_users_forgot_password_userId_unique", + "nullsNotDistinct": false, + "columns": [ + "userId" + ] + }, + "core_users_forgot_password_token_unique": { + "name": "core_users_forgot_password_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.core_users_secondary_roles": { + "name": "core_users_secondary_roles", + "schema": "", + "columns": { + "userId": { + "name": "userId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "roleId": { + "name": "roleId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "core_users_secondary_roles_user_id_idx": { + "name": "core_users_secondary_roles_user_id_idx", + "columns": [ + { + "expression": "userId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "core_users_secondary_roles_role_id_idx": { + "name": "core_users_secondary_roles_role_id_idx", + "columns": [ + { + "expression": "roleId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "core_users_secondary_roles_userId_core_users_id_fk": { + "name": "core_users_secondary_roles_userId_core_users_id_fk", + "tableFrom": "core_users_secondary_roles", + "tableTo": "core_users", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "core_users_secondary_roles_roleId_core_roles_id_fk": { + "name": "core_users_secondary_roles_roleId_core_roles_id_fk", + "tableFrom": "core_users_secondary_roles", + "tableTo": "core_roles", + "columnsFrom": [ + "roleId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "core_users_secondary_roles_userId_roleId_pk": { + "name": "core_users_secondary_roles_userId_roleId_pk", + "columns": [ + "userId", + "roleId" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.core_users_sso": { + "name": "core_users_sso", + "schema": "", + "columns": { + "userId": { + "name": "userId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "providerId": { + "name": "providerId", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "providerAccountId": { + "name": "providerAccountId", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "core_users_sso_user_id_idx": { + "name": "core_users_sso_user_id_idx", + "columns": [ + { + "expression": "userId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "core_users_sso_userId_core_users_id_fk": { + "name": "core_users_sso_userId_core_users_id_fk", + "tableFrom": "core_users_sso", + "tableTo": "core_users", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.blog_categories": { + "name": "blog_categories", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "color": { + "name": "color", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "blog_categories_created_at_idx": { + "name": "blog_categories_created_at_idx", + "columns": [ + { + "expression": "createdAt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "blog_categories_updated_at_idx": { + "name": "blog_categories_updated_at_idx", + "columns": [ + { + "expression": "updatedAt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.blog_categories_translations": { + "name": "blog_categories_translations", + "schema": "", + "columns": { + "itemId": { + "name": "itemId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "languageId": { + "name": "languageId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "name": { + "name": "name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "blog_categories_translations_language_id_idx": { + "name": "blog_categories_translations_language_id_idx", + "columns": [ + { + "expression": "languageId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "blog_categories_translations_itemId_blog_categories_id_fk": { + "name": "blog_categories_translations_itemId_blog_categories_id_fk", + "tableFrom": "blog_categories_translations", + "tableTo": "blog_categories", + "columnsFrom": [ + "itemId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "blog_categories_translations_languageId_core_languages_id_fk": { + "name": "blog_categories_translations_languageId_core_languages_id_fk", + "tableFrom": "blog_categories_translations", + "tableTo": "core_languages", + "columnsFrom": [ + "languageId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": { + "blog_categories_translations_item_id_language_id_pk": { + "name": "blog_categories_translations_item_id_language_id_pk", + "columns": [ + "itemId", + "languageId" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.blog_posts": { + "name": "blog_posts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "publishedAt": { + "name": "publishedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true, + "default": "'draft'" + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "categoryId": { + "name": "categoryId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "authorId": { + "name": "authorId", + "type": "integer", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "blog_posts_status_created_at_idx": { + "name": "blog_posts_status_created_at_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "createdAt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "blog_posts_category_id_idx": { + "name": "blog_posts_category_id_idx", + "columns": [ + { + "expression": "categoryId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "blog_posts_author_id_idx": { + "name": "blog_posts_author_id_idx", + "columns": [ + { + "expression": "authorId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "blog_posts_created_at_idx": { + "name": "blog_posts_created_at_idx", + "columns": [ + { + "expression": "createdAt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "blog_posts_updated_at_idx": { + "name": "blog_posts_updated_at_idx", + "columns": [ + { + "expression": "updatedAt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "blog_posts_status_published_at_idx": { + "name": "blog_posts_status_published_at_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "publishedAt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "blog_posts_categoryId_blog_categories_id_fk": { + "name": "blog_posts_categoryId_blog_categories_id_fk", + "tableFrom": "blog_posts", + "tableTo": "blog_categories", + "columnsFrom": [ + "categoryId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "cascade" + }, + "blog_posts_authorId_core_users_id_fk": { + "name": "blog_posts_authorId_core_users_id_fk", + "tableFrom": "blog_posts", + "tableTo": "core_users", + "columnsFrom": [ + "authorId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.blog_posts_translations": { + "name": "blog_posts_translations", + "schema": "", + "columns": { + "itemId": { + "name": "itemId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "languageId": { + "name": "languageId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "publishedAt": { + "name": "publishedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true, + "default": "'draft'" + }, + "title": { + "name": "title", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "friendlyUrl": { + "name": "friendlyUrl", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "blog_posts_translations_language_id_status_idx": { + "name": "blog_posts_translations_language_id_status_idx", + "columns": [ + { + "expression": "languageId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "blog_posts_translations_language_id_friendly_url_key": { + "name": "blog_posts_translations_language_id_friendly_url_key", + "columns": [ + { + "expression": "languageId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "friendlyUrl", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "blog_posts_translations_itemId_blog_posts_id_fk": { + "name": "blog_posts_translations_itemId_blog_posts_id_fk", + "tableFrom": "blog_posts_translations", + "tableTo": "blog_posts", + "columnsFrom": [ + "itemId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "blog_posts_translations_languageId_core_languages_id_fk": { + "name": "blog_posts_translations_languageId_core_languages_id_fk", + "tableFrom": "blog_posts_translations", + "tableTo": "core_languages", + "columnsFrom": [ + "languageId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": { + "blog_posts_translations_item_id_language_id_pk": { + "name": "blog_posts_translations_item_id_language_id_pk", + "columns": [ + "itemId", + "languageId" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.example_advanced_articles": { + "name": "example_advanced_articles", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "publishedAt": { + "name": "publishedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true, + "default": "'draft'" + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "syndicationIndexable": { + "name": "syndicationIndexable", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "syndicationNoIndex": { + "name": "syndicationNoIndex", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "syndicationPriority": { + "name": "syndicationPriority", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 5 + } + }, + "indexes": { + "example_advanced_articles_syndication_priority_idx": { + "name": "example_advanced_articles_syndication_priority_idx", + "columns": [ + { + "expression": "syndicationPriority", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "example_advanced_articles_created_at_idx": { + "name": "example_advanced_articles_created_at_idx", + "columns": [ + { + "expression": "createdAt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "example_advanced_articles_updated_at_idx": { + "name": "example_advanced_articles_updated_at_idx", + "columns": [ + { + "expression": "updatedAt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "example_advanced_articles_status_published_at_idx": { + "name": "example_advanced_articles_status_published_at_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "publishedAt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.example_advanced_articles_categories": { + "name": "example_advanced_articles_categories", + "schema": "", + "columns": { + "itemId": { + "name": "itemId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "relatedItemId": { + "name": "relatedItemId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "example_advanced_articles_categories_position_key": { + "name": "example_advanced_articles_categories_position_key", + "columns": [ + { + "expression": "itemId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "position", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "example_advanced_articles_categories_related_item_id_idx": { + "name": "example_advanced_articles_categories_related_item_id_idx", + "columns": [ + { + "expression": "relatedItemId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "example_advanced_articles_categories_itemId_example_advanced_articles_id_fk": { + "name": "example_advanced_articles_categories_itemId_example_advanced_articles_id_fk", + "tableFrom": "example_advanced_articles_categories", + "tableTo": "example_advanced_articles", + "columnsFrom": [ + "itemId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "example_advanced_articles_categories_relatedItemId_example_categories_id_fk": { + "name": "example_advanced_articles_categories_relatedItemId_example_categories_id_fk", + "tableFrom": "example_advanced_articles_categories", + "tableTo": "example_categories", + "columnsFrom": [ + "relatedItemId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": { + "example_advanced_articles_categories_pk": { + "name": "example_advanced_articles_categories_pk", + "columns": [ + "itemId", + "relatedItemId" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.example_advanced_articles_faq": { + "name": "example_advanced_articles_faq", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "itemId": { + "name": "itemId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "question": { + "name": "question", + "type": "varchar(200)", + "primaryKey": false, + "notNull": true + }, + "answer": { + "name": "answer", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "example_advanced_articles_faq_position_key": { + "name": "example_advanced_articles_faq_position_key", + "columns": [ + { + "expression": "itemId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "position", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "example_advanced_articles_faq_itemId_example_advanced_articles_id_fk": { + "name": "example_advanced_articles_faq_itemId_example_advanced_articles_id_fk", + "tableFrom": "example_advanced_articles_faq", + "tableTo": "example_advanced_articles", + "columnsFrom": [ + "itemId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.example_advanced_articles_related_articles": { + "name": "example_advanced_articles_related_articles", + "schema": "", + "columns": { + "itemId": { + "name": "itemId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "relatedItemId": { + "name": "relatedItemId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "example_advanced_articles_related_articles_position_key": { + "name": "example_advanced_articles_related_articles_position_key", + "columns": [ + { + "expression": "itemId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "position", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "example_advanced_articles_related_articles_related_item_id_idx": { + "name": "example_advanced_articles_related_articles_related_item_id_idx", + "columns": [ + { + "expression": "relatedItemId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "example_advanced_articles_related_articles_itemId_example_advanced_articles_id_fk": { + "name": "example_advanced_articles_related_articles_itemId_example_advanced_articles_id_fk", + "tableFrom": "example_advanced_articles_related_articles", + "tableTo": "example_advanced_articles", + "columnsFrom": [ + "itemId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "example_advanced_articles_related_articles_relatedItemId_example_advanced_articles_id_fk": { + "name": "example_advanced_articles_related_articles_relatedItemId_example_advanced_articles_id_fk", + "tableFrom": "example_advanced_articles_related_articles", + "tableTo": "example_advanced_articles", + "columnsFrom": [ + "relatedItemId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": { + "example_advanced_articles_related_articles_pk": { + "name": "example_advanced_articles_related_articles_pk", + "columns": [ + "itemId", + "relatedItemId" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.example_advanced_articles_translations": { + "name": "example_advanced_articles_translations", + "schema": "", + "columns": { + "itemId": { + "name": "itemId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "languageId": { + "name": "languageId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "publishedAt": { + "name": "publishedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true, + "default": "'draft'" + }, + "title": { + "name": "title", + "type": "varchar(200)", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "varchar(160)", + "primaryKey": false, + "notNull": true + }, + "seoTitle": { + "name": "seoTitle", + "type": "varchar(200)", + "primaryKey": false, + "notNull": false + }, + "seoDescription": { + "name": "seoDescription", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "example_advanced_articles_translations_language_id_status_idx": { + "name": "example_advanced_articles_translations_language_id_status_idx", + "columns": [ + { + "expression": "languageId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "example_advanced_articles_translations_language_id_slug_key": { + "name": "example_advanced_articles_translations_language_id_slug_key", + "columns": [ + { + "expression": "languageId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "example_advanced_articles_translations_itemId_example_advanced_articles_id_fk": { + "name": "example_advanced_articles_translations_itemId_example_advanced_articles_id_fk", + "tableFrom": "example_advanced_articles_translations", + "tableTo": "example_advanced_articles", + "columnsFrom": [ + "itemId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "example_advanced_articles_translations_languageId_core_languages_id_fk": { + "name": "example_advanced_articles_translations_languageId_core_languages_id_fk", + "tableFrom": "example_advanced_articles_translations", + "tableTo": "core_languages", + "columnsFrom": [ + "languageId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": { + "example_advanced_articles_translations_item_id_language_id_pk": { + "name": "example_advanced_articles_translations_item_id_language_id_pk", + "columns": [ + "itemId", + "languageId" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.example_articles": { + "name": "example_articles", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "publishedAt": { + "name": "publishedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true, + "default": "'draft'" + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "title": { + "name": "title", + "type": "varchar(200)", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "varchar(160)", + "primaryKey": false, + "notNull": true + }, + "code": { + "name": "code", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "excerpt": { + "name": "excerpt", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "views": { + "name": "views", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "featured": { + "name": "featured", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "noIndex": { + "name": "noIndex", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "author": { + "name": "author", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "category": { + "name": "category", + "type": "integer", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "example_articles_status_created_at_idx": { + "name": "example_articles_status_created_at_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "createdAt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "example_articles_slug_key": { + "name": "example_articles_slug_key", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "example_articles_code_key": { + "name": "example_articles_code_key", + "columns": [ + { + "expression": "code", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "example_articles_author_idx": { + "name": "example_articles_author_idx", + "columns": [ + { + "expression": "author", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "example_articles_category_idx": { + "name": "example_articles_category_idx", + "columns": [ + { + "expression": "category", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "example_articles_created_at_idx": { + "name": "example_articles_created_at_idx", + "columns": [ + { + "expression": "createdAt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "example_articles_updated_at_idx": { + "name": "example_articles_updated_at_idx", + "columns": [ + { + "expression": "updatedAt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "example_articles_status_published_at_idx": { + "name": "example_articles_status_published_at_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "publishedAt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "example_articles_author_core_users_id_fk": { + "name": "example_articles_author_core_users_id_fk", + "tableFrom": "example_articles", + "tableTo": "core_users", + "columnsFrom": [ + "author" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "cascade" + }, + "example_articles_category_example_categories_id_fk": { + "name": "example_articles_category_example_categories_id_fk", + "tableFrom": "example_articles", + "tableTo": "example_categories", + "columnsFrom": [ + "category" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.example_categories": { + "name": "example_categories", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "name": { + "name": "name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "example_categories_created_at_idx": { + "name": "example_categories_created_at_idx", + "columns": [ + { + "expression": "createdAt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "example_categories_updated_at_idx": { + "name": "example_categories_updated_at_idx", + "columns": [ + { + "expression": "updatedAt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.example_localized_articles": { + "name": "example_localized_articles", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "publishedAt": { + "name": "publishedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true, + "default": "'draft'" + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "featured": { + "name": "featured", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": { + "example_localized_articles_created_at_idx": { + "name": "example_localized_articles_created_at_idx", + "columns": [ + { + "expression": "createdAt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "example_localized_articles_updated_at_idx": { + "name": "example_localized_articles_updated_at_idx", + "columns": [ + { + "expression": "updatedAt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "example_localized_articles_status_published_at_idx": { + "name": "example_localized_articles_status_published_at_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "publishedAt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.example_localized_articles_translations": { + "name": "example_localized_articles_translations", + "schema": "", + "columns": { + "itemId": { + "name": "itemId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "languageId": { + "name": "languageId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "publishedAt": { + "name": "publishedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true, + "default": "'draft'" + }, + "title": { + "name": "title", + "type": "varchar(200)", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "varchar(160)", + "primaryKey": false, + "notNull": true + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "example_localized_articles_translations_language_id_status_idx": { + "name": "example_localized_articles_translations_language_id_status_idx", + "columns": [ + { + "expression": "languageId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "example_localized_articles_translations_language_id_slug_key": { + "name": "example_localized_articles_translations_language_id_slug_key", + "columns": [ + { + "expression": "languageId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "example_localized_articles_translations_itemId_example_localized_articles_id_fk": { + "name": "example_localized_articles_translations_itemId_example_localized_articles_id_fk", + "tableFrom": "example_localized_articles_translations", + "tableTo": "example_localized_articles", + "columnsFrom": [ + "itemId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "example_localized_articles_translations_languageId_core_languages_id_fk": { + "name": "example_localized_articles_translations_languageId_core_languages_id_fk", + "tableFrom": "example_localized_articles_translations", + "tableTo": "core_languages", + "columnsFrom": [ + "languageId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": { + "example_localized_articles_translations_item_id_language_id_pk": { + "name": "example_localized_articles_translations_item_id_language_id_pk", + "columns": [ + "itemId", + "languageId" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/apps/docs/migrations/meta/_journal.json b/apps/docs/migrations/meta/_journal.json index 2ba6eff0f..72819b08a 100644 --- a/apps/docs/migrations/meta/_journal.json +++ b/apps/docs/migrations/meta/_journal.json @@ -246,6 +246,13 @@ "when": 1786292946013, "tag": "0034_add_example_article_no_index_flag", "breakpoints": true + }, + { + "idx": 35, + "version": "7", + "when": 1786350996229, + "tag": "0035_migrate_blog_to_content_engine", + "breakpoints": true } ] } \ No newline at end of file diff --git a/apps/docs/src/app/[locale]/admin/(auth)/(plugins)/(vitnode-blog)/blog/categories/page.tsx b/apps/docs/src/app/[locale]/admin/(auth)/(plugins)/(vitnode-blog)/blog/categories/page.tsx index 3dfeea398..81d74e4c9 100644 --- a/apps/docs/src/app/[locale]/admin/(auth)/(plugins)/(vitnode-blog)/blog/categories/page.tsx +++ b/apps/docs/src/app/[locale]/admin/(auth)/(plugins)/(vitnode-blog)/blog/categories/page.tsx @@ -1,64 +1,8 @@ -import type { Metadata } from "next"; +import { blogCategoryContentType } from "@vitnode/blog/content/category"; +import { contentAdminHref } from "@vitnode/core/content"; +import { redirect } from "@vitnode/core/lib/navigation"; -import { I18nProvider } from "@vitnode/core/components/i18n-provider"; -import { DataTableSkeleton } from "@vitnode/core/components/table/data-table"; -import { HeaderContent } from "@vitnode/core/components/ui/header-content"; -import { checkAdminPermissionApi } from "@vitnode/core/lib/api/get-session-admin-api"; -import { getTranslations } from "next-intl/server"; -import dynamic from "next/dynamic"; -import { notFound } from "next/navigation"; -import React from "react"; - -import { CONFIG_PLUGIN } from "@vitnode/blog/const"; -import { ActionsCategoriesAdmin } from "@vitnode/blog/views/admin/categories/actions/actions"; - -const CategoriesAdminView = dynamic(async () => - import("@vitnode/blog/views/admin/categories/table/categories-admin-view").then(mod => ({ - default: mod.CategoriesAdminView, - })), -); - -export const generateMetadata = async (): Promise => { - const t = await getTranslations("@vitnode/blog.admin.nav"); - - return { - title: t("categories"), - }; -}; - -export default async function CategoriesPage( - params: React.ComponentProps, -) { - const [t, tNav, canView, canCreate] = await Promise.all([ - getTranslations("@vitnode/blog.admin.categories"), - getTranslations("@vitnode/blog.admin.nav"), - checkAdminPermissionApi({ - plugin: CONFIG_PLUGIN.pluginId, - module: "categories", - permission: "can_view", - }), - checkAdminPermissionApi({ - plugin: CONFIG_PLUGIN.pluginId, - module: "categories", - permission: "can_create", - }), - ]); - - if (!canView) { - notFound(); - } - - return ( - -
- - {canCreate && } - - - }> - - -
-
- ); +/** The address categories used to live at. See the posts page next door. */ +export default async function LegacyCategoriesPage() { + await redirect(contentAdminHref(blogCategoryContentType.id)); } diff --git a/apps/docs/src/app/[locale]/admin/(auth)/(plugins)/(vitnode-blog)/blog/posts/page.tsx b/apps/docs/src/app/[locale]/admin/(auth)/(plugins)/(vitnode-blog)/blog/posts/page.tsx index 99d58036d..d381bcf9f 100644 --- a/apps/docs/src/app/[locale]/admin/(auth)/(plugins)/(vitnode-blog)/blog/posts/page.tsx +++ b/apps/docs/src/app/[locale]/admin/(auth)/(plugins)/(vitnode-blog)/blog/posts/page.tsx @@ -1,64 +1,15 @@ -import type { Metadata } from "next"; - -import { I18nProvider } from "@vitnode/core/components/i18n-provider"; -import { DataTableSkeleton } from "@vitnode/core/components/table/data-table"; -import { HeaderContent } from "@vitnode/core/components/ui/header-content"; -import { checkAdminPermissionApi } from "@vitnode/core/lib/api/get-session-admin-api"; -import { getTranslations } from "next-intl/server"; -import dynamic from "next/dynamic"; -import { notFound } from "next/navigation"; -import React from "react"; - -import { CONFIG_PLUGIN } from "@vitnode/blog/const"; -import { ActionsPostsAdmin } from "@vitnode/blog/views/admin/posts/actions/actions"; - -const PostsAdminView = dynamic(async () => - import("@vitnode/blog/views/admin/posts/table/posts-admin-view").then(mod => ({ - default: mod.PostsAdminView, - })), -); - -export const generateMetadata = async (): Promise => { - const t = await getTranslations("@vitnode/blog.admin.nav"); - - return { - title: t("posts"), - }; -}; - -export default async function PostsPage( - params: React.ComponentProps, -) { - const [t, tNav, canView, canCreate] = await Promise.all([ - getTranslations("@vitnode/blog.admin.posts"), - getTranslations("@vitnode/blog.admin.nav"), - checkAdminPermissionApi({ - plugin: CONFIG_PLUGIN.pluginId, - module: "posts", - permission: "can_view", - }), - checkAdminPermissionApi({ - plugin: CONFIG_PLUGIN.pluginId, - module: "posts", - permission: "can_create", - }), - ]); - - if (!canView) { - notFound(); - } - - return ( - -
- - {canCreate && } - - - }> - - -
-
- ); +import { blogPostContentType } from "@vitnode/blog/content/post"; +import { contentAdminHref } from "@vitnode/core/content"; +import { redirect } from "@vitnode/core/lib/navigation"; + +/** + * The address articles used to live at. + * + * A redirect rather than a second list screen: the AdminCP linked here for + * several releases, so the URL is in bookmarks and in muscle memory - but the + * page behind it is now generated, and keeping a duplicate of it would mean two + * tables to fix every time one of them was wrong. + */ +export default async function LegacyPostsPage() { + await redirect(contentAdminHref(blogPostContentType.id)); } diff --git a/apps/docs/src/app/[locale]/admin/(auth)/@breadcrumb/blog/categories/page.tsx b/apps/docs/src/app/[locale]/admin/(auth)/@breadcrumb/blog/categories/page.tsx deleted file mode 100644 index b4680017f..000000000 --- a/apps/docs/src/app/[locale]/admin/(auth)/@breadcrumb/blog/categories/page.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import { BreadcrumbAdmin } from "@vitnode/core/views/admin/layouts/breadcrumb/breadcrumb-admin"; - -export default function BreadcrumbSlot() { - return ; -} diff --git a/apps/docs/src/app/[locale]/admin/(auth)/@breadcrumb/blog/posts/page.tsx b/apps/docs/src/app/[locale]/admin/(auth)/@breadcrumb/blog/posts/page.tsx deleted file mode 100644 index 6aad0fb44..000000000 --- a/apps/docs/src/app/[locale]/admin/(auth)/@breadcrumb/blog/posts/page.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import { BreadcrumbAdmin } from "@vitnode/core/views/admin/layouts/breadcrumb/breadcrumb-admin"; - -export default function BreadcrumbSlot() { - return ; -} diff --git a/apps/docs/src/locales/@vitnode/blog/pl.json b/apps/docs/src/locales/@vitnode/blog/pl.json index c4dd50874..e3eb86744 100644 --- a/apps/docs/src/locales/@vitnode/blog/pl.json +++ b/apps/docs/src/locales/@vitnode/blog/pl.json @@ -1,92 +1,65 @@ { "@vitnode/blog": { "title": "Blog", - "admin": { - "nav": { - "posts": "Wpisy", - "categories": "Kategorie" - }, - "categories": { - "desc": "Zarządzaj kategoriami wpisĂłw na blogu.", - "table": { + "content": { + "post": { + "title": "ArtykuƂy", + "desc": "Pisz artykuƂy na blogu i zarządzaj nimi.", + "fields": { "title": "TytuƂ", + "friendlyUrl": "Przyjazny adres URL", + "content": "Treƛć", + "categoryId": "Kategoria", + "authorId": "Autor", + "status": "Status", + "publishedAt": "Opublikowano", + "updatedAt": "Zaktualizowano" + } + }, + "category": { + "title": "Kategorie", + "desc": "Grupuj artykuƂy razem.", + "fields": { + "name": "Nazwa", "color": "Kolor", - "updated_at": "Zaktualizowano" - }, - "delete": { - "title": "UsuƄ kategorię", - "desc": "Czy na pewno chcesz usunąć kategorię ? Tej akcji nie moĆŒna cofnąć.", - "confirm": "Tak, usuƄ tę kategorię", - "success": "Kategoria zostaƂa pomyƛlnie usunięta." - }, - "create": { - "title": "UtwĂłrz kategorię", - "desc": "Nowa kategoria dla wpisĂłw na blogu.", - "form": { - "title": { - "label": "TytuƂ", - "already_exists": "Kategoria o tym tytule juĆŒ istnieje." - }, - "color": "Kolor" - }, - "submit": "UtwĂłrz", - "success": "Kategoria zostaƂa pomyƛlnie utworzona." + "updatedAt": "Zaktualizowano" + } + } + }, + "admin": { + "article": { + "content": { + "label": "Treƛć" }, - "edit": { - "title": "Edytuj kategorię", - "submit": "Zapisz zmiany", - "success": "Kategoria zostaƂa pomyƛlnie zaktualizowana." + "form": { + "publish": "Publikacja", + "settings": { + "title": "Ustawienia artykuƂu", + "locale_desc": "Adres i metadane wersji w tym języku." + } } }, - "posts": { - "desc": "TwĂłrz wpisy na blogu i zarządzaj nimi.", - "table": { - "title": "TytuƂ", - "category": "Kategoria", - "author": "Autor", - "updated_at": "Zaktualizowano" - }, - "create": { - "title": "UtwĂłrz wpis", - "desc": "Napisz nowy artykuƂ na swĂłj blog.", - "form": { - "title": { - "label": "TytuƂ", - "already_exists": "Wpis o tym tytule juĆŒ istnieje." - }, - "friendly_url": { - "label": "Przyjazny adres URL", - "desc": "UĆŒywany w adresie wpisu. WypeƂniany automatycznie na podstawie tytuƂu.", - "already_exists": "Taki przyjazny adres URL juĆŒ istnieje." - }, - "content": "Treƛć", - "category": "Kategoria" - }, - "submit": "UtwĂłrz wpis", - "success": "Wpis zostaƂ pomyƛlnie utworzony." - }, - "edit": { - "title": "Edytuj wpis", - "submit": "Zapisz zmiany", - "success": "Wpis zostaƂ pomyƛlnie zaktualizowany." - }, - "delete": { - "title": "UsuƄ wpis", - "desc": "Czy na pewno chcesz usunąć wpis ? Tej akcji nie moĆŒna cofnąć.", - "confirm": "Tak, usuƄ ten wpis", - "success": "Wpis zostaƂ pomyƛlnie usunięty." + "category": { + "color": { + "label": "Kolor", + "desc": "Wyƛwietlany obok kategorii na listach.", + "none": "Brak koloru" } } } }, - "@vitnode/blog:posts": "Wpisy", - "@vitnode/blog:posts:can_view": "Wyƛwietlanie listy wpisĂłw", - "@vitnode/blog:posts:can_create": "Tworzenie wpisĂłw", - "@vitnode/blog:posts:can_edit": "Edytowanie wpisĂłw", - "@vitnode/blog:posts:can_delete": "Usuwanie wpisĂłw", + "@vitnode/blog:posts": "ArtykuƂy", + "@vitnode/blog:posts:can_view": "Wyƛwietlanie listy artykuƂów", + "@vitnode/blog:posts:can_create": "Tworzenie artykuƂów", + "@vitnode/blog:posts:can_edit": "Edytowanie artykuƂów", + "@vitnode/blog:posts:can_delete": "Usuwanie artykuƂów", + "@vitnode/blog:posts:can_publish": "Publikowanie i cofanie publikacji artykuƂów", + "@vitnode/blog:posts:can_restore": "Przywracanie wczeƛniejszej wersji artykuƂu", + "@vitnode/blog:posts:can_translate": "Pisanie tƂumaczeƄ artykuƂów", "@vitnode/blog:categories": "Kategorie", "@vitnode/blog:categories:can_view": "Wyƛwietlanie listy kategorii", "@vitnode/blog:categories:can_create": "Tworzenie kategorii", "@vitnode/blog:categories:can_edit": "Edytowanie kategorii", - "@vitnode/blog:categories:can_delete": "Usuwanie kategorii" + "@vitnode/blog:categories:can_delete": "Usuwanie kategorii", + "@vitnode/blog:categories:can_translate": "Pisanie tƂumaczeƄ kategorii" } diff --git a/plugins/blog/package.json b/plugins/blog/package.json index 6214bcfc5..e2b13a778 100644 --- a/plugins/blog/package.json +++ b/plugins/blog/package.json @@ -32,7 +32,10 @@ "dev": "vitnode dev", "dev:email": "email dev --dir src/emails", "lint": "eslint .", - "lint:fix": "eslint . --fix" + "lint:fix": "eslint . --fix", + "test": "vitest run", + "test:watch": "vitest", + "test:types": "vitest run --typecheck.only" }, "dependencies": { "@hono/zod-openapi": "^1.5.1", @@ -55,11 +58,17 @@ "@react-email/ui": "^6.9.0", "@swc/cli": "^0.8.1", "@swc/core": "^1.15.46", + "@testing-library/dom": "^10.4.1", + "@testing-library/react": "^16.3.2", "@types/react": "^19.2.17", "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^6.0.4", "@vitnode/config": "workspace:*", "eslint": "^10.7.0", + "jsdom": "^29.1.1", + "postgres": "^3.4.9", "tsc-alias": "^1.9.1", - "typescript": "^6.0.3" + "typescript": "^6.0.3", + "vitest": "^4.1.10" } } diff --git a/plugins/blog/src/api/lib/categories-language.ts b/plugins/blog/src/api/lib/categories-language.ts deleted file mode 100644 index 6187d22f9..000000000 --- a/plugins/blog/src/api/lib/categories-language.ts +++ /dev/null @@ -1,69 +0,0 @@ -import type { MultiLangValue } from "@vitnode/core/lib/helpers/multi-lang"; -import type { Context } from "hono"; - -import { saveLanguageWords } from "@vitnode/core/api/lib/save-language-words"; -import { core_languages_words } from "@vitnode/core/database/languages"; -import { and, eq, inArray } from "drizzle-orm"; - -import { CONFIG_PLUGIN } from "@/const"; - -export const CATEGORY_LANG_TABLE = "blog_categories"; -export const CATEGORY_LANG_VARIABLE = "title"; - -export interface CategoryTranslations { - title: MultiLangValue; -} - -// The category title lives entirely in `core_languages_words` (one row per -// language); nothing text-like remains on `blog_categories`. -export const saveCategoryTranslations = async ( - c: Context, - itemId: number, - { title }: CategoryTranslations, -): Promise => { - await saveLanguageWords(c, { - pluginCode: CONFIG_PLUGIN.pluginId, - tableName: CATEGORY_LANG_TABLE, - variable: CATEGORY_LANG_VARIABLE, - itemId, - values: title, - }); -}; - -export const loadCategoryTranslations = async ( - c: Context, - categoryIds: number[], -): Promise> => { - const result = new Map(); - if (categoryIds.length === 0) { - return result; - } - - const words = await c - .get("db") - .select({ - itemId: core_languages_words.itemId, - languageCode: core_languages_words.languageCode, - value: core_languages_words.value, - }) - .from(core_languages_words) - .where( - and( - eq(core_languages_words.pluginCode, CONFIG_PLUGIN.pluginId), - eq(core_languages_words.tableName, CATEGORY_LANG_TABLE), - eq(core_languages_words.variable, CATEGORY_LANG_VARIABLE), - inArray(core_languages_words.itemId, categoryIds), - ), - ); - - for (const id of categoryIds) { - result.set( - id, - words - .filter(word => word.itemId === id) - .map(({ languageCode, value }) => ({ languageCode, value })), - ); - } - - return result; -}; diff --git a/plugins/blog/src/api/lib/events.ts b/plugins/blog/src/api/lib/events.ts index b9932c9e8..441ae1948 100644 --- a/plugins/blog/src/api/lib/events.ts +++ b/plugins/blog/src/api/lib/events.ts @@ -1,12 +1,47 @@ +import type { EnvVitNode } from "@vitnode/core/api/middlewares/global.middleware"; +import type { ContentEventsFor } from "@vitnode/core/content"; +import type { Context } from "hono"; + import { buildEventListener } from "@vitnode/core/api/lib/events"; +import { contentEventName } from "@vitnode/core/content"; +import { eq } from "drizzle-orm"; + +import type { blogCategoryContentType } from "@/content/category"; +import { blogPostContentType } from "@/content/post"; +import { blog_posts } from "@/database/posts"; + +/** + * The blog's own event names, kept as **adapters** over the Content Engine's. + * + * There is one mutation pipeline now - the engine's - and these listeners + * translate its events into the names the blog has always published, so a plugin + * listening for `blog.post.created` keeps working without the blog keeping a + * second way to write a row. + * + * They are a compatibility layer with a shelf life. New listeners should use + * `content.blog.post.*` and `content.blog.category.*`, which carry more: changed + * fields, revision ids, publication transitions, per-locale translation events + * and slug history - none of which the blog's own names ever had. + */ declare module "@vitnode/core/api/models/events" { - interface VitNodeEvents { + interface VitNodeEvents + extends + ContentEventsFor, + ContentEventsFor { "blog.category.created": { categoryId: number; }; "blog.category.deleted": { categoryId: number; + /** + * Always empty. + * + * It always effectively was: the foreign key from `blog_posts` refuses a + * category that still has articles, so a category deletion that succeeds + * is one that had none. The field stays so existing listeners still + * compile. + */ postIds: number[]; }; "blog.category.updated": { @@ -16,8 +51,15 @@ declare module "@vitnode/core/api/models/events" { categoryId: number; postId: number; }; + /** + * No `categoryId`, unlike the other two. + * + * The row is gone by the time this is emitted, so there is nothing left to + * read it from - and inventing one would put a wrong category id into an + * audit trail. A listener that needs it should watch + * `content.blog.post.deleted` and keep its own index. + */ "blog.post.deleted": { - categoryId: number; postId: number; }; "blog.post.updated": { @@ -27,14 +69,106 @@ declare module "@vitnode/core/api/models/events" { } } -export const cleanupCategorySearchListener = buildEventListener({ - event: "blog.category.deleted", - name: "cleanup-category-search", - description: - "Remove search index rows of posts cascade-deleted with a category", +const POST = blogPostContentType.id; +const CATEGORY = "blog.category"; + +/** The category an article is in, read back for the legacy payload. */ +const categoryOf = async ( + c: Context, + postId: number, +): Promise => { + const [post] = await c + .get("db") + .select({ categoryId: blog_posts.categoryId }) + .from(blog_posts) + .where(eq(blog_posts.id, postId)) + .limit(1); + + return post?.categoryId ?? null; +}; + +export const legacyPostCreatedListener = buildEventListener({ + event: contentEventName(POST, "created"), + name: "legacy-blog-post-created", + description: "Re-emits the blog's own blog.post.created event", + handler: async (c, payload) => { + const categoryId = await categoryOf(c, payload.contentId); + if (categoryId === null) return; + + await c.get("events").emit("blog.post.created", { + categoryId, + postId: payload.contentId, + }); + }, +}); + +export const legacyPostUpdatedListener = buildEventListener({ + event: contentEventName(POST, "updated"), + name: "legacy-blog-post-updated", + description: "Re-emits the blog's own blog.post.updated event", + handler: async (c, payload) => { + const categoryId = await categoryOf(c, payload.contentId); + if (categoryId === null) return; + + await c.get("events").emit("blog.post.updated", { + categoryId, + postId: payload.contentId, + }); + }, +}); + +export const legacyPostDeletedListener = buildEventListener({ + event: contentEventName(POST, "deleted"), + name: "legacy-blog-post-deleted", + description: "Re-emits the blog's own blog.post.deleted event", + handler: async (c, payload) => { + await c.get("events").emit("blog.post.deleted", { + postId: payload.contentId, + }); + }, +}); + +export const legacyCategoryCreatedListener = buildEventListener({ + event: contentEventName(CATEGORY, "created"), + name: "legacy-blog-category-created", + description: "Re-emits the blog's own blog.category.created event", handler: async (c, payload) => { - for (const postId of payload.postIds) { - await c.get("search").delete("blog_post", postId); - } + await c.get("events").emit("blog.category.created", { + categoryId: payload.contentId, + }); }, }); + +export const legacyCategoryUpdatedListener = buildEventListener({ + event: contentEventName(CATEGORY, "updated"), + name: "legacy-blog-category-updated", + description: "Re-emits the blog's own blog.category.updated event", + handler: async (c, payload) => { + await c.get("events").emit("blog.category.updated", { + categoryId: payload.contentId, + }); + }, +}); + +export const legacyCategoryDeletedListener = buildEventListener({ + event: contentEventName(CATEGORY, "deleted"), + name: "legacy-blog-category-deleted", + description: "Re-emits the blog's own blog.category.deleted event", + handler: async (c, payload) => { + await c.get("events").emit("blog.category.deleted", { + categoryId: payload.contentId, + // See the payload's own note: a category with articles cannot be deleted, + // so a deletion that happened had nothing to cascade. + postIds: [], + }); + }, +}); + +export const blogLegacyEventListeners = [ + legacyCategoryCreatedListener, + legacyCategoryDeletedListener, + legacyCategoryUpdatedListener, + legacyPostCreatedListener, + legacyPostDeletedListener, + legacyPostUpdatedListener, +]; diff --git a/plugins/blog/src/api/lib/posts-language.ts b/plugins/blog/src/api/lib/posts-language.ts deleted file mode 100644 index a7390e72b..000000000 --- a/plugins/blog/src/api/lib/posts-language.ts +++ /dev/null @@ -1,129 +0,0 @@ -import type { MultiLangValue } from "@vitnode/core/lib/helpers/multi-lang"; -import type { Context } from "hono"; - -import { saveLanguageWords } from "@vitnode/core/api/lib/save-language-words"; -import { - core_languages, - core_languages_words, -} from "@vitnode/core/database/languages"; -import { getLangValue } from "@vitnode/core/lib/helpers/multi-lang"; -import { removeSpecialCharacters } from "@vitnode/core/lib/special-characters"; -import { and, eq, inArray } from "drizzle-orm"; - -import { CONFIG_PLUGIN } from "@/const"; - -export const POST_LANG_TABLE = "blog_posts"; -export const POST_LANG_VARIABLES = ["title", "content", "friendlyUrl"] as const; -export type PostLangVariable = (typeof POST_LANG_VARIABLES)[number]; - -export interface PostTranslations { - content: MultiLangValue; - friendlyUrl: MultiLangValue; - title: MultiLangValue; -} - -export const slugifyMultiLang = (values: MultiLangValue): MultiLangValue => - values.map(({ languageCode, value }) => ({ - languageCode, - value: removeSpecialCharacters(value), - })); - -export const getDefaultLanguageCode = async ( - c: Context, -): Promise => { - const [language] = await c - .get("db") - .select({ code: core_languages.code }) - .from(core_languages) - .where(eq(core_languages.default, true)) - .limit(1); - - return language?.code ?? null; -}; - -// Every translated field lives in `core_languages_words`; nothing text-like -// remains on the table. This picks the default-language value (falling back to -// the first available) - used to derive slugs and validate required fields. -export const pickDefaultValue = ( - values: MultiLangValue, - defaultLanguageCode: null | string, -): string => - values.find(item => item.languageCode === defaultLanguageCode)?.value ?? - values[0]?.value ?? - ""; - -// Resolve a translated field for a language: the exact translation, else the -// default-language value, else the first available. Used when rendering/indexing -// now that the flat mirror columns are gone. -export const resolveLangValue = ( - values: MultiLangValue | undefined, - languageCode: string, - defaultLanguageCode: null | string, -): string => - getLangValue(values, languageCode) || - pickDefaultValue(values ?? [], defaultLanguageCode); - -export const savePostTranslations = async ( - c: Context, - itemId: number, - translations: PostTranslations, -): Promise => { - await Promise.all( - POST_LANG_VARIABLES.map(async variable => { - await saveLanguageWords(c, { - pluginCode: CONFIG_PLUGIN.pluginId, - tableName: POST_LANG_TABLE, - variable, - itemId, - values: - variable === "friendlyUrl" - ? slugifyMultiLang(translations.friendlyUrl) - : translations[variable], - }); - }), - ); -}; - -export const loadPostTranslations = async ( - c: Context, - postIds: number[], -): Promise> => { - const result = new Map(); - if (postIds.length === 0) { - return result; - } - - const words = await c - .get("db") - .select({ - itemId: core_languages_words.itemId, - variable: core_languages_words.variable, - languageCode: core_languages_words.languageCode, - value: core_languages_words.value, - }) - .from(core_languages_words) - .where( - and( - eq(core_languages_words.pluginCode, CONFIG_PLUGIN.pluginId), - eq(core_languages_words.tableName, POST_LANG_TABLE), - inArray(core_languages_words.variable, [...POST_LANG_VARIABLES]), - inArray(core_languages_words.itemId, postIds), - ), - ); - - for (const id of postIds) { - result.set(id, { - title: words - .filter(word => word.itemId === id && word.variable === "title") - .map(({ languageCode, value }) => ({ languageCode, value })), - content: words - .filter(word => word.itemId === id && word.variable === "content") - .map(({ languageCode, value }) => ({ languageCode, value })), - friendlyUrl: words - .filter(word => word.itemId === id && word.variable === "friendlyUrl") - .map(({ languageCode, value }) => ({ languageCode, value })), - }); - } - - return result; -}; diff --git a/plugins/blog/src/api/lib/search.ts b/plugins/blog/src/api/lib/search.ts deleted file mode 100644 index bf70575e4..000000000 --- a/plugins/blog/src/api/lib/search.ts +++ /dev/null @@ -1,150 +0,0 @@ -import type { - SearchDocument, - SearchIndexer, -} from "@vitnode/core/api/models/search"; -import type { Context } from "hono"; - -import { asc, count } from "drizzle-orm"; - -import { blog_posts } from "@/database/posts"; - -import type { PostTranslations } from "./posts-language"; - -import { - getDefaultLanguageCode, - loadPostTranslations, - resolveLangValue, -} from "./posts-language"; - -interface BlogPostForSearch { - authorId: null | number; - categoryId: number; - createdAt: Date; - id: number; - updatedAt?: Date; -} - -const getEnabledLanguageCodes = (c: Context): string[] => - c - .get("core") - .i18n.locales.filter(locale => locale.enabled !== false) - .map(locale => locale.code); - -// One search document per enabled language: each language gets its own -// translation (falling back to the default-language mirror) and its own -// friendly URL, so search and discovery can be scoped to the viewer's locale. -const buildDocumentsForPost = ( - post: BlogPostForSearch, - languageCodes: string[], - translations: PostTranslations | undefined, - defaultLanguageCode: null | string, -): SearchDocument[] => { - const codes = languageCodes.length > 0 ? languageCodes : [""]; - - return codes.map(languageCode => { - const friendlyUrl = resolveLangValue( - translations?.friendlyUrl, - languageCode, - defaultLanguageCode, - ); - - return { - itemType: "blog_post", - itemId: post.id, - languageCode, - authorId: post.authorId, - title: resolveLangValue( - translations?.title, - languageCode, - defaultLanguageCode, - ), - content: resolveLangValue( - translations?.content, - languageCode, - defaultLanguageCode, - ), - containerType: "blog_category", - containerId: post.categoryId, - url: `/blog/${post.categoryId}/${friendlyUrl}`, - isPublic: true, - createdAt: post.createdAt, - updatedAt: post.updatedAt, - }; - }); -}; - -export const reindexBlogPost = async ( - c: Context, - post: BlogPostForSearch, -): Promise => { - const languageCodes = getEnabledLanguageCodes(c); - const [defaultLanguageCode, translations] = await Promise.all([ - getDefaultLanguageCode(c), - loadPostTranslations(c, [post.id]), - ]); - - // Drop every language row first so translations removed since the last index - // don't linger. - await c.get("search").delete("blog_post", post.id); - await c - .get("search") - .bulkIndex( - buildDocumentsForPost( - post, - languageCodes, - translations.get(post.id), - defaultLanguageCode, - ), - ); -}; - -export const blogPostSearchIndexer: SearchIndexer = { - itemType: "blog_post", - count: async c => { - const [row] = await c.get("db").select({ value: count() }).from(blog_posts); - - return row?.value ?? 0; - }, - load: async (c, offset, limit) => { - const rows = await c - .get("db") - .select({ - id: blog_posts.id, - categoryId: blog_posts.categoryId, - authorId: blog_posts.authorId, - createdAt: blog_posts.createdAt, - updatedAt: blog_posts.updatedAt, - }) - .from(blog_posts) - .orderBy(asc(blog_posts.id)) - .limit(limit) - .offset(offset); - - if (rows.length === 0) { - return { documents: [], itemsRead: 0 }; - } - - const languageCodes = getEnabledLanguageCodes(c); - const [defaultLanguageCode, translations] = await Promise.all([ - getDefaultLanguageCode(c), - loadPostTranslations( - c, - rows.map(row => row.id), - ), - ]); - - // One post emits one document per enabled language, so the document count is - // never the source count - `itemsRead` is what the rebuild pages by. - return { - documents: rows.flatMap(post => - buildDocumentsForPost( - post, - languageCodes, - translations.get(post.id), - defaultLanguageCode, - ), - ), - itemsRead: rows.length, - }; - }, -}; diff --git a/plugins/blog/src/api/modules/admin/admin.module.ts b/plugins/blog/src/api/modules/admin/admin.module.ts index 4113aca19..0435b25c0 100644 --- a/plugins/blog/src/api/modules/admin/admin.module.ts +++ b/plugins/blog/src/api/modules/admin/admin.module.ts @@ -1,17 +1,32 @@ import { buildModule } from "@vitnode/core/api/lib/module"; +import { buildContentAdminModule } from "@vitnode/core/content/server"; -import { CONFIG_PLUGIN } from "../../../const"; -import { cleanupCategorySearchListener } from "../../lib/events"; -import { categoriesAdminModule } from "./categories/categories.admin.module"; -import { postsAdminModule } from "./posts/posts.admin.module"; +import { blogLegacyEventListeners } from "@/api/lib/events"; +import { CONFIG_PLUGIN } from "@/const"; +import { categoryContent } from "@/database/categories"; +import { postContent } from "@/database/posts"; +/** + * Every admin route the blog has, generated from two content types. + * + * The generated content module is nested here rather than mounted by the engine: + * Hono serves only the last sub-app mounted at a prefix, so a second top-level + * `/admin` would silently shadow this one. + * + * Routes land at `/api/@vitnode/blog/admin/content/{posts,categories}`, and the + * staff permissions they check are the modules the blog has always used. + */ export const adminModule = buildModule({ pluginId: CONFIG_PLUGIN.pluginId, name: "admin", - modules: [categoriesAdminModule, postsAdminModule], routes: [], - // Event listeners are only collected from top-level modules (like cronJobs - // and queueTasks), so they are registered here rather than on the nested - // categories module. - events: [cleanupCategorySearchListener], + modules: [ + buildContentAdminModule({ + pluginId: CONFIG_PLUGIN.pluginId, + contentTypes: [categoryContent, postContent], + }), + ], + // Event listeners are only collected from top-level modules, so the + // compatibility adapters are registered here rather than on a nested one. + events: blogLegacyEventListeners, }); diff --git a/plugins/blog/src/api/modules/admin/categories/categories.admin.module.ts b/plugins/blog/src/api/modules/admin/categories/categories.admin.module.ts deleted file mode 100644 index 56c107aa0..000000000 --- a/plugins/blog/src/api/modules/admin/categories/categories.admin.module.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { buildModule } from "@vitnode/core/api/lib/module"; - -import { CONFIG_PLUGIN } from "../../../../const"; -import { createCategoryRoute } from "./routes/create.route"; -import { deleteCategoryRoute } from "./routes/delete.route"; -import { editCategoryRoute } from "./routes/edit.route"; - -export const categoriesAdminModule = buildModule({ - pluginId: CONFIG_PLUGIN.pluginId, - name: "categories", - routes: [createCategoryRoute, editCategoryRoute, deleteCategoryRoute], -}); diff --git a/plugins/blog/src/api/modules/admin/categories/routes/create.route.ts b/plugins/blog/src/api/modules/admin/categories/routes/create.route.ts deleted file mode 100644 index 7f452cf0b..000000000 --- a/plugins/blog/src/api/modules/admin/categories/routes/create.route.ts +++ /dev/null @@ -1,68 +0,0 @@ -import { z } from "@hono/zod-openapi"; -import { buildRoute } from "@vitnode/core/api/lib/route"; -import { multiLangValueSchema } from "@vitnode/core/lib/helpers/multi-lang"; - -import { CONFIG_PLUGIN } from "@/const"; -import { blog_categories } from "@/database/categories"; - -import { saveCategoryTranslations } from "../../../../lib/categories-language"; - -const zodCategoryResponseSchema = z.object({ - id: z.number(), - createdAt: z.date(), - updatedAt: z.date(), -}); - -export const zodCreateCategorySchema = z.object({ - title: multiLangValueSchema({ minLength: 1, maxLength: 100 }).min(1), - color: z.string().nullish(), -}); - -export const createCategoryRoute = buildRoute({ - pluginId: CONFIG_PLUGIN.pluginId, - adminStaffPermission: { module: "categories", permission: "can_create" }, - route: { - method: "post", - path: "/", - request: { - body: { - content: { - "application/json": { - schema: zodCreateCategorySchema, - }, - }, - }, - }, - responses: { - 201: { - content: { - "application/json": { - schema: zodCategoryResponseSchema, - }, - }, - description: "Category created successfully", - }, - 400: { - description: "Bad request - Invalid input data", - }, - }, - }, - handler: async c => { - const { title, color } = c.req.valid("json"); - const [category] = await c - .get("db") - .insert(blog_categories) - .values({ - color: color?.trim() ? color : null, - }) - .returning(); - - await saveCategoryTranslations(c, category.id, { title }); - - await c.get("events").emit("blog.category.created", { - categoryId: category.id, - }); - - return c.json(category, 201); - }, -}); diff --git a/plugins/blog/src/api/modules/admin/categories/routes/delete.route.ts b/plugins/blog/src/api/modules/admin/categories/routes/delete.route.ts deleted file mode 100644 index 8daa567e5..000000000 --- a/plugins/blog/src/api/modules/admin/categories/routes/delete.route.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { z } from "@hono/zod-openapi"; -import { buildRoute } from "@vitnode/core/api/lib/route"; -import { eq } from "drizzle-orm"; -import { HTTPException } from "hono/http-exception"; - -import { CONFIG_PLUGIN } from "@/const"; -import { blog_categories } from "@/database/categories"; -import { blog_posts } from "@/database/posts"; - -export const deleteCategoryRoute = buildRoute({ - pluginId: CONFIG_PLUGIN.pluginId, - adminStaffPermission: { module: "categories", permission: "can_delete" }, - route: { - method: "delete", - path: "/{id}", - request: { - params: z.object({ - id: z.string().transform(Number), - }), - }, - responses: { - 204: { - description: "Category deleted successfully", - }, - 404: { - description: "Category not found", - }, - }, - }, - handler: async c => { - const { id } = c.req.valid("param"); - - // Capture the posts the category's `onDelete: "cascade"` is about to - // remove, so listeners (e.g. search index cleanup) know what went away. - const posts = await c - .get("db") - .select({ id: blog_posts.id }) - .from(blog_posts) - .where(eq(blog_posts.categoryId, id)); - - const result = await c - .get("db") - .delete(blog_categories) - .where(eq(blog_categories.id, id)) - .returning(); - - if (result.length === 0) { - throw new HTTPException(404); - } - - await c.get("events").emit("blog.category.deleted", { - categoryId: id, - postIds: posts.map(post => post.id), - }); - - return c.body(null, 204); - }, -}); diff --git a/plugins/blog/src/api/modules/admin/categories/routes/edit.route.ts b/plugins/blog/src/api/modules/admin/categories/routes/edit.route.ts deleted file mode 100644 index 8b1228269..000000000 --- a/plugins/blog/src/api/modules/admin/categories/routes/edit.route.ts +++ /dev/null @@ -1,84 +0,0 @@ -import { z } from "@hono/zod-openapi"; -import { buildRoute } from "@vitnode/core/api/lib/route"; -import { eq } from "drizzle-orm"; -import { HTTPException } from "hono/http-exception"; - -import { CONFIG_PLUGIN } from "@/const"; -import { blog_categories } from "@/database/categories"; - -import { saveCategoryTranslations } from "../../../../lib/categories-language"; -import { zodCreateCategorySchema } from "./create.route"; - -const zodCategoryResponseSchema = z.object({ - id: z.number(), - createdAt: z.date(), - updatedAt: z.date(), -}); - -export const editCategoryRoute = buildRoute({ - pluginId: CONFIG_PLUGIN.pluginId, - adminStaffPermission: { module: "categories", permission: "can_edit" }, - route: { - method: "put", - path: "/{id}", - request: { - params: z.object({ - id: z.string().transform(Number), - }), - body: { - content: { - "application/json": { - schema: zodCreateCategorySchema, - }, - }, - }, - }, - responses: { - 200: { - content: { - "application/json": { - schema: zodCategoryResponseSchema, - }, - }, - description: "Category updated successfully", - }, - 400: { - description: "Bad request - Invalid input data", - }, - 404: { - description: "Category not found", - }, - }, - }, - handler: async c => { - const { id } = c.req.valid("param"); - const { title, color } = c.req.valid("json"); - const [editData] = await c - .get("db") - .select({ id: blog_categories.id }) - .from(blog_categories) - .where(eq(blog_categories.id, id)) - .limit(1); - - if (!editData) { - throw new HTTPException(404); - } - - const [category] = await c - .get("db") - .update(blog_categories) - .set({ - color: color?.trim() ? color : null, - }) - .where(eq(blog_categories.id, id)) - .returning(); - - await saveCategoryTranslations(c, id, { title }); - - await c.get("events").emit("blog.category.updated", { - categoryId: id, - }); - - return c.json(category); - }, -}); diff --git a/plugins/blog/src/api/modules/admin/posts/posts.admin.module.ts b/plugins/blog/src/api/modules/admin/posts/posts.admin.module.ts deleted file mode 100644 index c39aa207c..000000000 --- a/plugins/blog/src/api/modules/admin/posts/posts.admin.module.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { buildModule } from "@vitnode/core/api/lib/module"; - -import { CONFIG_PLUGIN } from "../../../../const"; -import { createPostRoute } from "./routes/create.route"; -import { deletePostRoute } from "./routes/delete.route"; -import { editPostRoute } from "./routes/edit.route"; - -export const postsAdminModule = buildModule({ - pluginId: CONFIG_PLUGIN.pluginId, - name: "posts", - routes: [editPostRoute, createPostRoute, deletePostRoute], -}); diff --git a/plugins/blog/src/api/modules/admin/posts/routes/create.route.ts b/plugins/blog/src/api/modules/admin/posts/routes/create.route.ts deleted file mode 100644 index c6321ca04..000000000 --- a/plugins/blog/src/api/modules/admin/posts/routes/create.route.ts +++ /dev/null @@ -1,133 +0,0 @@ -import { z } from "@hono/zod-openapi"; -import { buildRoute } from "@vitnode/core/api/lib/route"; -import { core_languages_words } from "@vitnode/core/database/languages"; -import { multiLangValueSchema } from "@vitnode/core/lib/helpers/multi-lang"; -import { and, eq, inArray } from "drizzle-orm"; -import { HTTPException } from "hono/http-exception"; - -import { CONFIG_PLUGIN } from "@/const"; -import { blog_categories } from "@/database/categories"; -import { blog_posts } from "@/database/posts"; - -import { - POST_LANG_TABLE, - savePostTranslations, - slugifyMultiLang, -} from "../../../../lib/posts-language"; -import { reindexBlogPost } from "../../../../lib/search"; - -const zodPostResponseSchema = z.object({ - id: z.number(), - categoryId: z.number(), - createdAt: z.date(), - updatedAt: z.date(), -}); - -export const zodCreatePostSchema = z.object({ - title: multiLangValueSchema({ minLength: 3, maxLength: 255 }).min(1), - content: multiLangValueSchema(), - friendlyUrl: multiLangValueSchema({ minLength: 1, maxLength: 255 }).min(1), - categoryId: z.number(), -}); - -export const createPostRoute = buildRoute({ - pluginId: CONFIG_PLUGIN.pluginId, - adminStaffPermission: { module: "posts", permission: "can_create" }, - route: { - method: "post", - path: "/", - request: { - body: { - content: { - "application/json": { - schema: zodCreatePostSchema, - }, - }, - }, - }, - responses: { - 201: { - content: { - "application/json": { - schema: zodPostResponseSchema, - }, - }, - description: "Post created successfully", - }, - 400: { - description: "Bad request - Invalid input data", - }, - 404: { - description: "Category not found", - }, - }, - }, - handler: async c => { - const { title, content, friendlyUrl, categoryId } = c.req.valid("json"); - const slugFriendlyUrl = slugifyMultiLang(friendlyUrl); - const friendlyUrlValues = [ - ...new Set(slugFriendlyUrl.map(item => item.value).filter(Boolean)), - ]; - - if (friendlyUrlValues.length === 0) { - throw new HTTPException(400, { - message: "Friendly URL is required.", - }); - } - - const [category] = await c - .get("db") - .select({ id: blog_categories.id }) - .from(blog_categories) - .where(eq(blog_categories.id, categoryId)) - .limit(1); - - if (!category) { - throw new HTTPException(404, { - message: "Category not found.", - }); - } - - // The friendly URL is the post's public slug and lives in - // `core_languages_words`; keep it globally unique so two posts can't resolve - // to the same URL. - const [duplicate] = await c - .get("db") - .select({ itemId: core_languages_words.itemId }) - .from(core_languages_words) - .where( - and( - eq(core_languages_words.pluginCode, CONFIG_PLUGIN.pluginId), - eq(core_languages_words.tableName, POST_LANG_TABLE), - eq(core_languages_words.variable, "friendlyUrl"), - inArray(core_languages_words.value, friendlyUrlValues), - ), - ) - .limit(1); - - if (duplicate) { - throw new HTTPException(400, { - message: "Post with this title already exists.", - }); - } - - const [post] = await c - .get("db") - .insert(blog_posts) - .values({ - categoryId, - authorId: c.get("admin")?.user.id ?? c.get("user")?.id ?? null, - }) - .returning(); - - await savePostTranslations(c, post.id, { title, content, friendlyUrl }); - await reindexBlogPost(c, post); - - await c.get("events").emit("blog.post.created", { - postId: post.id, - categoryId: post.categoryId, - }); - - return c.json(post, 201); - }, -}); diff --git a/plugins/blog/src/api/modules/admin/posts/routes/delete.route.ts b/plugins/blog/src/api/modules/admin/posts/routes/delete.route.ts deleted file mode 100644 index 6337e7c73..000000000 --- a/plugins/blog/src/api/modules/admin/posts/routes/delete.route.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { z } from "@hono/zod-openapi"; -import { buildRoute } from "@vitnode/core/api/lib/route"; -import { eq } from "drizzle-orm"; -import { HTTPException } from "hono/http-exception"; - -import { CONFIG_PLUGIN } from "@/const"; -import { blog_posts } from "@/database/posts"; - -export const deletePostRoute = buildRoute({ - pluginId: CONFIG_PLUGIN.pluginId, - adminStaffPermission: { module: "posts", permission: "can_delete" }, - route: { - method: "delete", - path: "/{id}", - request: { - params: z.object({ - id: z.string().transform(Number), - }), - }, - responses: { - 204: { - description: "Post deleted successfully", - }, - 404: { - description: "Post not found", - }, - }, - }, - handler: async c => { - const { id } = c.req.valid("param"); - - const result = await c - .get("db") - .delete(blog_posts) - .where(eq(blog_posts.id, id)) - .returning(); - - if (result.length === 0) { - throw new HTTPException(404); - } - - await c.get("search").delete("blog_post", id); - - await c.get("events").emit("blog.post.deleted", { - postId: id, - categoryId: result[0].categoryId, - }); - - return c.body(null, 204); - }, -}); diff --git a/plugins/blog/src/api/modules/admin/posts/routes/edit.route.ts b/plugins/blog/src/api/modules/admin/posts/routes/edit.route.ts deleted file mode 100644 index f3d80dc6d..000000000 --- a/plugins/blog/src/api/modules/admin/posts/routes/edit.route.ts +++ /dev/null @@ -1,139 +0,0 @@ -import { z } from "@hono/zod-openapi"; -import { buildRoute } from "@vitnode/core/api/lib/route"; -import { core_languages_words } from "@vitnode/core/database/languages"; -import { and, eq, inArray, ne } from "drizzle-orm"; -import { HTTPException } from "hono/http-exception"; - -import { CONFIG_PLUGIN } from "@/const"; -import { blog_categories } from "@/database/categories"; -import { blog_posts } from "@/database/posts"; - -import { - POST_LANG_TABLE, - savePostTranslations, - slugifyMultiLang, -} from "../../../../lib/posts-language"; -import { reindexBlogPost } from "../../../../lib/search"; -import { zodCreatePostSchema } from "./create.route"; - -const zodPostResponseSchema = z.object({ - id: z.number(), - categoryId: z.number(), - createdAt: z.date(), - updatedAt: z.date(), -}); - -export const editPostRoute = buildRoute({ - pluginId: CONFIG_PLUGIN.pluginId, - adminStaffPermission: { module: "posts", permission: "can_edit" }, - route: { - method: "put", - path: "/{id}", - request: { - params: z.object({ - id: z.string().transform(Number), - }), - body: { - content: { - "application/json": { - schema: zodCreatePostSchema, - }, - }, - }, - }, - responses: { - 200: { - content: { - "application/json": { - schema: zodPostResponseSchema, - }, - }, - description: "Post updated successfully", - }, - 400: { - description: "Bad request - Invalid input data", - }, - 404: { - description: "Post or category not found", - }, - }, - }, - handler: async c => { - const { id } = c.req.valid("param"); - const { title, content, friendlyUrl, categoryId } = c.req.valid("json"); - const slugFriendlyUrl = slugifyMultiLang(friendlyUrl); - const friendlyUrlValues = [ - ...new Set(slugFriendlyUrl.map(item => item.value).filter(Boolean)), - ]; - - if (friendlyUrlValues.length === 0) { - throw new HTTPException(400, { - message: "Friendly URL is required.", - }); - } - - const [existingPost] = await c - .get("db") - .select({ id: blog_posts.id }) - .from(blog_posts) - .where(eq(blog_posts.id, id)) - .limit(1); - - if (!existingPost) { - throw new HTTPException(404, { message: "Post not found." }); - } - - const [category] = await c - .get("db") - .select({ id: blog_categories.id }) - .from(blog_categories) - .where(eq(blog_categories.id, categoryId)) - .limit(1); - - if (!category) { - throw new HTTPException(404, { message: "Category not found." }); - } - - // Keep the friendly URL (the public slug, stored in `core_languages_words`) - // globally unique, excluding this post's own rows. - const [duplicate] = await c - .get("db") - .select({ itemId: core_languages_words.itemId }) - .from(core_languages_words) - .where( - and( - eq(core_languages_words.pluginCode, CONFIG_PLUGIN.pluginId), - eq(core_languages_words.tableName, POST_LANG_TABLE), - eq(core_languages_words.variable, "friendlyUrl"), - inArray(core_languages_words.value, friendlyUrlValues), - ne(core_languages_words.itemId, id), - ), - ) - .limit(1); - - if (duplicate) { - throw new HTTPException(400, { - message: "Post with this title already exists.", - }); - } - - const [post] = await c - .get("db") - .update(blog_posts) - .set({ - categoryId, - }) - .where(eq(blog_posts.id, id)) - .returning(); - - await savePostTranslations(c, id, { title, content, friendlyUrl }); - await reindexBlogPost(c, post); - - await c.get("events").emit("blog.post.updated", { - postId: post.id, - categoryId: post.categoryId, - }); - - return c.json(post); - }, -}); diff --git a/plugins/blog/src/api/modules/categories/categories.module.ts b/plugins/blog/src/api/modules/categories/categories.module.ts deleted file mode 100644 index 55b7f5271..000000000 --- a/plugins/blog/src/api/modules/categories/categories.module.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { buildModule } from "@vitnode/core/api/lib/module"; - -import { CONFIG_PLUGIN } from "@/const"; - -import { categoriesRoute } from "./routes/get.route"; - -export const categoriesModule = buildModule({ - pluginId: CONFIG_PLUGIN.pluginId, - name: "categories", - routes: [categoriesRoute], -}); diff --git a/plugins/blog/src/api/modules/categories/routes/get.route.ts b/plugins/blog/src/api/modules/categories/routes/get.route.ts deleted file mode 100644 index 189654ec6..000000000 --- a/plugins/blog/src/api/modules/categories/routes/get.route.ts +++ /dev/null @@ -1,137 +0,0 @@ -import { z } from "@hono/zod-openapi"; -import { buildRoute } from "@vitnode/core/api/lib/route"; -import { - withPagination, - zodPaginationPageInfo, - zodPaginationQuery, -} from "@vitnode/core/api/lib/with-pagination"; -import { core_languages_words } from "@vitnode/core/database/languages"; -import { multiLangValueSchema } from "@vitnode/core/lib/helpers/multi-lang"; -import { - and, - eq, - getTableColumns, - ilike, - inArray, - type SQL, -} from "drizzle-orm"; - -import { CONFIG_PLUGIN } from "@/const"; -import { blog_categories } from "@/database/categories"; - -import { - CATEGORY_LANG_TABLE, - CATEGORY_LANG_VARIABLE, - loadCategoryTranslations, -} from "../../../lib/categories-language"; - -const zodMultiLangValue = multiLangValueSchema(); - -export const zodCategorySchema = z.object({ - id: z.number(), - // The title lives in `core_languages_words`; the client resolves this array to - // the active locale (see `getLangValue`). - titleTranslations: zodMultiLangValue, - color: z.string().nullable(), - createdAt: z.date(), - updatedAt: z.date(), -}); - -export const categoriesRoute = buildRoute({ - pluginId: CONFIG_PLUGIN.pluginId, - route: { - method: "get", - path: "/", - request: { - query: zodPaginationQuery.extend({ - order: z.enum(["asc", "desc"]).optional(), - orderBy: z.enum(["updatedAt"]).optional(), - search: z.string().optional(), - }), - }, - responses: { - 200: { - content: { - "application/json": { - schema: z.object({ - edges: z.array(zodCategorySchema), - pageInfo: zodPaginationPageInfo, - }), - }, - }, - description: "Categories retrieved successfully", - }, - }, - }, - handler: async c => { - const query = c.req.valid("query"); - - const data = await withPagination({ - c, - params: { - query, - }, - primaryCursor: blog_categories.id, - query: async ({ cursorSelection, limit, where, orderBy }) => { - // The title lives in `core_languages_words`, so search resolves matching - // category ids from there rather than a column on `blog_categories`. - const searchCondition = query.search - ? inArray( - blog_categories.id, - c - .get("db") - .select({ id: core_languages_words.itemId }) - .from(core_languages_words) - .where( - and( - eq(core_languages_words.pluginCode, CONFIG_PLUGIN.pluginId), - eq(core_languages_words.tableName, CATEGORY_LANG_TABLE), - eq(core_languages_words.variable, CATEGORY_LANG_VARIABLE), - ilike(core_languages_words.value, `%${query.search}%`), - ), - ), - ) - : undefined; - - let combinedWhere: SQL | undefined; - if (searchCondition) { - if (where) { - combinedWhere = and(where, searchCondition); - } else { - combinedWhere = searchCondition; - } - } else { - combinedWhere = where; - } - - return await c - .get("db") - .select({ ...getTableColumns(blog_categories), ...cursorSelection }) - .from(blog_categories) - .where(combinedWhere) - .orderBy(orderBy) - .limit(limit); - }, - table: blog_categories, - orderBy: { - column: query.orderBy - ? blog_categories[query.orderBy] - : blog_categories.updatedAt, - order: query.order ?? "desc", - }, - }); - - const translations = await loadCategoryTranslations( - c, - data.edges.map(edge => edge.id), - ); - - return c.json({ - ...data, - edges: data.edges.map(edge => ({ - ...edge, - titleTranslations: translations.get(edge.id) ?? [], - })), - }); - }, -}); diff --git a/plugins/blog/src/api/modules/posts/posts.module.ts b/plugins/blog/src/api/modules/posts/posts.module.ts deleted file mode 100644 index 13e57ab11..000000000 --- a/plugins/blog/src/api/modules/posts/posts.module.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { buildModule } from "@vitnode/core/api/lib/module"; - -import { CONFIG_PLUGIN } from "@/const"; - -import { postsRoute } from "./routes/get.route"; - -export const postsModule = buildModule({ - pluginId: CONFIG_PLUGIN.pluginId, - name: "posts", - routes: [postsRoute], -}); diff --git a/plugins/blog/src/api/modules/posts/routes/get.route.ts b/plugins/blog/src/api/modules/posts/routes/get.route.ts deleted file mode 100644 index a50564630..000000000 --- a/plugins/blog/src/api/modules/posts/routes/get.route.ts +++ /dev/null @@ -1,150 +0,0 @@ -import { z } from "@hono/zod-openapi"; -import { buildRoute } from "@vitnode/core/api/lib/route"; -import { - withPagination, - zodPaginationPageInfo, - zodPaginationQuery, -} from "@vitnode/core/api/lib/with-pagination"; -import { core_users } from "@vitnode/core/database/users"; -import { multiLangValueSchema } from "@vitnode/core/lib/helpers/multi-lang"; -import { eq } from "drizzle-orm"; - -import { CONFIG_PLUGIN } from "@/const"; -import { blog_categories } from "@/database/categories"; -import { blog_posts } from "@/database/posts"; - -import { loadCategoryTranslations } from "../../../lib/categories-language"; -import { loadPostTranslations } from "../../../lib/posts-language"; - -const zodMultiLangValue = multiLangValueSchema(); - -export const zodPostSchema = z.object({ - id: z.number(), - // Every translated field lives in `core_languages_words`; the client resolves - // these arrays to the active locale (see `getLangValue` / `resolveLangValue`). - titleTranslations: zodMultiLangValue, - contentTranslations: zodMultiLangValue, - friendlyUrlTranslations: zodMultiLangValue, - categoryId: z.number(), - createdAt: z.date(), - updatedAt: z.date(), - category: z.object({ - id: z.number(), - titleTranslations: zodMultiLangValue, - }), - author: z - .object({ - id: z.number(), - name: z.string(), - nameCode: z.string(), - avatarColor: z.string(), - }) - .nullable(), -}); - -export const postsRoute = buildRoute({ - pluginId: CONFIG_PLUGIN.pluginId, - route: { - method: "get", - path: "/", - request: { - query: zodPaginationQuery.extend({ - order: z.enum(["asc", "desc"]).optional(), - orderBy: z.enum(["updatedAt", "createdAt"]).optional(), - categoryId: z.string().transform(Number).optional(), - }), - }, - responses: { - 200: { - content: { - "application/json": { - schema: z.object({ - edges: z.array(zodPostSchema), - pageInfo: zodPaginationPageInfo, - }), - }, - }, - description: "Posts retrieved successfully", - }, - }, - }, - handler: async c => { - const query = c.req.valid("query"); - - const data = await withPagination({ - c, - params: { - query, - }, - primaryCursor: blog_posts.id, - query: async ({ cursorSelection, limit, where, orderBy }) => - await c - .get("db") - .select({ - ...cursorSelection, - id: blog_posts.id, - categoryId: blog_posts.categoryId, - createdAt: blog_posts.createdAt, - updatedAt: blog_posts.updatedAt, - category: { - id: blog_categories.id, - }, - author: { - id: core_users.id, - name: core_users.name, - nameCode: core_users.nameCode, - avatarColor: core_users.avatarColor, - }, - }) - .from(blog_posts) - .innerJoin( - blog_categories, - eq(blog_posts.categoryId, blog_categories.id), - ) - .leftJoin(core_users, eq(core_users.id, blog_posts.authorId)) - .where( - query.categoryId - ? eq(blog_posts.categoryId, query.categoryId) - : where, - ) - .orderBy(orderBy) - .limit(limit), - table: blog_posts, - orderBy: { - column: query.orderBy - ? blog_posts[query.orderBy] - : blog_posts.updatedAt, - order: query.order ?? "desc", - }, - }); - - const [translations, categoryTranslations] = await Promise.all([ - loadPostTranslations( - c, - data.edges.map(edge => edge.id), - ), - loadCategoryTranslations( - c, - data.edges.map(edge => edge.category.id), - ), - ]); - - return c.json({ - ...data, - edges: data.edges.map(edge => { - const words = translations.get(edge.id); - - return { - ...edge, - titleTranslations: words?.title ?? [], - contentTranslations: words?.content ?? [], - friendlyUrlTranslations: words?.friendlyUrl ?? [], - category: { - ...edge.category, - titleTranslations: categoryTranslations.get(edge.category.id) ?? [], - }, - }; - }), - }); - }, -}); diff --git a/plugins/blog/src/config.api.ts b/plugins/blog/src/config.api.ts index 487f96d41..b8a76c463 100644 --- a/plugins/blog/src/config.api.ts +++ b/plugins/blog/src/config.api.ts @@ -1,33 +1,36 @@ import { buildApiPlugin } from "@vitnode/core/api/lib/plugin"; +import { buildContentPublicModule } from "@vitnode/core/content/server"; +import { adminModule } from "@/api/modules/admin/admin.module"; import { CONFIG_PLUGIN } from "@/const"; +import { categoryContent } from "@/database/categories"; +import { postContent } from "@/database/posts"; -import { blogPostSearchIndexer } from "./api/lib/search"; -import { adminModule } from "./api/modules/admin/admin.module"; -import { categoriesModule } from "./api/modules/categories/categories.module"; -import { postsModule } from "./api/modules/posts/posts.module"; - -export const blogApiPlugin = () => { - return buildApiPlugin({ +/** + * No `contentTypes` here: `buildApiPlugin` walks the module tree, so the content + * types declared in `admin.module.ts` also drive the registry and the derived + * `can_view` / `can_create` / `can_edit` / `can_delete` / `can_publish` / + * `can_restore` / `can_translate` permissions. + * + * No `searchIndexers` either. The article's `search` block is the indexer now - + * one document per published translation, written by the engine in the same + * transaction as the mutation that caused it. + * + * `permissionStaff` names the same two modules the blog always used, so an + * existing role's stored permissions still address the right thing. Only the + * generated additions - publish, restore, translate - are new, and a role that + * does not have them is denied by default. + */ +export const blogApiPlugin = () => + buildApiPlugin({ pluginId: CONFIG_PLUGIN.pluginId, - modules: [adminModule, categoriesModule, postsModule], - searchIndexers: [blogPostSearchIndexer], - permissionStaff: { - moderator: { - posts: ["can_edit", "can_delete"], - }, - admin: { - posts: [ - "can_view", - { - permission: "can_create", - dependsOn: ["can_view"], - }, - "can_edit", - "can_delete", - ], - categories: ["can_view", "can_create", "can_edit", "can_delete"], - }, - }, + modules: [ + adminModule, + buildContentPublicModule({ + pluginId: CONFIG_PLUGIN.pluginId, + // Skips any content type without `publicApi`, so the category + // contributes nothing - it has no public URL of its own. + contentTypes: [categoryContent, postContent], + }), + ], }); -}; diff --git a/plugins/blog/src/config.test-d.ts b/plugins/blog/src/config.test-d.ts new file mode 100644 index 000000000..a572874f4 --- /dev/null +++ b/plugins/blog/src/config.test-d.ts @@ -0,0 +1,51 @@ +import { contentTypeAdmin } from "@vitnode/core/lib/plugin"; +import { describe, expectTypeOf, it } from "vitest"; + +import { blogCategoryContentType } from "@/content/category"; +import { blogPostContentType } from "@/content/post"; + +/** + * What the frontend registration will and will not accept. + * + * The registration is checked against the definition's own field names, so a + * renamed field is a compile error at the override rather than an input that + * silently stops being overridden. + */ +describe("blog content admin registration", () => { + it("accepts overrides for fields the content type has", () => { + contentTypeAdmin({ + definition: blogCategoryContentType, + fields: { color: { component: () => null } }, + columns: { color: { cell: () => null } }, + }); + + contentTypeAdmin({ + definition: blogPostContentType, + fields: { content: { component: () => null } }, + forms: { layout: () => null }, + }); + }); + + it("refuses an override for a field that does not exist", () => { + contentTypeAdmin({ + definition: blogCategoryContentType, + // @ts-expect-error - the category has no `colour` + fields: { colour: { component: () => null } }, + }); + + contentTypeAdmin({ + definition: blogPostContentType, + // @ts-expect-error - the article's body is `content`, not `body` + fields: { body: { component: () => null } }, + }); + }); + + it("keeps the presentation modes literal", () => { + expectTypeOf(blogPostContentType.admin.create.mode).toEqualTypeOf< + "dialog" | "page" + >(); + expectTypeOf(blogCategoryContentType.admin.edit.mode).toEqualTypeOf< + "dialog" | "page" + >(); + }); +}); diff --git a/plugins/blog/src/config.tsx b/plugins/blog/src/config.tsx index 29f09e666..bed13177d 100644 --- a/plugins/blog/src/config.tsx +++ b/plugins/blog/src/config.tsx @@ -1,29 +1,56 @@ -import { buildPlugin } from "@vitnode/core/lib/plugin"; +import { buildPlugin, contentTypeAdmin } from "@vitnode/core/lib/plugin"; import { ListIcon, NotebookPenIcon } from "lucide-react"; import { CONFIG_PLUGIN } from "@/const"; +import { blogCategoryContentType } from "@/content/category"; +import { blogPostContentType } from "@/content/post"; +import { BlogArticleEditorField } from "@/views/admin/article/editor-field"; +import { BlogArticleFormLayout } from "@/views/admin/article/form-layout"; +import { BlogCategoryColorCell } from "@/views/admin/category/color-cell"; +import { BlogCategoryColorField } from "@/views/admin/category/color-field"; import messages from "./locales"; +/** + * The blog's entire frontend integration. + * + * Two content types, three component overrides and one layout - and that is the + * AdminCP: the nav items, the breadcrumbs, the list, the create and edit screens + * and the delete confirmation are all generated. No page under + * `src/routes/admin` renders a table any more, and no view calls a mutation. + * + * The overrides are the two escape hatches, one of each kind. `fields` replaces + * an input, `columns` replaces a table cell, and `forms.layout` replaces the + * arrangement of a whole form - never its behaviour. + */ export const blogPlugin = () => { return buildPlugin({ pluginId: CONFIG_PLUGIN.pluginId, messages, - admin: { - nav: [ - { - id: "posts", - href: "/admin/blog/posts", - icon: , - permission: { module: "posts", permission: "can_view" }, + contentTypes: [ + contentTypeAdmin({ + definition: blogPostContentType, + icon: , + fields: { + // The Tiptap editor, inside the same AutoForm as everything else. + content: { component: BlogArticleEditorField }, }, - { - id: "categories", - href: "/admin/blog/categories", - icon: , - permission: { module: "categories", permission: "can_view" }, + forms: { + // One layout for both actions - they are the same screen, and writing + // it twice is how two screens drift apart. + layout: BlogArticleFormLayout, }, - ], - }, + }), + contentTypeAdmin({ + definition: blogCategoryContentType, + icon: , + fields: { + color: { component: BlogCategoryColorField }, + }, + columns: { + color: { cell: BlogCategoryColorCell }, + }, + }), + ], }); }; diff --git a/plugins/blog/src/content/category.ts b/plugins/blog/src/content/category.ts new file mode 100644 index 000000000..6ca1c49b7 --- /dev/null +++ b/plugins/blog/src/content/category.ts @@ -0,0 +1,72 @@ +import { defineContentType, field } from "@vitnode/core/content"; + +/** + * Blog categories, as a Content Engine content type. + * + * The **simple** reference implementation: two fields, generated CRUD, and + * dialog create/edit. It exists to show that a small record needs no page-mode + * editor - and, between them, that `admin.create.mode` really is per content + * type rather than per install. + * + * `tableName` is deliberately the table the plugin has always used. The + * Content Engine's generated schema for this shape *is* `blog_categories` plus a + * translation table, so the migration adds rather than replaces: no ids move, no + * rows are copied between tables, and an install with categories keeps them. + * + * `name` is localized because it always was - the blog stored category titles in + * `core_languages_words`, one row per language - and it moves into + * `blog_categories_translations`, which is where the engine keeps the same idea. + * `color` is shared, because a colour is a property of the category and not of + * the language somebody is reading it in. + */ +export const blogCategoryContentType = defineContentType({ + id: "blog.category", + tableName: "blog_categories", + + localization: { + enabled: true, + // The language every category is first written in. `en` is the locale + // VitNode installs seed, and the boot guard says so loudly if an install + // does not have it rather than failing on the first write. + defaultLocale: "en", + fallback: "default", + }, + + fields: { + // The existing `varchar(50)` on `blog_categories`, unchanged. Rendered by + // the AdminCP's own colour picker through a frontend field override - the + // Content Engine has no `color` kind, and does not need one. + color: field.text({ maxLength: 50, nullable: true }), + name: field.text({ + localized: true, + required: true, + minLength: 1, + maxLength: 100, + }), + }, + + admin: { + label: { plural: "Categories", singular: "Category" }, + // The module the blog's staff permissions have always been stored under, so + // every existing role keeps exactly the access it had. + permissionModule: "categories", + /** + * `null` rather than left out, and the difference matters here. + * + * Every text field on this content type is localized, so there is no shared + * column that could honestly be a title. Left undefined the engine would + * pick the first shared text field - which is `color`, and a toast reading + * "#3260c0 has been deleted" is worse than no name at all. + */ + titleField: null, + // Dialogs, deliberately: a name and a colour do not need a page, and this is + // the half of the blog that proves page mode is opt-in. + create: { mode: "dialog" }, + edit: { mode: "dialog" }, + list: { + // Shared columns only - `name` lives on the translation table, and the + // list's locale selector is what shows it. + columns: ["color", "updatedAt"], + }, + }, +}); diff --git a/plugins/blog/src/content/content-types.test.ts b/plugins/blog/src/content/content-types.test.ts new file mode 100644 index 000000000..6820f3525 --- /dev/null +++ b/plugins/blog/src/content/content-types.test.ts @@ -0,0 +1,115 @@ +// @vitest-environment node +import { describe, expect, it } from "vitest"; + +import { blogCategoryContentType } from "./category"; +import { blogPostContentType } from "./post"; + +/** + * What the blog's two content types promise, stated as facts rather than as a + * snapshot of the descriptor. + * + * Every assertion here is something an install would notice if it changed: a + * table name, a permission module, a public URL, a presentation mode. They are + * the compatibility contract of the migration. + */ +describe("blog content types", () => { + describe("compatibility with the pre-migration plugin", () => { + it("keeps the table names, so no data has to move", () => { + expect(blogCategoryContentType.tableName).toBe("blog_categories"); + expect(blogPostContentType.tableName).toBe("blog_posts"); + }); + + it("keeps the column names of the relation and the author", () => { + expect(Object.keys(blogPostContentType.fields)).toContain("categoryId"); + expect(Object.keys(blogPostContentType.fields)).toContain("authorId"); + }); + + it("keeps the staff permission modules every role is stored against", () => { + expect(blogCategoryContentType.permissionModule).toBe("categories"); + expect(blogPostContentType.permissionModule).toBe("posts"); + }); + + it("keeps the public URL prefix", () => { + expect(blogPostContentType.publicApi.path).toBe("blog"); + }); + }); + + describe("the category, the simple example", () => { + it("creates and edits in a dialog", () => { + expect(blogCategoryContentType.admin.create.mode).toBe("dialog"); + expect(blogCategoryContentType.admin.edit.mode).toBe("dialog"); + }); + + it("keeps the colour shared and the name per language", () => { + expect(blogCategoryContentType.fields.color.localized).toBe(false); + expect(blogCategoryContentType.fields.name.localized).toBe(true); + }); + + it("has no shared title to guess at", () => { + // Left undefined the engine would pick `color`, and "#3260c0 has been + // deleted" is not a sentence anybody wants to read. + expect(blogCategoryContentType.admin.titleField).toBeNull(); + }); + + it("shows only shared columns in the list", () => { + expect(blogCategoryContentType.admin.list.columns).toEqual([ + "color", + "updatedAt", + ]); + }); + }); + + describe("the article, the rich example", () => { + it("creates and edits on a page", () => { + expect(blogPostContentType.admin.create.mode).toBe("page"); + expect(blogPostContentType.admin.edit.mode).toBe("page"); + }); + + it("relates to the category, and refuses to orphan an article", () => { + const relation = blogPostContentType.fields.categoryId; + + expect(relation.kind).toBe("relation"); + expect(relation.required).toBe(true); + expect(relation).toMatchObject({ onDelete: "restrict" }); + }); + + it("keeps the three translated fields per language", () => { + expect(blogPostContentType.fields.title.localized).toBe(true); + expect(blogPostContentType.fields.friendlyUrl.localized).toBe(true); + expect(blogPostContentType.fields.content.localized).toBe(true); + expect(blogPostContentType.localization.enabled).toBe(true); + }); + + it("derives the friendly URL from the title, per language", () => { + expect(blogPostContentType.fields.friendlyUrl).toMatchObject({ + kind: "slug", + source: "title", + }); + }); + + it("has publication and the editorial workflow", () => { + expect(blogPostContentType.publication.enabled).toBe(true); + expect(blogPostContentType.editorial.enabled).toBe(true); + expect(blogPostContentType.editorial.preview.enabled).toBe(true); + expect(blogPostContentType.editorial.scheduling.enabled).toBe(true); + }); + + it("indexes itself through the engine rather than a plugin indexer", () => { + expect(blogPostContentType.search.enabled).toBe(true); + expect(blogPostContentType.search.titleField).toBe("title"); + expect(blogPostContentType.search.pathTemplate).toBe( + "/{locale}/blog/{slug}", + ); + }); + + it("owns its public URLs through delivery, redirects included", () => { + expect(blogPostContentType.delivery.enabled).toBe(true); + expect(blogPostContentType.delivery.redirects.enabled).toBe(true); + expect(blogPostContentType.delivery.sitemap.enabled).toBe(true); + }); + + it("never exposes the author publicly", () => { + expect(blogPostContentType.publicApi.fields).not.toContain("authorId"); + }); + }); +}); diff --git a/plugins/blog/src/content/post.ts b/plugins/blog/src/content/post.ts new file mode 100644 index 000000000..8daa61c3d --- /dev/null +++ b/plugins/blog/src/content/post.ts @@ -0,0 +1,176 @@ +import { defineContentType, field } from "@vitnode/core/content"; + +import { blogCategoryContentType } from "./category"; + +/** + * Blog articles, as a Content Engine content type. + * + * The **rich** reference implementation: page-mode create and edit, a custom + * AdminCP layout, `AutoFormEditor` for the body, a native relation to the + * category, an author, publication, editorial history, search and delivery. + * Everything a real CMS entry needs, and not one line of bespoke CRUD. + * + * The id stays `blog.post` and the table stays `blog_posts`. "Article" is what + * the AdminCP calls it, because that is what people call it - but the content + * type id is part of the event names, the permission keys and the admin URL, and + * renaming a public contract for a nicer noun is churn with no payer. + * + * The field names are the column names the plugin already had: `categoryId` and + * `authorId`, not `category` and `author`. The engine names a column after its + * field, so keeping the field names keeps the columns, the foreign keys and + * their constraint names exactly where they are. + */ +export const blogPostContentType = defineContentType({ + id: "blog.post", + tableName: "blog_posts", + + localization: { + enabled: true, + defaultLocale: "en", + // A locale with no translation of its own is served the default language's, + // which is what the plugin's own `resolveLangValue` did by hand. + fallback: "default", + }, + + /** + * Draft and published, which the blog did not have and now does. + * + * Every article that exists today is publicly readable - the old public route + * returned every row and the search index marked every document + * `isPublic: true` - so the migration backfills them all as published, with + * `publishedAt` set to `createdAt`. That is the one publication fact the old + * schema can actually prove; nothing else about their history is invented. + */ + publication: { enabled: true }, + + /** + * Versions, revisions, preview links and scheduling. + * + * `editorial` is also what `delivery.redirects` is gated on: slug history has + * to be written in the same transaction as the slug change, and only the + * editorial mutation paths own such a transaction. An article's URL is the + * thing most worth not breaking, so both are on. + */ + editorial: { + enabled: true, + revisions: { retention: 20 }, + preview: { enabled: true, expiresInMinutes: 30 }, + scheduling: { enabled: true }, + }, + + fields: { + // Shared: which category an article is in, and who wrote it, are properties + // of the article rather than of a language. + categoryId: field.relation({ + required: true, + // Postgres itself refuses to delete a category that still has articles, + // which is what the plugin's own delete route was trying to be careful + // about with a `SELECT` first. + onDelete: "restrict", + target: () => blogCategoryContentType, + }), + authorId: field.user(), + + // Localized: exactly the three variables the plugin kept in + // `core_languages_words`. + title: field.text({ + localized: true, + required: true, + minLength: 3, + maxLength: 255, + }), + // Derived from the localized title, per language - which is what + // `TitleField` did in the browser, except the engine also keeps it unique + // per language and remembers the addresses it has retired. + friendlyUrl: field.slug({ + localized: true, + maxLength: 255, + source: "title", + }), + content: field.textarea({ localized: true, required: true }), + }, + + /** + * The public read layer. + * + * `path: "blog"` keeps the public URL shape the plugin already published under + * - `/blog/...` - now as a canonical delivery address the engine owns. + * + * `authorId` is **not** exposed, and cannot be: a `user` field is not one of + * the publicly exposable kinds, because publishing a staff account's display + * name is a decision the core users table gets to make rather than a side + * effect of an article having an author. + */ + publicApi: { + enabled: true, + path: "blog", + fields: [ + // Delivery resolves localized alternates by identifier, so a localized + // delivery content type that withheld `id` would carry an empty alternate + // set. + "id", + "title", + "friendlyUrl", + "content", + "categoryId", + "publishedAt", + ], + searchableFields: ["title", "content"], + // Shared columns only: a list ordered by a localized title would reshuffle + // itself per language, and a cursor would mean two positions at once. + orderableFields: ["publishedAt"], + filterableFields: ["categoryId", "friendlyUrl"], + defaultOrderBy: "publishedAt", + defaultOrder: "desc", + }, + + /** + * One search document per published translation. + * + * Replaces `api/lib/search.ts` entirely. That file emitted one document per + * *enabled language* whether or not a translation existed, falling back to the + * default language's copy - so a Polish search could return an English article + * at a Polish URL. The engine indexes translations that actually exist, which + * is both less code and a better answer. + */ + search: { + enabled: true, + titleField: "title", + contentFields: ["title", "content"], + pathTemplate: "/{locale}/blog/{slug}", + }, + + /** + * Canonical URLs, slug history, redirects, SEO and the sitemap. + * + * `redirects` is the reason the old friendly-URL uniqueness check is gone: + * the engine reserves every address an article has ever been published at, so + * renaming one 308s the old URL instead of leaving it dead - and a second + * article cannot quietly claim an address the first one still redirects from. + */ + delivery: { + enabled: true, + redirects: { enabled: true }, + seo: { titleField: "title", descriptionField: "content" }, + sitemap: { enabled: true, changeFrequency: "weekly", priority: 0.7 }, + hreflang: { xDefault: "defaultLocale" }, + }, + + indexes: [{ on: ["status", "createdAt"] }], + + admin: { + // "Article" in the AdminCP, `blog.post` in the database and the API. + label: { plural: "Articles", singular: "Article" }, + permissionModule: "posts", + // Same reason as the category: every text field here is localized, so there + // is no shared column that could honestly be the title. + titleField: null, + // The page-mode reference. Both actions, so a create hands straight over to + // the article's own edit page. + create: { mode: "page" }, + edit: { mode: "page" }, + list: { + columns: ["status", "categoryId", "authorId", "publishedAt", "updatedAt"], + }, + }, +}); diff --git a/plugins/blog/src/database/categories.ts b/plugins/blog/src/database/categories.ts index 00a900dbd..3cdc05b46 100644 --- a/plugins/blog/src/database/categories.ts +++ b/plugins/blog/src/database/categories.ts @@ -1,11 +1,11 @@ -import { pgTable } from "drizzle-orm/pg-core"; +import { createContentModel } from "@vitnode/core/content/server"; -export const blog_categories = pgTable("blog_categories", t => ({ - id: t.serial().primaryKey(), - color: t.varchar({ length: 50 }), - createdAt: t.timestamp().notNull().defaultNow(), - updatedAt: t - .timestamp() - .notNull() - .$onUpdate(() => new Date()), -})).enableRLS(); +import { blogCategoryContentType } from "@/content/category"; + +export const categoryContent = createContentModel(blogCategoryContentType); + +// 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 blog_categories = categoryContent.table; +export const blog_categories_translations = categoryContent.translationTable; diff --git a/plugins/blog/src/database/harness.ts b/plugins/blog/src/database/harness.ts new file mode 100644 index 000000000..14c519c08 --- /dev/null +++ b/plugins/blog/src/database/harness.ts @@ -0,0 +1,312 @@ +import type { SearchDocument } from "@vitnode/core/api/models/search"; +import type { Context } from "hono"; + +import { drizzle } from "drizzle-orm/postgres-js"; +import { readFileSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import postgres from "postgres"; + +import { CONFIG_PLUGIN } from "@/const"; + +import { categoryContent } from "./categories"; +import { postContent } from "./posts"; + +/** + * A real Postgres fixture for the blog's migration onto the Content Engine. + * + * It starts from the schema an **existing install** actually has - the blog's + * own two tables, with its text in `core_languages_words` - and then runs the + * committed migration over it. That is the only way to test the thing that + * matters here: not that a fresh install gets the right tables, but that an + * install with articles in it still has them afterwards. + */ + +export const DATABASE_TEST_URL = process.env.DATABASE_TEST_URL; + +const databaseName = (() => { + if (!DATABASE_TEST_URL) return ""; + try { + return new URL(DATABASE_TEST_URL).pathname.replace(/^\//, ""); + } catch { + return ""; + } +})(); + +const here = dirname(fileURLToPath(import.meta.url)); + +/** The migration under test, read from the app that ships it. */ +export const readMigration = (file: string): string => + readFileSync(resolve(here, "../../../../apps/docs/migrations", file), "utf8"); + +export const BLOG_MIGRATION = "0035_migrate_blog_to_content_engine.sql"; + +/** + * The core tables the blog and the engine touch, stubbed to the columns they + * use. + * + * Core's own migration history is not replayed: one of its migrations builds a + * full-text column from per-language text-search configurations a stock Postgres + * image does not ship, and none of that has anything to do with the blog. + */ +const CORE_STUBS = ` + CREATE TABLE "core_users" ( + "id" serial PRIMARY KEY NOT NULL, + "name" varchar(255) NOT NULL + ); + CREATE TABLE "core_languages" ( + "id" serial PRIMARY KEY NOT NULL, + "code" varchar(32) NOT NULL, + "name" varchar(255) NOT NULL, + "default" boolean DEFAULT false NOT NULL, + "protected" boolean DEFAULT false NOT NULL, + CONSTRAINT "core_languages_code_unique" UNIQUE("code") + ); + CREATE TABLE "core_languages_words" ( + "id" serial PRIMARY KEY NOT NULL, + "pluginCode" varchar(255) NOT NULL, + "tableName" varchar(255) NOT NULL, + "variable" varchar(255) NOT NULL, + "itemId" integer NOT NULL, + "languageCode" varchar(32) NOT NULL, + "value" text NOT NULL + ); + CREATE TABLE "core_queue" ( + "id" serial PRIMARY KEY NOT NULL, + "pluginId" varchar(255) NOT NULL, + "name" varchar(100) NOT NULL, + "queue" varchar(100) DEFAULT 'default' NOT NULL, + "status" varchar(20) DEFAULT 'pending' NOT NULL, + "payload" jsonb DEFAULT '{}'::jsonb NOT NULL, + "priority" integer DEFAULT 0 NOT NULL, + "attempts" integer DEFAULT 0 NOT NULL, + "maxAttempts" integer DEFAULT 3 NOT NULL, + "availableAt" timestamp DEFAULT now() NOT NULL, + "reservedAt" timestamp, + "lastError" text, + "createdAt" timestamp DEFAULT now() NOT NULL, + "updatedAt" timestamp DEFAULT now() NOT NULL, + "completedAt" timestamp + ); + CREATE TABLE "core_content_revisions" ( + "id" serial PRIMARY KEY NOT NULL, + "contentTypeId" varchar(255) NOT NULL, + "itemId" integer NOT NULL, + "languageId" integer, + "version" integer NOT NULL, + "operation" varchar(32) NOT NULL, + "snapshot" jsonb DEFAULT '{}'::jsonb NOT NULL, + "changedFields" jsonb DEFAULT '[]'::jsonb NOT NULL, + "actorType" varchar(32) NOT NULL, + "actorUserId" integer, + "createdAt" timestamp DEFAULT now() NOT NULL + ); + CREATE TABLE "core_content_slug_history" ( + "id" serial PRIMARY KEY NOT NULL, + "contentTypeId" varchar(255) NOT NULL, + "itemId" integer NOT NULL, + "languageId" integer, + "path" varchar(512) NOT NULL, + "slug" varchar(255) NOT NULL, + "createdAt" timestamp DEFAULT now() NOT NULL, + CONSTRAINT "core_content_slug_history_path_key" UNIQUE("path") + ); + CREATE TABLE "core_content_schedules" ( + "id" serial PRIMARY KEY NOT NULL, + "contentTypeId" varchar(255) NOT NULL, + "itemId" integer NOT NULL, + "action" varchar(32) NOT NULL, + "scheduledFor" timestamp NOT NULL, + "status" varchar(32) DEFAULT 'pending' NOT NULL, + "actorUserId" integer, + "queueId" integer, + "lastError" text, + "effectsError" text, + "createdAt" timestamp DEFAULT now() NOT NULL, + "updatedAt" timestamp DEFAULT now() NOT NULL, + "completedAt" timestamp + ); + CREATE TABLE "core_search_index" ( + "id" serial PRIMARY KEY NOT NULL, + "pluginId" varchar(255) NOT NULL, + "itemType" varchar(100) NOT NULL, + "itemId" integer NOT NULL, + "languageCode" varchar(32) DEFAULT '' NOT NULL, + "authorId" integer, + "title" text NOT NULL, + "content" text NOT NULL, + "containerType" varchar(100), + "containerId" integer, + "url" text, + "isPublic" boolean DEFAULT true NOT NULL, + "metadata" jsonb DEFAULT '{}'::jsonb NOT NULL, + "createdAt" timestamp NOT NULL, + "updatedAt" timestamp, + "indexedAt" timestamp DEFAULT now() NOT NULL, + CONSTRAINT "core_search_index_item_key" + UNIQUE("itemType", "itemId", "languageCode") + ); +`; + +/** + * The blog exactly as it shipped before this migration. + * + * Copied from `plugins/blog/src/database/{categories,posts}.ts` as they were: + * two tables with no text on them at all, because every translated value lived + * in `core_languages_words`. This is what an install upgrading today looks like. + */ +export const LEGACY_BLOG_SCHEMA = ` + CREATE TABLE "blog_categories" ( + "id" serial PRIMARY KEY NOT NULL, + "color" varchar(50), + "createdAt" timestamp DEFAULT now() NOT NULL, + "updatedAt" timestamp NOT NULL + ); + CREATE TABLE "blog_posts" ( + "id" serial PRIMARY KEY NOT NULL, + "categoryId" integer NOT NULL, + "authorId" integer, + "createdAt" timestamp DEFAULT now() NOT NULL, + "updatedAt" timestamp NOT NULL, + CONSTRAINT "blog_posts_categoryId_blog_categories_id_fk" + FOREIGN KEY ("categoryId") REFERENCES "blog_categories"("id"), + CONSTRAINT "blog_posts_authorId_core_users_id_fk" + FOREIGN KEY ("authorId") REFERENCES "core_users"("id") + ON DELETE SET NULL ON UPDATE CASCADE + ); +`; + +export const ACTOR = { type: "staff" as const, userId: null }; + +export interface RecordedEvent { + name: string; + payload: unknown; +} + +export interface BlogTestHarness { + context: Context; + db: ReturnType; + deleted: { itemId: number; itemType: string; locale?: string }[]; + emitted: RecordedEvent[]; + end: () => Promise; + indexed: SearchDocument[]; + /** Runs a script one statement per `--> statement-breakpoint`. */ + migrate: (script: string) => Promise; + reset: () => void; + sql: ReturnType; +} + +export const createBlogTestHarness = async (): Promise => { + if (!DATABASE_TEST_URL) throw new Error("DATABASE_TEST_URL is not set."); + if (!/test/i.test(databaseName)) { + throw new Error( + `DATABASE_TEST_URL points at "${databaseName || DATABASE_TEST_URL}". This suite wipes the database it runs against, so its name must contain "test".`, + ); + } + + const sql = postgres(DATABASE_TEST_URL, { + max: 1, + onnotice: () => undefined, + }); + + await sql.unsafe(` + DROP SCHEMA IF EXISTS public CASCADE; + CREATE SCHEMA public; + `); + await sql.unsafe(CORE_STUBS); + await sql` + INSERT INTO "core_languages" ("code", "name", "default") VALUES + ('en', 'English', true), + ('pl', 'Polski', false) + `; + + const migrate = async (script: string): Promise => { + for (const statement of script.split("--> statement-breakpoint")) { + const trimmed = statement.trim(); + if (trimmed) await sql.unsafe(trimmed); + } + }; + + const db = drizzle(sql, { casing: "camelCase" }); + const indexed: SearchDocument[] = []; + const deleted: BlogTestHarness["deleted"] = []; + const emitted: RecordedEvent[] = []; + + const context = { + get: (key: string) => { + if (key === "db") return db; + if (key === "search") { + return { + countDocuments: async () => await Promise.resolve(0), + isCanonicalStorage: () => true, + name: () => "postgres", + delete: async (itemType: string, itemId: number, locale?: string) => { + deleted.push({ itemId, itemType, locale }); + + return await Promise.resolve(); + }, + index: async (document: SearchDocument) => { + indexed.push(document); + + return await Promise.resolve(); + }, + }; + } + if (key === "events") { + return { + emit: async (name: string, payload: unknown) => { + emitted.push({ name, payload }); + + return await Promise.resolve({ + delivered: 1, + eventId: `event-${emitted.length}`, + failures: [], + status: "delivered" as const, + }); + }, + }; + } + if (key === "log") { + return { error: async () => await Promise.resolve() }; + } + if (key === "core") { + return { + contentRevalidateOrigins: [], + cronSecret: "blog-test-secret", + hasCronAdapter: false, + contentModels: [ + { model: categoryContent, pluginId: CONFIG_PLUGIN.pluginId }, + { model: postContent, pluginId: CONFIG_PLUGIN.pluginId }, + ], + i18n: { + locales: [ + { code: "en", name: "English" }, + { code: "pl", name: "Polski" }, + ], + }, + searchIndexers: [], + }; + } + + return undefined; + }, + } as unknown as Context; + + return { + context, + db, + deleted, + emitted, + end: async () => { + await sql.end(); + }, + indexed, + migrate, + reset: () => { + indexed.length = 0; + deleted.length = 0; + emitted.length = 0; + }, + sql, + }; +}; diff --git a/plugins/blog/src/database/index.ts b/plugins/blog/src/database/index.ts index 4c9d269a8..79630b5aa 100644 --- a/plugins/blog/src/database/index.ts +++ b/plugins/blog/src/database/index.ts @@ -1,6 +1,3 @@ // Tables export * from "./categories"; export * from "./posts"; - -// Relations -export * from "./relations"; diff --git a/plugins/blog/src/database/migration-postgres.test.ts b/plugins/blog/src/database/migration-postgres.test.ts new file mode 100644 index 000000000..296e6fdc8 --- /dev/null +++ b/plugins/blog/src/database/migration-postgres.test.ts @@ -0,0 +1,368 @@ +import { afterAll, beforeAll, describe, expect, it } from "vitest"; + +import type { BlogTestHarness } from "./harness"; + +import { categoryContent } from "./categories"; +import { + BLOG_MIGRATION, + createBlogTestHarness, + DATABASE_TEST_URL, + LEGACY_BLOG_SCHEMA, + readMigration, +} from "./harness"; +import { postContent } from "./posts"; + +/** + * The blog's move onto the Content Engine, run over a database that already has + * a blog in it. + * + * The seed below is a pre-migration install: two categories with different + * colours, three articles across them, an author, rich bodies, existing slugs + * and Polish translations of one article - all stored the way the plugin used to + * store them, in `core_languages_words`. Everything after `migrate` reads the + * same data back through the **Content Engine's** services, which is the only + * proof that matters: the rows did not merely survive, they arrived somewhere + * the new code can actually see. + */ + +let h: BlogTestHarness; + +const seed = async (): Promise<{ + authorId: number; + categoryIds: number[]; + postIds: number[]; +}> => { + const [author] = await h.sql<{ id: number }[]>` + INSERT INTO "core_users" ("name") VALUES ('Ada Lovelace') RETURNING "id" + `; + + const categories = await h.sql<{ id: number }[]>` + INSERT INTO "blog_categories" ("color", "createdAt", "updatedAt") VALUES + ('#3260c0', '2024-01-01 10:00:00', '2024-01-02 10:00:00'), + (NULL, '2024-01-03 10:00:00', '2024-01-04 10:00:00') + RETURNING "id" + `; + + const posts = await h.sql<{ id: number }[]>` + INSERT INTO "blog_posts" ("categoryId", "authorId", "createdAt", "updatedAt") + VALUES + (${categories[0].id}, ${author.id}, '2024-02-01 09:00:00', '2024-02-05 09:00:00'), + (${categories[0].id}, NULL, '2024-02-02 09:00:00', '2024-02-06 09:00:00'), + (${categories[1].id}, ${author.id}, '2024-02-03 09:00:00', '2024-02-07 09:00:00') + RETURNING "id" + `; + + const word = ( + tableName: string, + variable: string, + itemId: number, + languageCode: string, + value: string, + ) => ({ + itemId, + languageCode, + pluginCode: "@vitnode/blog", + tableName, + value, + variable, + }); + + await h.sql` + INSERT INTO "core_languages_words" + ${h.sql([ + word("blog_categories", "title", categories[0].id, "en", "Engineering"), + word("blog_categories", "title", categories[0].id, "pl", "InĆŒynieria"), + word("blog_categories", "title", categories[1].id, "en", "Culture"), + + word("blog_posts", "title", posts[0].id, "en", "Hello world"), + word( + "blog_posts", + "content", + posts[0].id, + "en", + "

The first article.

", + ), + word("blog_posts", "friendlyUrl", posts[0].id, "en", "hello-world"), + word("blog_posts", "title", posts[0].id, "pl", "Witaj ƛwiecie"), + word( + "blog_posts", + "content", + posts[0].id, + "pl", + "

Pierwszy artykuƂ.

", + ), + word("blog_posts", "friendlyUrl", posts[0].id, "pl", "witaj-swiecie"), + + word("blog_posts", "title", posts[1].id, "en", "Second article"), + word("blog_posts", "content", posts[1].id, "en", "

Body two.

"), + word("blog_posts", "friendlyUrl", posts[1].id, "en", "second-article"), + + // Only Polish, and no English at all - the record the default-locale + // backfill has to rescue rather than leave without a translation. + word("blog_posts", "title", posts[2].id, "pl", "Tylko po polsku"), + word("blog_posts", "content", posts[2].id, "pl", "

Trzeci.

"), + word("blog_posts", "friendlyUrl", posts[2].id, "pl", "tylko-po-polsku"), + ])} + `; + + return { + authorId: author.id, + categoryIds: categories.map(row => row.id), + postIds: posts.map(row => row.id), + }; +}; + +describe.skipIf(!DATABASE_TEST_URL)("blog -> Content Engine migration", () => { + let seeded: Awaited>; + + beforeAll(async () => { + h = await createBlogTestHarness(); + await h.migrate(LEGACY_BLOG_SCHEMA); + seeded = await seed(); + await h.migrate(readMigration(BLOG_MIGRATION)); + }, 60_000); + + afterAll(async () => { + await h.end(); + }); + + describe("the records themselves", () => { + it("keeps every category, its id and its colour", async () => { + const rows = await h.sql<{ color: null | string; id: number }[]>` + SELECT "id", "color" FROM "blog_categories" ORDER BY "id" + `; + + expect(rows).toEqual([ + { color: "#3260c0", id: seeded.categoryIds[0] }, + { color: null, id: seeded.categoryIds[1] }, + ]); + }); + + it("keeps every article, its id, its category and its author", async () => { + const rows = await h.sql< + { authorId: null | number; categoryId: number; id: number }[] + >` + SELECT "id", "categoryId", "authorId" FROM "blog_posts" ORDER BY "id" + `; + + expect(rows).toEqual([ + { + authorId: seeded.authorId, + categoryId: seeded.categoryIds[0], + id: seeded.postIds[0], + }, + { + authorId: null, + categoryId: seeded.categoryIds[0], + id: seeded.postIds[1], + }, + { + authorId: seeded.authorId, + categoryId: seeded.categoryIds[1], + id: seeded.postIds[2], + }, + ]); + }); + + it("keeps the timestamps rather than stamping the migration's own", async () => { + const [row] = await h.sql<{ createdAt: string; updatedAt: string }[]>` + SELECT "createdAt", "updatedAt" FROM "blog_posts" + WHERE "id" = ${seeded.postIds[0]} + `; + + expect(row.createdAt).toContain("2024-02-01"); + expect(row.updatedAt).toContain("2024-02-05"); + }); + }); + + describe("publication", () => { + it("publishes every article that was publicly readable before", async () => { + const rows = await h.sql< + { publishedAt: null | string; status: string }[] + >`SELECT "status", "publishedAt" FROM "blog_posts" ORDER BY "id"`; + + expect(rows.map(row => row.status)).toEqual([ + "published", + "published", + "published", + ]); + expect(rows.every(row => row.publishedAt !== null)).toBe(true); + }); + + it("dates the publication from the record rather than from the upgrade", async () => { + const [row] = await h.sql<{ publishedAt: string }[]>` + SELECT "publishedAt" FROM "blog_posts" WHERE "id" = ${seeded.postIds[0]} + `; + + expect(row.publishedAt).toContain("2024-02-01"); + }); + + it("starts every record at version 1, inventing no history", async () => { + const [{ versions }] = await h.sql<{ versions: number[] }[]>` + SELECT array_agg(DISTINCT "version") AS versions FROM "blog_posts" + `; + const [{ count }] = await h.sql<{ count: number }[]>` + SELECT count(*)::int AS count FROM "core_content_revisions" + `; + + expect(versions).toEqual([1]); + expect(count).toBe(0); + }); + }); + + describe("translations", () => { + it("moves every language of an article into the translation table", async () => { + const rows = await h.sql< + { + content: string; + friendlyUrl: string; + locale: string; + title: string; + }[] + >` + SELECT l."code" AS locale, t."title", t."friendlyUrl", t."content" + FROM "blog_posts_translations" t + JOIN "core_languages" l ON l."id" = t."languageId" + WHERE t."itemId" = ${seeded.postIds[0]} + ORDER BY l."code" + `; + + expect(rows).toEqual([ + { + content: "

The first article.

", + friendlyUrl: "hello-world", + locale: "en", + title: "Hello world", + }, + { + content: "

Pierwszy artykuƂ.

", + friendlyUrl: "witaj-swiecie", + locale: "pl", + title: "Witaj ƛwiecie", + }, + ]); + }); + + it("does not invent a translation for a language nobody wrote", async () => { + const rows = await h.sql<{ locale: string }[]>` + SELECT l."code" AS locale + FROM "blog_categories_translations" t + JOIN "core_languages" l ON l."id" = t."languageId" + WHERE t."itemId" = ${seeded.categoryIds[1]} + `; + + // Only the English title existed, and the default-locale backfill has + // nothing to add on top of it. + expect(rows).toEqual([{ locale: "en" }]); + }); + + it("gives a record with no default-locale translation one built from what it has", async () => { + const rows = await h.sql<{ locale: string; title: string }[]>` + SELECT l."code" AS locale, t."title" + FROM "blog_posts_translations" t + JOIN "core_languages" l ON l."id" = t."languageId" + WHERE t."itemId" = ${seeded.postIds[2]} + ORDER BY l."code" + `; + + expect(rows).toEqual([ + { locale: "en", title: "Tylko po polsku" }, + { locale: "pl", title: "Tylko po polsku" }, + ]); + }); + + it("keeps the category names, in every language they had", async () => { + const rows = await h.sql<{ locale: string; name: string }[]>` + SELECT l."code" AS locale, t."name" + FROM "blog_categories_translations" t + JOIN "core_languages" l ON l."id" = t."languageId" + WHERE t."itemId" = ${seeded.categoryIds[0]} + ORDER BY l."code" + `; + + expect(rows).toEqual([ + { locale: "en", name: "Engineering" }, + { locale: "pl", name: "InĆŒynieria" }, + ]); + }); + + it("empties the storage it migrated out of", async () => { + const [{ count }] = await h.sql<{ count: number }[]>` + SELECT count(*)::int AS count FROM "core_languages_words" + WHERE "pluginCode" = '@vitnode/blog' + `; + + expect(count).toBe(0); + }); + }); + + describe("read back through the Content Engine", () => { + it("lists the articles the AdminCP list would show", async () => { + const { edges, pageInfo } = await postContent + .service(h.context) + .findMany(); + + expect(pageInfo.totalCount).toBe(3); + expect(edges.map(edge => edge.id).sort((a, b) => a - b)).toEqual( + seeded.postIds, + ); + }); + + it("reads one article the way the edit page does", async () => { + const row = await postContent + .service(h.context) + .findRowById(seeded.postIds[0]); + + expect(row?.categoryId).toBe(seeded.categoryIds[0]); + expect(row?.authorId).toBe(seeded.authorId); + expect(row?.status).toBe("published"); + }); + + it("reads a translation the way the locale tab does", async () => { + const translation = await postContent + .translationService?.(h.context) + .findByLocale(seeded.postIds[0], "pl"); + + expect(translation?.locale).toBe("pl"); + expect(translation?.status).toBe("published"); + expect(translation?.values).toEqual({ + content: "

Pierwszy artykuƂ.

", + friendlyUrl: "witaj-swiecie", + title: "Witaj ƛwiecie", + }); + }); + + it("keeps the category relation usable as a relation", async () => { + const rows = await h.sql<{ count: number }[]>` + SELECT count(*)::int AS count FROM "blog_posts" + WHERE "categoryId" = ${seeded.categoryIds[0]} + `; + + expect(rows[0].count).toBe(2); + }); + + it("refuses to delete a category that still has articles", async () => { + let code: string | undefined; + try { + await h.sql` + DELETE FROM "blog_categories" WHERE "id" = ${seeded.categoryIds[0]} + `; + } catch (error) { + const cause = (error as { cause?: { code?: string } }).cause; + code = cause?.code ?? (error as { code?: string }).code; + } + + expect(code).toBe("23503"); + }); + + it("edits a migrated article through the engine and nothing else", async () => { + const service = categoryContent.service(h.context); + const updated = await service.update(seeded.categoryIds[0], { + color: "#112233", + }); + + expect(updated?.changedFields).toEqual(["color"]); + expect(updated?.row.color).toBe("#112233"); + }); + }); +}); diff --git a/plugins/blog/src/database/posts.ts b/plugins/blog/src/database/posts.ts index cf7c7e0d5..78a314fea 100644 --- a/plugins/blog/src/database/posts.ts +++ b/plugins/blog/src/database/posts.ts @@ -1,21 +1,14 @@ -import { core_users } from "@vitnode/core/database/users"; -import { pgTable } from "drizzle-orm/pg-core"; +import { createContentModel } from "@vitnode/core/content/server"; + +import { blogPostContentType } from "@/content/post"; import { blog_categories } from "./categories"; -export const blog_posts = pgTable("blog_posts", t => ({ - id: t.serial().primaryKey(), - categoryId: t - .integer() - .references(() => blog_categories.id) - .notNull(), - authorId: t.integer().references(() => core_users.id, { - onDelete: "set null", - onUpdate: "cascade", - }), - createdAt: t.timestamp().notNull().defaultNow(), - updatedAt: t - .timestamp() - .notNull() - .$onUpdate(() => new Date()), -})).enableRLS(); +export const postContent = createContentModel(blogPostContentType, { + // One thunk per `relation` field - a missing or extra key is a compile error, + // and the thunk keeps circular content type references safe. + references: { categoryId: () => blog_categories.id }, +}); + +export const blog_posts = postContent.table; +export const blog_posts_translations = postContent.translationTable; diff --git a/plugins/blog/src/database/relations.ts b/plugins/blog/src/database/relations.ts deleted file mode 100644 index 0530da3a9..000000000 --- a/plugins/blog/src/database/relations.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { relations } from "drizzle-orm"; - -import { blog_categories } from "./categories"; -import { blog_posts } from "./posts"; - -export const blog_posts_relations = relations(blog_posts, ({ one }) => ({ - category: one(blog_categories, { - fields: [blog_posts.categoryId], - references: [blog_categories.id], - }), -})); - -export const blog_categories_relations = relations( - blog_categories, - ({ many }) => ({ - posts: many(blog_posts), - }), -); diff --git a/plugins/blog/src/locales/en.json b/plugins/blog/src/locales/en.json index 108930014..fac507f8a 100644 --- a/plugins/blog/src/locales/en.json +++ b/plugins/blog/src/locales/en.json @@ -1,92 +1,65 @@ { "@vitnode/blog": { "title": "Blog", - "admin": { - "nav": { - "posts": "Posts", - "categories": "Categories" - }, - "categories": { - "desc": "Manage categories for blog posts.", - "table": { + "content": { + "post": { + "title": "Articles", + "desc": "Write and manage your blog articles.", + "fields": { "title": "Title", + "friendlyUrl": "Friendly URL", + "content": "Content", + "categoryId": "Category", + "authorId": "Author", + "status": "Status", + "publishedAt": "Published", + "updatedAt": "Updated" + } + }, + "category": { + "title": "Categories", + "desc": "Group articles together.", + "fields": { + "name": "Name", "color": "Color", - "updated_at": "Updated At" - }, - "delete": { - "title": "Delete Category", - "desc": "Are you sure you want to delete category? This action cannot be undone.", - "confirm": "Yes, delete this category", - "success": "Category has been deleted successfully." - }, - "create": { - "title": "Create Category", - "desc": "A new category for your blog posts.", - "form": { - "title": { - "label": "Title", - "already_exists": "This category title already exists." - }, - "color": "Color" - }, - "submit": "Create", - "success": "Category has been created successfully." + "updatedAt": "Updated" + } + } + }, + "admin": { + "article": { + "content": { + "label": "Content" }, - "edit": { - "title": "Edit Category", - "submit": "Save Changes", - "success": "Category has been updated successfully." + "form": { + "publish": "Publish", + "settings": { + "title": "Article settings", + "locale_desc": "The address and metadata of this language's version." + } } }, - "posts": { - "desc": "Write and manage your blog posts.", - "table": { - "title": "Title", - "category": "Category", - "author": "Author", - "updated_at": "Updated At" - }, - "create": { - "title": "Create Post", - "desc": "Write a new article for your blog.", - "form": { - "title": { - "label": "Title", - "already_exists": "This post title already exists." - }, - "friendly_url": { - "label": "Friendly URL", - "desc": "Used in the post address. Auto-filled from the title.", - "already_exists": "This friendly URL already exists." - }, - "content": "Content", - "category": "Category" - }, - "submit": "Create Post", - "success": "Post has been created successfully." - }, - "edit": { - "title": "Edit Post", - "submit": "Save Changes", - "success": "Post has been updated successfully." - }, - "delete": { - "title": "Delete Post", - "desc": "Are you sure you want to delete post? This action cannot be undone.", - "confirm": "Yes, delete this post", - "success": "Post has been deleted successfully." + "category": { + "color": { + "label": "Color", + "desc": "Shown next to the category in lists.", + "none": "No color" } } } }, - "@vitnode/blog:posts": "Posts", - "@vitnode/blog:posts:can_view": "View posts list", - "@vitnode/blog:posts:can_create": "Create posts", - "@vitnode/blog:posts:can_edit": "Edit posts", - "@vitnode/blog:posts:can_delete": "Delete posts", + "@vitnode/blog:posts": "Articles", + "@vitnode/blog:posts:can_view": "View articles list", + "@vitnode/blog:posts:can_create": "Create articles", + "@vitnode/blog:posts:can_edit": "Edit articles", + "@vitnode/blog:posts:can_delete": "Delete articles", + "@vitnode/blog:posts:can_publish": "Publish and unpublish articles", + "@vitnode/blog:posts:can_restore": "Restore an earlier version of an article", + "@vitnode/blog:posts:can_translate": "Write article translations", "@vitnode/blog:categories": "Categories", "@vitnode/blog:categories:can_view": "View categories list", "@vitnode/blog:categories:can_create": "Create categories", "@vitnode/blog:categories:can_edit": "Edit categories", - "@vitnode/blog:categories:can_delete": "Delete categories" + "@vitnode/blog:categories:can_delete": "Delete categories", + "@vitnode/blog:categories:can_translate": "Write category translations" } diff --git a/plugins/blog/src/routes/admin/blog/categories/page.tsx b/plugins/blog/src/routes/admin/blog/categories/page.tsx index 36aa27716..a0786d25e 100644 --- a/plugins/blog/src/routes/admin/blog/categories/page.tsx +++ b/plugins/blog/src/routes/admin/blog/categories/page.tsx @@ -1,64 +1,9 @@ -import type { Metadata } from "next"; +import { contentAdminHref } from "@vitnode/core/content"; +import { redirect } from "@vitnode/core/lib/navigation"; -import { I18nProvider } from "@vitnode/core/components/i18n-provider"; -import { DataTableSkeleton } from "@vitnode/core/components/table/data-table"; -import { HeaderContent } from "@vitnode/core/components/ui/header-content"; -import { checkAdminPermissionApi } from "@vitnode/core/lib/api/get-session-admin-api"; -import { getTranslations } from "next-intl/server"; -import dynamic from "next/dynamic"; -import { notFound } from "next/navigation"; -import React from "react"; +import { blogCategoryContentType } from "@/content/category"; -import { CONFIG_PLUGIN } from "@/const"; -import { ActionsCategoriesAdmin } from "@/views/admin/categories/actions/actions"; - -const CategoriesAdminView = dynamic(async () => - import("@/views/admin/categories/table/categories-admin-view").then(mod => ({ - default: mod.CategoriesAdminView, - })), -); - -export const generateMetadata = async (): Promise => { - const t = await getTranslations("@vitnode/blog.admin.nav"); - - return { - title: t("categories"), - }; -}; - -export default async function CategoriesPage( - params: React.ComponentProps, -) { - const [t, tNav, canView, canCreate] = await Promise.all([ - getTranslations("@vitnode/blog.admin.categories"), - getTranslations("@vitnode/blog.admin.nav"), - checkAdminPermissionApi({ - plugin: CONFIG_PLUGIN.pluginId, - module: "categories", - permission: "can_view", - }), - checkAdminPermissionApi({ - plugin: CONFIG_PLUGIN.pluginId, - module: "categories", - permission: "can_create", - }), - ]); - - if (!canView) { - notFound(); - } - - return ( - -
- - {canCreate && } - - - }> - - -
-
- ); +/** The address categories used to live at. See the posts page next door. */ +export default async function LegacyCategoriesPage() { + await redirect(contentAdminHref(blogCategoryContentType.id)); } diff --git a/plugins/blog/src/routes/admin/blog/posts/page.tsx b/plugins/blog/src/routes/admin/blog/posts/page.tsx index 53ee8699e..ca0430824 100644 --- a/plugins/blog/src/routes/admin/blog/posts/page.tsx +++ b/plugins/blog/src/routes/admin/blog/posts/page.tsx @@ -1,64 +1,16 @@ -import type { Metadata } from "next"; - -import { I18nProvider } from "@vitnode/core/components/i18n-provider"; -import { DataTableSkeleton } from "@vitnode/core/components/table/data-table"; -import { HeaderContent } from "@vitnode/core/components/ui/header-content"; -import { checkAdminPermissionApi } from "@vitnode/core/lib/api/get-session-admin-api"; -import { getTranslations } from "next-intl/server"; -import dynamic from "next/dynamic"; -import { notFound } from "next/navigation"; -import React from "react"; - -import { CONFIG_PLUGIN } from "@/const"; -import { ActionsPostsAdmin } from "@/views/admin/posts/actions/actions"; - -const PostsAdminView = dynamic(async () => - import("@/views/admin/posts/table/posts-admin-view").then(mod => ({ - default: mod.PostsAdminView, - })), -); - -export const generateMetadata = async (): Promise => { - const t = await getTranslations("@vitnode/blog.admin.nav"); - - return { - title: t("posts"), - }; -}; - -export default async function PostsPage( - params: React.ComponentProps, -) { - const [t, tNav, canView, canCreate] = await Promise.all([ - getTranslations("@vitnode/blog.admin.posts"), - getTranslations("@vitnode/blog.admin.nav"), - checkAdminPermissionApi({ - plugin: CONFIG_PLUGIN.pluginId, - module: "posts", - permission: "can_view", - }), - checkAdminPermissionApi({ - plugin: CONFIG_PLUGIN.pluginId, - module: "posts", - permission: "can_create", - }), - ]); - - if (!canView) { - notFound(); - } - - return ( - -
- - {canCreate && } - - - }> - - -
-
- ); +import { contentAdminHref } from "@vitnode/core/content"; +import { redirect } from "@vitnode/core/lib/navigation"; + +import { blogPostContentType } from "@/content/post"; + +/** + * The address articles used to live at. + * + * A redirect rather than a second list screen: the AdminCP linked here for + * several releases, so the URL is in bookmarks and in muscle memory - but the + * page behind it is now generated, and keeping a duplicate of it would mean two + * tables to fix every time one of them was wrong. + */ +export default async function LegacyPostsPage() { + await redirect(contentAdminHref(blogPostContentType.id)); } diff --git a/plugins/blog/src/routes/breadcrumb/admin/blog/categories/page.tsx b/plugins/blog/src/routes/breadcrumb/admin/blog/categories/page.tsx deleted file mode 100644 index b4680017f..000000000 --- a/plugins/blog/src/routes/breadcrumb/admin/blog/categories/page.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import { BreadcrumbAdmin } from "@vitnode/core/views/admin/layouts/breadcrumb/breadcrumb-admin"; - -export default function BreadcrumbSlot() { - return ; -} diff --git a/plugins/blog/src/routes/breadcrumb/admin/blog/posts/page.tsx b/plugins/blog/src/routes/breadcrumb/admin/blog/posts/page.tsx deleted file mode 100644 index 6aad0fb44..000000000 --- a/plugins/blog/src/routes/breadcrumb/admin/blog/posts/page.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import { BreadcrumbAdmin } from "@vitnode/core/views/admin/layouts/breadcrumb/breadcrumb-admin"; - -export default function BreadcrumbSlot() { - return ; -} diff --git a/plugins/blog/src/views/admin/article/editor-field.tsx b/plugins/blog/src/views/admin/article/editor-field.tsx new file mode 100644 index 000000000..bf88b03b4 --- /dev/null +++ b/plugins/blog/src/views/admin/article/editor-field.tsx @@ -0,0 +1,45 @@ +"use client"; + +import type { ItemAutoFormComponentProps } from "@vitnode/core/components/form/auto-form"; + +import { Loader } from "@vitnode/core/components/ui/loader"; +import { useTranslations } from "next-intl"; +import dynamic from "next/dynamic"; +import React from "react"; + +/** + * The rich text editor, loaded on demand. + * + * `AutoFormEditor` pulls in the whole Tiptap stack, which is the single heaviest + * thing on the article screen and useless on every other one - so it arrives with + * the editor tab rather than with the page. `ssr: false` because the editor + * mounts against a real DOM; rendering it on the server and again in the browser + * is exactly the hydration mismatch that makes an editor drop its first + * keystroke. + */ +const AutoFormEditor = dynamic( + async () => + await import("@vitnode/core/components/form/fields/editor").then(mod => ({ + default: mod.AutoFormEditor, + })), + { loading: () => , ssr: false }, +); + +/** + * The article body. + * + * A field override, so the editor is one input inside the **same** form as the + * title, the slug, the category and the author: one `react-hook-form` instance, + * one schema, one submit. There is no editor-local state atom and no second save + * button - `field.value` and `field.onChange` are the whole integration, and + * dirty state and validation work because of it. + */ +export const BlogArticleEditorField = (props: ItemAutoFormComponentProps) => { + const t = useTranslations("@vitnode/blog.admin.article"); + + return ( + }> + + + ); +}; diff --git a/plugins/blog/src/views/admin/article/form-layout.test.tsx b/plugins/blog/src/views/admin/article/form-layout.test.tsx new file mode 100644 index 000000000..8500985d9 --- /dev/null +++ b/plugins/blog/src/views/admin/article/form-layout.test.tsx @@ -0,0 +1,120 @@ +import type { ContentFormLayoutProps } from "@vitnode/core/lib/plugin"; + +import { render, screen } from "@testing-library/react"; +import { ContentFormProvider } from "@vitnode/core/views/admin/views/content/form/context"; +import { FormProvider, useForm } from "react-hook-form"; +import { describe, expect, it, vi } from "vitest"; + +import { BlogArticleFormLayout } from "./form-layout"; + +vi.mock("next-intl", () => ({ + useTranslations: () => (key: string) => key, +})); + +vi.mock("@vitnode/core/lib/navigation", () => ({ + Link: ({ children, href }: { children: React.ReactNode; href: string }) => ( + {children} + ), +})); + +/** + * The layout is rendered on its own, with stand-in elements where the Content + * Engine would have put real fields. + * + * That is exactly the contract under test: the layout must place whatever it is + * handed, by name, and must not know or care what a field actually is. + */ +/** The submit button reads the surrounding form, exactly as it does for real. */ +const Harness = ({ children }: { children: React.ReactNode }) => { + const form = useForm(); + + return ( + + {children} + + ); +}; + +const renderLayout = ({ + fieldNames, + surface = "shared", +}: { + fieldNames: string[]; + surface?: ContentFormLayoutProps["surface"]; +}) => + render( + + [ + name, + field:{name}, + ]), + ), + mode: "edit", + publication: { enabled: true, publishedAt: null, status: "draft" }, + surface, + }} + > + + + , + ); + +describe("BlogArticleFormLayout", () => { + it("puts the writing fields in the main column and the metadata beside them", () => { + const { container } = renderLayout({ + fieldNames: ["title", "content", "friendlyUrl", "categoryId", "authorId"], + }); + + for (const name of [ + "title", + "content", + "friendlyUrl", + "categoryId", + "authorId", + ]) { + expect(screen.getByText(`field:${name}`, { exact: false })).toBeTruthy(); + } + + const sections = container.querySelectorAll("section"); + // Body, publish, article settings. + expect(sections).toHaveLength(3); + expect(sections[0].textContent).toContain("title"); + expect(sections[0].textContent).toContain("content"); + expect(sections[2].textContent).toContain("categoryId"); + }); + + it("renders the publication state as a read-only line, with no publish control", () => { + renderLayout({ fieldNames: ["title"] }); + + expect(screen.getByText("draft")).toBeTruthy(); + // One button only: save. Publishing stays where the engine put it. + expect(screen.getAllByRole("button")).toHaveLength(1); + }); + + it("places the same names on a locale tab, ignoring the ones that are not there", () => { + // A localized content type splits its fields in two. The shared surface has + // no `title`, and the layout has to cope without knowing that. + renderLayout({ fieldNames: ["categoryId", "authorId"] }); + + expect(screen.getByText("field:categoryId", { exact: false })).toBeTruthy(); + expect(screen.queryByText("field:title", { exact: false })).toBeNull(); + }); + + it("explains itself on a locale tab", () => { + renderLayout({ fieldNames: ["title"], surface: "translation" }); + + expect(screen.getByText("settings.locale_desc")).toBeTruthy(); + }); +}); diff --git a/plugins/blog/src/views/admin/article/form-layout.tsx b/plugins/blog/src/views/admin/article/form-layout.tsx new file mode 100644 index 000000000..72db811a9 --- /dev/null +++ b/plugins/blog/src/views/admin/article/form-layout.tsx @@ -0,0 +1,67 @@ +"use client"; + +import type { ContentFormLayoutProps } from "@vitnode/core/lib/plugin"; + +import { + ContentFormActions, + ContentFormField, + ContentFormLayoutGrid, + ContentFormMain, + ContentFormSection, + ContentFormSidebar, + ContentFormStatus, +} from "@vitnode/core/content/admin-form"; +import { useTranslations } from "next-intl"; + +/** + * The article editor: a wide writing column and a metadata sidebar. + * + * **Presentation only.** Every field here is the one the Content Engine built, + * complete with its overrides, its validation and its error message; the submit + * button is the engine's; the mutation, the version precondition, the toast, the + * cache invalidation, the events, the search write and the delivery effects all + * happen exactly as they do in the generated dialog. This file decides where + * things are and nothing else - there is not a single API call in it. + * + * One layout for create and edit, and one for both surfaces of a localized + * content type. `ContentFormField` renders nothing for a field this surface does + * not have, so the shared tab shows the category and the author while each + * language tab shows that language's title, body and URL - from the same source. + * + * The publication state is read-only, deliberately and consistently with the + * generated dialog: `status` and `publishedAt` are not in the form schema, and + * the publish action on the list is the one thing that moves them. Two mutation + * paths in one screen is how a form ends up fighting its own state. + */ +export const BlogArticleFormLayout = ({ surface }: ContentFormLayoutProps) => { + const t = useTranslations("@vitnode/blog.admin.article.form"); + + return ( + + + + + + + + + + + + + + + + + + + + + + ); +}; diff --git a/plugins/blog/src/views/admin/categories/actions/actions.tsx b/plugins/blog/src/views/admin/categories/actions/actions.tsx deleted file mode 100644 index fdfea47ed..000000000 --- a/plugins/blog/src/views/admin/categories/actions/actions.tsx +++ /dev/null @@ -1,46 +0,0 @@ -"use client"; - -import { Button } from "@vitnode/core/components/ui/button"; -import { - Dialog, - DialogContent, - DialogDescription, - DialogHeader, - DialogTitle, - DialogTrigger, -} from "@vitnode/core/components/ui/dialog"; -import { Loader } from "@vitnode/core/components/ui/loader"; -import { PlusIcon } from "lucide-react"; -import { useTranslations } from "next-intl"; -import dynamic from "next/dynamic"; -import React from "react"; - -const CreateEditActionCategoriesAdmin = dynamic(async () => - import("./create-edit/create-edit").then(mod => ({ - default: mod.CreateEditActionCategoriesAdmin, - })), -); - -export const ActionsCategoriesAdmin = () => { - const t = useTranslations("@vitnode/blog.admin.categories.create"); - - return ( - - }> - - {t("title")} - - - - - {t("title")} - {t("desc")} - - - }> - - - - - ); -}; diff --git a/plugins/blog/src/views/admin/categories/actions/create-edit/create-edit.tsx b/plugins/blog/src/views/admin/categories/actions/create-edit/create-edit.tsx deleted file mode 100644 index 43d2decc7..000000000 --- a/plugins/blog/src/views/admin/categories/actions/create-edit/create-edit.tsx +++ /dev/null @@ -1,90 +0,0 @@ -import { - AutoForm, - type AutoFormOnSubmit, -} from "@vitnode/core/components/form/auto-form"; -import { AutoFormColor } from "@vitnode/core/components/form/fields/color"; -import { AutoFormInput } from "@vitnode/core/components/form/fields/input"; -import { useDialog } from "@vitnode/core/components/ui/dialog"; -import { - getLangValue, - multiLangValueSchema, -} from "@vitnode/core/lib/helpers/multi-lang"; -import { usePathname, useRouter } from "@vitnode/core/lib/navigation"; -import { useLocale, useTranslations } from "next-intl"; -import { toast } from "sonner"; -import { z } from "zod"; - -import type { zodCategorySchema } from "@/api/modules/categories/routes/get.route"; - -import { createMutationApi, editMutationApi } from "./mutation-api.server"; - -export const CreateEditActionCategoriesAdmin = ({ - data, -}: { - data?: z.infer & { id: number }; -}) => { - const t = useTranslations("@vitnode/blog.admin.categories"); - const tCore = useTranslations("core.global.errors"); - const { setOpen } = useDialog(); - const { push } = useRouter(); - const pathname = usePathname(); - const locale = useLocale(); - const formSchema = z.object({ - title: multiLangValueSchema({ minLength: 1, maxLength: 100 }) - .min(1) - .default(data?.titleTranslations ?? []), - color: z.string().default(data?.color ?? ""), - }); - - const onSubmit: AutoFormOnSubmit = async values => { - const mutation = data?.id - ? await editMutationApi({ id: data.id, ...values }) - : await createMutationApi(values); - - if (mutation?.error) { - toast.error(tCore("title"), { - description: tCore("internal_server_error"), - }); - - return; - } - - toast.success(t(data ? "edit.success" : "create.success"), { - description: getLangValue(values.title, locale) || values.title[0]?.value, - }); - setOpen?.(false); - push(pathname); - }; - - return ( - ( - - ), - }, - { - id: "color", - component: props => ( - - ), - }, - ]} - formSchema={formSchema} - onSubmit={onSubmit} - submitButtonProps={{ - children: t(`${data ? "edit" : "create"}.submit`), - }} - /> - ); -}; diff --git a/plugins/blog/src/views/admin/categories/actions/create-edit/mutation-api.server.ts b/plugins/blog/src/views/admin/categories/actions/create-edit/mutation-api.server.ts deleted file mode 100644 index 6d5e68ea4..000000000 --- a/plugins/blog/src/views/admin/categories/actions/create-edit/mutation-api.server.ts +++ /dev/null @@ -1,60 +0,0 @@ -"use server"; - -import type { z } from "zod"; - -import { fetcher } from "@vitnode/core/lib/fetcher"; -import { revalidatePath } from "next/cache"; - -import type { zodCreateCategorySchema } from "../../../../../api/modules/admin/categories/routes/create.route"; - -import { categoriesAdminModule } from "../../../../../api/modules/admin/categories/categories.admin.module"; - -export const createMutationApi = async ( - body: z.infer, -) => { - const res = await fetcher(categoriesAdminModule, { - prefixPath: "/admin", - method: "post", - module: "categories", - path: "/", - args: { - body, - }, - }); - - if (res.status !== 201) { - return { error: await res.text() }; - } - - revalidatePath( - "/[locale]/admin/(auth)/(plugins)/(vitnode-blog)/blog/categories", - "page", - ); -}; - -export const editMutationApi = async ({ - id, - ...body -}: z.infer & { id: number }) => { - const res = await fetcher(categoriesAdminModule, { - prefixPath: "/admin", - method: "put", - module: "categories", - path: "/{id}", - args: { - params: { - id, - }, - body, - }, - }); - - if (res.status !== 200) { - return { error: await res.text() }; - } - - revalidatePath( - "/[locale]/admin/(auth)/(plugins)/(vitnode-blog)/blog/categories", - "page", - ); -}; diff --git a/plugins/blog/src/views/admin/categories/table/actions/delete/delete-action.tsx b/plugins/blog/src/views/admin/categories/table/actions/delete/delete-action.tsx deleted file mode 100644 index 2394677a5..000000000 --- a/plugins/blog/src/views/admin/categories/table/actions/delete/delete-action.tsx +++ /dev/null @@ -1,71 +0,0 @@ -"use client"; - -import { ConfirmActionAlertDialog } from "@vitnode/core/components/confirm-action/confirm-action-alert-dialog"; -import { useAdminStaffPermission } from "@vitnode/core/components/staff-permission/provider"; -import { Button } from "@vitnode/core/components/ui/button"; -import { - Tooltip, - TooltipContent, - TooltipProvider, - TooltipTrigger, -} from "@vitnode/core/components/ui/tooltip"; -import { Trash2Icon } from "lucide-react"; -import { useTranslations } from "next-intl"; -import { toast } from "sonner"; - -import { CONFIG_PLUGIN } from "@/const"; - -import { mutationApi } from "./mutation-api.server"; - -export const DeleteAction = ({ title, id }: { id: number; title: string }) => { - const t = useTranslations("@vitnode/blog.admin.categories.delete"); - const tGlobal = useTranslations("core.global"); - const canDelete = useAdminStaffPermission({ - plugin: CONFIG_PLUGIN.pluginId, - module: "categories", - permission: "can_delete", - }); - - if (!canDelete) return null; - - return ( - - - ( - {title} - ), - })} - onSubmit={async ({ onClose }) => { - const mutation = await mutationApi(id); - if (mutation?.error) { - toast.error(tGlobal("errors.title"), { - description: tGlobal("errors.internal_server_error"), - }); - - return; - } - - toast.success(t("success"), { - description: title, - }); - onClose(); - }} - textSubmit={t("confirm")} - title={t("title")} - > - - - - } - /> - - - {t("title")} - - - ); -}; diff --git a/plugins/blog/src/views/admin/categories/table/actions/delete/mutation-api.server.ts b/plugins/blog/src/views/admin/categories/table/actions/delete/mutation-api.server.ts deleted file mode 100644 index 661b6a735..000000000 --- a/plugins/blog/src/views/admin/categories/table/actions/delete/mutation-api.server.ts +++ /dev/null @@ -1,29 +0,0 @@ -"use server"; - -import { fetcher } from "@vitnode/core/lib/fetcher"; -import { revalidatePath } from "next/cache"; - -import { categoriesAdminModule } from "@/api/modules/admin/categories/categories.admin.module"; - -export const mutationApi = async (id: number) => { - const res = await fetcher(categoriesAdminModule, { - prefixPath: "/admin", - method: "delete", - path: "/{id}", - module: "categories", - args: { - params: { - id, - }, - }, - }); - - if (!res.ok) { - return { error: await res.text() }; - } - - revalidatePath( - "/[locale]/admin/(auth)/(plugins)/(vitnode-blog)/blog/categories", - "page", - ); -}; diff --git a/plugins/blog/src/views/admin/categories/table/actions/edit-action.tsx b/plugins/blog/src/views/admin/categories/table/actions/edit-action.tsx deleted file mode 100644 index 092a72665..000000000 --- a/plugins/blog/src/views/admin/categories/table/actions/edit-action.tsx +++ /dev/null @@ -1,81 +0,0 @@ -"use client"; - -import { useAdminStaffPermission } from "@vitnode/core/components/staff-permission/provider"; -import { Button } from "@vitnode/core/components/ui/button"; -import { - Dialog, - DialogContent, - DialogDescription, - DialogHeader, - DialogTitle, - DialogTrigger, -} from "@vitnode/core/components/ui/dialog"; -import { Loader } from "@vitnode/core/components/ui/loader"; -import { - Tooltip, - TooltipContent, - TooltipProvider, - TooltipTrigger, -} from "@vitnode/core/components/ui/tooltip"; -import { getLangValue } from "@vitnode/core/lib/helpers/multi-lang"; -import { PencilIcon } from "lucide-react"; -import { useLocale, useTranslations } from "next-intl"; -import dynamic from "next/dynamic"; -import React from "react"; - -import { CONFIG_PLUGIN } from "@/const"; - -const CreateEditActionCategoriesAdmin = dynamic(async () => - import("../..//actions/create-edit/create-edit").then(mod => ({ - default: mod.CreateEditActionCategoriesAdmin, - })), -); - -export const EditAction = ( - props: Required>, -) => { - const t = useTranslations("@vitnode/blog.admin.categories.edit"); - const locale = useLocale(); - const canEdit = useAdminStaffPermission({ - plugin: CONFIG_PLUGIN.pluginId, - module: "categories", - permission: "can_edit", - }); - - if (!canEdit) return null; - - return ( - - - - - } - > - -
- } - /> - {t("title")} - - - - - - {t("title")} - - {getLangValue(props.data.titleTranslations, locale) || - props.data.titleTranslations[0]?.value} - - - - }> - - - -
- ); -}; diff --git a/plugins/blog/src/views/admin/categories/table/categories-admin-view.tsx b/plugins/blog/src/views/admin/categories/table/categories-admin-view.tsx deleted file mode 100644 index 41e58bbe6..000000000 --- a/plugins/blog/src/views/admin/categories/table/categories-admin-view.tsx +++ /dev/null @@ -1,102 +0,0 @@ -import { DateFormat } from "@vitnode/core/components/date-format"; -import { DataTable } from "@vitnode/core/components/table/data-table"; -import { fetcher } from "@vitnode/core/lib/fetcher"; -import { getLangValue } from "@vitnode/core/lib/helpers/multi-lang"; -import { getLocale, getTranslations } from "next-intl/server"; - -import { categoriesModule } from "@/api/modules/categories/categories.module"; - -import { DeleteAction } from "./actions/delete/delete-action"; -import { EditAction } from "./actions/edit-action"; - -export const CategoriesAdminView = async ({ - searchParams, -}: { - searchParams: Promise>; -}) => { - const t = await getTranslations("@vitnode/blog.admin.categories.table"); - const locale = await getLocale(); - const query = await searchParams; - const res = await fetcher(categoriesModule, { - path: "/", - method: "get", - module: "categories", - args: { - query, - }, - withPagination: true, - options: { - cache: "force-cache", - }, - }); - const data = await res.json(); - - return ( - - row.color ? ( -
- - - {row.color} - -
- ) : ( - — - ), - }, - { - accessorKey: "updatedAt", - header: t("updated_at"), - className: "w-48", - cell: ({ row }) => , - }, - { - id: "actions", - header: "", - align: "right", - className: "w-10", - cell: ({ row }) => ( - <> - - - - ), - }, - ]} - edges={data.edges.map(edge => ({ - ...edge, - title: - getLangValue(edge.titleTranslations, locale) || - edge.titleTranslations[0]?.value || - "", - }))} - id="categories-table" - order={{ - columns: ["createdAt", "updatedAt"], - defaultOrder: { - column: "createdAt", - order: "desc", - }, - }} - pageInfo={data.pageInfo} - /> - ); -}; diff --git a/plugins/blog/src/views/admin/category/color-cell.test.tsx b/plugins/blog/src/views/admin/category/color-cell.test.tsx new file mode 100644 index 000000000..e6b970fee --- /dev/null +++ b/plugins/blog/src/views/admin/category/color-cell.test.tsx @@ -0,0 +1,42 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; + +import { BlogCategoryColorCell } from "./color-cell"; + +vi.mock("next-intl", () => ({ + useTranslations: () => (key: string) => key, +})); + +type Row = Parameters[0]["row"]; + +const renderCell = (color: null | string) => + render(); + +describe("BlogCategoryColorCell", () => { + it("says the colour in words as well as showing it", () => { + const { container } = renderCell("#3260c0"); + + // The value is real text, so a screen reader and a colour-blind reader get + // the same information the swatch carries. + expect(screen.getByText("#3260c0")).toBeTruthy(); + + const swatch = container.querySelector("span[aria-hidden]"); + expect(swatch).toBeTruthy(); + expect((swatch as HTMLElement).style.backgroundColor).toBe( + "rgb(50, 96, 192)", + ); + }); + + it("keeps the swatch out of the accessibility tree", () => { + const { container } = renderCell("#3260c0"); + + expect(container.querySelector("span[aria-hidden]")?.textContent).toBe(""); + }); + + it("names the empty state rather than rendering a blank cell", () => { + renderCell(null); + + expect(screen.getByText("color.none")).toBeTruthy(); + expect(screen.queryByText("#3260c0")).toBeNull(); + }); +}); diff --git a/plugins/blog/src/views/admin/category/color-cell.tsx b/plugins/blog/src/views/admin/category/color-cell.tsx new file mode 100644 index 000000000..8ba75a9df --- /dev/null +++ b/plugins/blog/src/views/admin/category/color-cell.tsx @@ -0,0 +1,40 @@ +"use client"; + +import type { ContentCellProps } from "@vitnode/core/lib/plugin"; + +import { useTranslations } from "next-intl"; + +import type { blogCategoryContentType } from "@/content/category"; + +/** + * The colour column, as a swatch **and** the value it stands for. + * + * The text is not decoration. A cell that communicated the colour only visually + * would be unreadable to a screen reader and ambiguous to anyone who cannot tell + * two blues apart, so the swatch is `aria-hidden` and the value next to it is + * the accessible content. + * + * A column override, so none of this reasoning lands in the generic content + * table - which knows about kinds, not about colours. + */ +export const BlogCategoryColorCell = ({ + row, +}: ContentCellProps) => { + const t = useTranslations("@vitnode/blog.admin.category"); + const color = row.color; + + if (!color) { + return {t("color.none")}; + } + + return ( +
+ + {color} +
+ ); +}; diff --git a/plugins/blog/src/views/admin/category/color-field.tsx b/plugins/blog/src/views/admin/category/color-field.tsx new file mode 100644 index 000000000..b2aebd2d4 --- /dev/null +++ b/plugins/blog/src/views/admin/category/color-field.tsx @@ -0,0 +1,27 @@ +"use client"; + +import type { ItemAutoFormComponentProps } from "@vitnode/core/components/form/auto-form"; + +import { AutoFormColor } from "@vitnode/core/components/form/fields/color"; +import { useTranslations } from "next-intl"; + +/** + * The category colour, as the AdminCP's own colour picker. + * + * A field override, not a new field kind: the Content Engine stores a + * `varchar(50)` and has no opinion about what is in it, and the picker VitNode + * already ships is what turns that into something anyone would want to use. The + * value, the validation and the mutation are still the engine's. + */ +export const BlogCategoryColorField = (props: ItemAutoFormComponentProps) => { + const t = useTranslations("@vitnode/blog.admin.category"); + + return ( + + ); +}; diff --git a/plugins/blog/src/views/admin/posts/actions/actions.tsx b/plugins/blog/src/views/admin/posts/actions/actions.tsx deleted file mode 100644 index 9630734e4..000000000 --- a/plugins/blog/src/views/admin/posts/actions/actions.tsx +++ /dev/null @@ -1,46 +0,0 @@ -"use client"; - -import { Button } from "@vitnode/core/components/ui/button"; -import { - Dialog, - DialogContent, - DialogDescription, - DialogHeader, - DialogTitle, - DialogTrigger, -} from "@vitnode/core/components/ui/dialog"; -import { Loader } from "@vitnode/core/components/ui/loader"; -import { PlusIcon } from "lucide-react"; -import { useTranslations } from "next-intl"; -import dynamic from "next/dynamic"; -import React from "react"; - -const CreateEditActionPostsAdmin = dynamic(async () => - import("./create-edit/create-edit").then(module => ({ - default: module.CreateEditActionPostsAdmin, - })), -); - -export const ActionsPostsAdmin = () => { - const t = useTranslations("@vitnode/blog.admin.posts.create"); - - return ( - - }> - - {t("title")} - - - - - {t("title")} - {t("desc")} - - - }> - - - - - ); -}; diff --git a/plugins/blog/src/views/admin/posts/actions/create-edit/create-edit.tsx b/plugins/blog/src/views/admin/posts/actions/create-edit/create-edit.tsx deleted file mode 100644 index bb6a25892..000000000 --- a/plugins/blog/src/views/admin/posts/actions/create-edit/create-edit.tsx +++ /dev/null @@ -1,174 +0,0 @@ -import { - AutoForm, - type AutoFormOnSubmit, -} from "@vitnode/core/components/form/auto-form"; -import { AutoFormCombobox } from "@vitnode/core/components/form/fields/combobox"; -import { AutoFormEditor } from "@vitnode/core/components/form/fields/editor"; -import { useDialog } from "@vitnode/core/components/ui/dialog"; -import { fetcherClient } from "@vitnode/core/lib/fetcher-client"; -import { - getLangValue, - multiLangValueSchema, -} from "@vitnode/core/lib/helpers/multi-lang"; -import { usePathname, useRouter } from "@vitnode/core/lib/navigation"; -import { useLocale, useTranslations } from "next-intl"; -import React from "react"; -import { toast } from "sonner"; -import { z } from "zod"; - -import type { zodPostSchema } from "@/api/modules/posts/routes/get.route"; - -import { categoriesModule } from "@/api/modules/categories/categories.module"; - -import { FriendlyUrlField, TitleField } from "./multi-lang-fields"; -import { createMutationApi, editMutationApi } from "./mutation-api.server"; - -export const CreateEditActionPostsAdmin = ({ - data, -}: { - data?: z.infer & { id?: number }; -}) => { - const t = useTranslations("@vitnode/blog.admin.posts"); - const tCore = useTranslations("core.global.errors"); - const locale = useLocale(); - const { setOpen } = useDialog(); - const { push } = useRouter(); - const pathname = usePathname(); - const resolveCategoryTitle = ( - translations: { languageCode: string; value: string }[], - ) => getLangValue(translations, locale) || translations[0]?.value || ""; - const friendlyUrlTouchedRef = React.useRef>( - new Set(data?.friendlyUrlTranslations?.map(item => item.languageCode)), - ); - - const formSchema = z.object({ - title: multiLangValueSchema({ minLength: 3, maxLength: 255 }) - .min(1) - .default(data?.titleTranslations ?? []), - friendlyUrl: multiLangValueSchema({ minLength: 1, maxLength: 255 }) - .min(1) - .default(data?.friendlyUrlTranslations ?? []), - content: multiLangValueSchema().default(data?.contentTranslations ?? []), - categoryId: z - .object({ value: z.string(), label: z.string() }) - .refine(value => value.value !== "", { - message: tCore("field_required"), - }) - .default( - data?.category - ? { - value: data.category.id.toString(), - label: resolveCategoryTitle(data.category.titleTranslations), - } - : { value: "", label: "" }, - ), - }); - - const onSubmit: AutoFormOnSubmit = async ( - values, - form, - ) => { - const body = { - title: values.title, - content: values.content, - friendlyUrl: values.friendlyUrl, - categoryId: parseInt(values.categoryId.value, 10), - }; - const mutation = data?.id - ? await editMutationApi({ id: data.id, ...body }) - : await createMutationApi(body); - - if (mutation?.error) { - if (mutation.error.includes("already exists")) { - form.setError("friendlyUrl", { - type: "manual", - message: t("create.form.friendly_url.already_exists"), - }); - - return; - } - - toast.error(tCore("title"), { - description: tCore("internal_server_error"), - }); - - return; - } - - toast.success(t(data ? "edit.success" : "create.success")); - setOpen?.(false); - setTimeout(() => push(pathname), 300); - }; - - return ( - ( - - ), - }, - { - id: "friendlyUrl", - component: props => ( - - ), - }, - { - id: "categoryId", - component: props => ( - { - const res = await fetcherClient(categoriesModule, { - path: "/", - method: "get", - module: "categories", - args: { - query: { - search, - }, - }, - }); - const data = await res.json(); - - return data.edges.map(category => ({ - label: resolveCategoryTitle(category.titleTranslations), - value: category.id.toString(), - })); - }} - id="categoryId" - label={t("create.form.category")} - {...props} - /> - ), - }, - { - id: "content", - component: props => ( - - ), - }, - ]} - formSchema={formSchema} - onSubmit={onSubmit} - submitButtonProps={{ - children: t(`${data ? "edit" : "create"}.submit`), - }} - /> - ); -}; diff --git a/plugins/blog/src/views/admin/posts/actions/create-edit/multi-lang-fields.tsx b/plugins/blog/src/views/admin/posts/actions/create-edit/multi-lang-fields.tsx deleted file mode 100644 index ba2294400..000000000 --- a/plugins/blog/src/views/admin/posts/actions/create-edit/multi-lang-fields.tsx +++ /dev/null @@ -1,138 +0,0 @@ -"use client"; - -import type { ItemAutoFormComponentProps } from "@vitnode/core/components/form/auto-form"; -import type { MultiLangValue } from "@vitnode/core/lib/helpers/multi-lang"; - -import { AutoFormDesc } from "@vitnode/core/components/form/common/desc"; -import { AutoFormLabel } from "@vitnode/core/components/form/common/label"; -import { - MultiLangSelect, - useMultiLangField, -} from "@vitnode/core/components/form/fields/multi-lang"; -import { FormControl, FormMessage } from "@vitnode/core/components/ui/form"; -import { - InputGroup, - InputGroupAddon, - InputGroupInput, -} from "@vitnode/core/components/ui/input-group"; -import { upsertLangValue } from "@vitnode/core/lib/helpers/multi-lang"; -import { removeSpecialCharacters } from "@vitnode/core/lib/special-characters"; -import React from "react"; -import { useFormContext } from "react-hook-form"; - -const MultiLangInputGroup = ({ - currentValue, - languages, - onChange, - onBlur, - onSelect, - selected, - ...props -}: Omit< - React.ComponentProps, - "onBlur" | "onChange" | "onSelect" | "value" -> & { - currentValue: string; - languages: ReturnType["languages"]; - onBlur: () => void; - onChange: (value: string) => void; - onSelect: (code: string) => void; - selected: string; -}) => ( - - - onChange(e.target.value)} - value={currentValue} - {...props} - /> - {languages.length > 1 && ( - - - - )} - - -); - -// Title drives the friendly URL: as the user types the title for a language, the -// same language's friendly URL is filled with a slug - until that language's -// friendly URL is edited by hand (tracked in `friendlyUrlTouched`). -export const TitleField = ({ - field, - label, - description, - friendlyUrlName, - friendlyUrlTouched, -}: ItemAutoFormComponentProps & { - friendlyUrlName: string; - friendlyUrlTouched: React.RefObject>; -}) => { - const form = useFormContext(); - const { languages, selected, setSelected, currentValue, setValue } = - useMultiLangField(field); - - return ( - <> - {!!label && {label}} - { - setValue(value); - - if (friendlyUrlTouched.current?.has(selected)) return; - const current: MultiLangValue | undefined = - form.getValues(friendlyUrlName); - form.setValue( - friendlyUrlName, - upsertLangValue(current, selected, removeSpecialCharacters(value)), - { shouldValidate: true, shouldDirty: true }, - ); - }} - onSelect={setSelected} - selected={selected} - /> - {!!description && {description}} - - - ); -}; - -export const FriendlyUrlField = ({ - field, - label, - description, - friendlyUrlTouched, -}: ItemAutoFormComponentProps & { - friendlyUrlTouched: React.RefObject>; -}) => { - const { languages, selected, setSelected, currentValue, setValue } = - useMultiLangField(field); - - return ( - <> - {!!label && {label}} - { - friendlyUrlTouched.current?.add(selected); - setValue(value); - }} - onSelect={setSelected} - selected={selected} - /> - {!!description && {description}} - - - ); -}; diff --git a/plugins/blog/src/views/admin/posts/actions/create-edit/mutation-api.server.ts b/plugins/blog/src/views/admin/posts/actions/create-edit/mutation-api.server.ts deleted file mode 100644 index 1046849d9..000000000 --- a/plugins/blog/src/views/admin/posts/actions/create-edit/mutation-api.server.ts +++ /dev/null @@ -1,60 +0,0 @@ -"use server"; - -import type { z } from "zod"; - -import { fetcher } from "@vitnode/core/lib/fetcher"; -import { revalidatePath } from "next/cache"; - -import type { zodCreatePostSchema } from "@/api/modules/admin/posts/routes/create.route"; - -import { postsAdminModule } from "@/api/modules/admin/posts/posts.admin.module"; - -export const createMutationApi = async ( - body: z.infer, -) => { - const res = await fetcher(postsAdminModule, { - prefixPath: "/admin", - method: "post", - module: "posts", - path: "/", - args: { - body, - }, - }); - - if (res.status !== 201) { - return { error: await res.text() }; - } - - revalidatePath( - "/[locale]/admin/(auth)/(plugins)/(vitnode-blog)/blog/posts", - "page", - ); -}; - -export const editMutationApi = async ({ - id, - ...body -}: z.infer & { id: number }) => { - const res = await fetcher(postsAdminModule, { - prefixPath: "/admin", - method: "put", - module: "posts", - path: "/{id}", - args: { - params: { - id, - }, - body, - }, - }); - - if (res.status !== 200) { - return { error: await res.text() }; - } - - revalidatePath( - "/[locale]/admin/(auth)/(plugins)/(vitnode-blog)/blog/posts", - "page", - ); -}; diff --git a/plugins/blog/src/views/admin/posts/table/actions/delete/delete-action.tsx b/plugins/blog/src/views/admin/posts/table/actions/delete/delete-action.tsx deleted file mode 100644 index 4b92470a3..000000000 --- a/plugins/blog/src/views/admin/posts/table/actions/delete/delete-action.tsx +++ /dev/null @@ -1,71 +0,0 @@ -"use client"; - -import { ConfirmActionAlertDialog } from "@vitnode/core/components/confirm-action/confirm-action-alert-dialog"; -import { useAdminStaffPermission } from "@vitnode/core/components/staff-permission/provider"; -import { Button } from "@vitnode/core/components/ui/button"; -import { - Tooltip, - TooltipContent, - TooltipProvider, - TooltipTrigger, -} from "@vitnode/core/components/ui/tooltip"; -import { Trash2Icon } from "lucide-react"; -import { useTranslations } from "next-intl"; -import { toast } from "sonner"; - -import { CONFIG_PLUGIN } from "@/const"; - -import { mutationApi } from "./mutation-api.server"; - -export const DeleteAction = ({ title, id }: { id: number; title: string }) => { - const t = useTranslations("@vitnode/blog.admin.posts.delete"); - const tGlobal = useTranslations("core.global"); - const canDelete = useAdminStaffPermission({ - plugin: CONFIG_PLUGIN.pluginId, - module: "posts", - permission: "can_delete", - }); - - if (!canDelete) return null; - - return ( - - - ( - {title} - ), - })} - onSubmit={async ({ onClose }) => { - const mutation = await mutationApi(id); - if (mutation?.error) { - toast.error(tGlobal("errors.title"), { - description: tGlobal("errors.internal_server_error"), - }); - - return; - } - - toast.success(t("success"), { - description: title, - }); - onClose(); - }} - textSubmit={t("confirm")} - title={t("title")} - > - - - - } - /> - - - {t("title")} - - - ); -}; diff --git a/plugins/blog/src/views/admin/posts/table/actions/delete/mutation-api.server.ts b/plugins/blog/src/views/admin/posts/table/actions/delete/mutation-api.server.ts deleted file mode 100644 index 227f71823..000000000 --- a/plugins/blog/src/views/admin/posts/table/actions/delete/mutation-api.server.ts +++ /dev/null @@ -1,29 +0,0 @@ -"use server"; - -import { fetcher } from "@vitnode/core/lib/fetcher"; -import { revalidatePath } from "next/cache"; - -import { postsAdminModule } from "@/api/modules/admin/posts/posts.admin.module"; - -export const mutationApi = async (id: number) => { - const res = await fetcher(postsAdminModule, { - prefixPath: "/admin", - method: "delete", - path: "/{id}", - module: "posts", - args: { - params: { - id, - }, - }, - }); - - if (!res.ok) { - return { error: await res.text() }; - } - - revalidatePath( - "/[locale]/admin/(auth)/(plugins)/(vitnode-blog)/blog/posts", - "page", - ); -}; diff --git a/plugins/blog/src/views/admin/posts/table/actions/edit-action.tsx b/plugins/blog/src/views/admin/posts/table/actions/edit-action.tsx deleted file mode 100644 index 87343e7ab..000000000 --- a/plugins/blog/src/views/admin/posts/table/actions/edit-action.tsx +++ /dev/null @@ -1,81 +0,0 @@ -"use client"; - -import { useAdminStaffPermission } from "@vitnode/core/components/staff-permission/provider"; -import { Button } from "@vitnode/core/components/ui/button"; -import { - Dialog, - DialogContent, - DialogDescription, - DialogHeader, - DialogTitle, - DialogTrigger, -} from "@vitnode/core/components/ui/dialog"; -import { Loader } from "@vitnode/core/components/ui/loader"; -import { - Tooltip, - TooltipContent, - TooltipProvider, - TooltipTrigger, -} from "@vitnode/core/components/ui/tooltip"; -import { getLangValue } from "@vitnode/core/lib/helpers/multi-lang"; -import { PencilIcon } from "lucide-react"; -import { useLocale, useTranslations } from "next-intl"; -import dynamic from "next/dynamic"; -import React from "react"; - -import { CONFIG_PLUGIN } from "@/const"; - -const CreateEditActionPostsAdmin = dynamic(async () => - import("../../actions/create-edit/create-edit").then(mod => ({ - default: mod.CreateEditActionPostsAdmin, - })), -); - -export const EditAction = ( - props: Required>, -) => { - const t = useTranslations("@vitnode/blog.admin.posts.edit"); - const locale = useLocale(); - const canEdit = useAdminStaffPermission({ - plugin: CONFIG_PLUGIN.pluginId, - module: "posts", - permission: "can_edit", - }); - - if (!canEdit) return null; - - return ( - - - - - } - > - - - } - /> - {t("title")} - - - - - - {t("title")} - - {getLangValue(props.data.titleTranslations, locale) || - props.data.titleTranslations[0]?.value} - - - - }> - - - - - ); -}; diff --git a/plugins/blog/src/views/admin/posts/table/posts-admin-view.tsx b/plugins/blog/src/views/admin/posts/table/posts-admin-view.tsx deleted file mode 100644 index 914d5ef0c..000000000 --- a/plugins/blog/src/views/admin/posts/table/posts-admin-view.tsx +++ /dev/null @@ -1,102 +0,0 @@ -import { Avatar } from "@vitnode/core/components/avatar"; -import { DateFormat } from "@vitnode/core/components/date-format"; -import { DataTable } from "@vitnode/core/components/table/data-table"; -import { fetcher } from "@vitnode/core/lib/fetcher"; -import { getLangValue } from "@vitnode/core/lib/helpers/multi-lang"; -import { getLocale, getTranslations } from "next-intl/server"; - -import { postsModule } from "@/api/modules/posts/posts.module"; - -import { DeleteAction } from "./actions/delete/delete-action"; -import { EditAction } from "./actions/edit-action"; - -export const PostsAdminView = async ({ - searchParams, -}: { - searchParams: Promise>; -}) => { - const t = await getTranslations("@vitnode/blog.admin.posts.table"); - const locale = await getLocale(); - const query = await searchParams; - const res = await fetcher(postsModule, { - path: "/", - method: "get", - module: "posts", - args: { - query, - }, - withPagination: true, - options: { - cache: "force-cache", - }, - }); - const data = await res.json(); - - return ( - - getLangValue(row.category.titleTranslations, locale) || - row.category.titleTranslations[0]?.value || - "", - }, - { - accessorKey: "author", - header: t("author"), - className: "w-48", - cell: ({ row }) => - row.author ? ( -
- - {row.author.name} -
- ) : ( - — - ), - }, - { - accessorKey: "updatedAt", - header: t("updated_at"), - className: "w-48", - cell: ({ row }) => , - }, - { - id: "actions", - header: "", - align: "right", - className: "w-10", - cell: ({ row }) => ( - <> - - - - ), - }, - ]} - edges={data.edges.map(edge => ({ - ...edge, - title: - getLangValue(edge.titleTranslations, locale) || - edge.titleTranslations[0]?.value || - "", - }))} - id="posts-table" - order={{ - columns: ["createdAt", "updatedAt"], - defaultOrder: { - column: "createdAt", - order: "desc", - }, - }} - pageInfo={data.pageInfo} - /> - ); -}; diff --git a/plugins/blog/tsconfig.json b/plugins/blog/tsconfig.json index 0862c86ca..573bd9b55 100644 --- a/plugins/blog/tsconfig.json +++ b/plugins/blog/tsconfig.json @@ -18,9 +18,17 @@ } ], "paths": { - "@/*": ["./src/*"] + "@/*": [ + "./src/*" + ] } }, - "exclude": ["node_modules"], - "include": ["src", "global.d.ts"] + "exclude": [ + "node_modules" + ], + "include": [ + "src", + "global.d.ts", + "vitest.config.ts" + ] } diff --git a/plugins/blog/vitest.config.ts b/plugins/blog/vitest.config.ts new file mode 100644 index 000000000..3191f9009 --- /dev/null +++ b/plugins/blog/vitest.config.ts @@ -0,0 +1,24 @@ +import react from "@vitejs/plugin-react"; +import { resolve } from "node:path"; +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + plugins: [react()], + test: { + globals: true, + environment: "jsdom", + exclude: ["**/node_modules/**", "**/dist/**"], + // The migration suite drops and rebuilds the schema in its `beforeAll`, so + // it must not share a database with another file running at the same time. + fileParallelism: false, + typecheck: { + tsconfig: "./tsconfig.json", + include: ["**/*.test-d.ts"], + }, + }, + resolve: { + alias: { + "@": resolve(__dirname, "./src"), + }, + }, +}); From f5c60ac5231ad9f7d1a930ac6d46b01ec45a8c8c Mon Sep 17 00:00:00 2001 From: aXenDeveloper Date: Mon, 10 Aug 2026 11:12:58 +0200 Subject: [PATCH 3/7] docs(content): document admin form presentation and layouts A new page for dialog-vs-page and custom layouts, with the blog as the worked example of both ends: a category with a colour picker and a colour cell, and an article with a page-mode editor and `AutoFormEditor`. Says plainly what a layout is not: it decides where the fields are, and the Content Engine decides what happens when you press Save. The blog guide gains its new architecture and an upgrade section naming the two deliberate breaks, and the events reference now marks the blog's own event names as compatibility adapters over `content.blog.*`. Co-Authored-By: Claude Opus 5 (1M context) --- .../dev/content-engine/admin-form-layouts.mdx | 281 +++++++++++++++++ .../docs/dev/content-engine/admincp.mdx | 20 +- .../content/docs/dev/content-engine/meta.json | 1 + .../docs/dev/events/built-in-events.mdx | 56 +++- apps/docs/content/docs/guides/blog.mdx | 112 +++++++ pnpm-lock.yaml | 292 +++--------------- 6 files changed, 494 insertions(+), 268 deletions(-) create mode 100644 apps/docs/content/docs/dev/content-engine/admin-form-layouts.mdx diff --git a/apps/docs/content/docs/dev/content-engine/admin-form-layouts.mdx b/apps/docs/content/docs/dev/content-engine/admin-form-layouts.mdx new file mode 100644 index 000000000..acf8ff8f3 --- /dev/null +++ b/apps/docs/content/docs/dev/content-engine/admin-form-layouts.mdx @@ -0,0 +1,281 @@ +--- +title: Dialog or page, and custom layouts +description: Choose how a content type's create and edit forms appear - and rearrange them without giving up a single line of the generated behaviour. +icon: LayoutPanelLeft +--- + +The generated create and edit forms open in a dialog. That is right for most +records and wrong for the ones people spend an hour inside, so a content type can +say which it wants - and, separately, a plugin can decide where the fields go. + +The two are independent. Page mode with no layout is a perfectly good screen; a +custom layout inside a dialog works too. + +## Dialog or page + +```ts title="src/content/article.ts" +admin: { + label: { plural: "Articles", singular: "Article" }, + + create: { mode: "page" }, + edit: { mode: "page" }, +} +``` + +`"dialog"` and `"page"`, and **`"dialog"` is the default** - a content type +written before this existed behaves exactly as it did, and nothing about it +changes until somebody adds those two lines. Each action is independent: a +content type can create on a page and edit in a dialog. + +```ts +// @ts-expect-error - only "dialog" and "page" are presentation modes +create: { mode: "drawer" } +``` + +### The URLs + +Page mode is served by the **same** catch-all route as the list. There is no +second router, and no file to add: + +```text +/admin/content/blog/post list +/admin/content/blog/post/create create page +/admin/content/blog/post/42/edit edit page +``` + +The Create button becomes a link rather than a dialog trigger - none of the +form's JavaScript is downloaded until the page it points at is requested - and +the pencil in each table row becomes a link too. Typing either URL works, which +is the point of checking permissions on the server rather than on the button. + + + The slug resolves to a content type id first, and only then as a form URL. So + an id that ends in `.create` keeps its own list screen, and the create page of + its neighbour is unreachable - a name clash its author can see, rather than a + screen that silently disappeared. + + +### Permissions + +Page mode weakens nothing. The create page checks `can_view` **and** +`can_create`; the edit page checks `can_view` and then `can_edit`, or +`can_translate` on a localized content type - the same pair the edit dialog +opens for. A missing permission is a 404, whether the button was rendered or +not, and the generated route behind the form checks again. + +### After a successful save + +| Situation | What happens | +| --- | --- | +| Create, and edit is also `page` | Goes to the new record's edit page, using the id the mutation returned | +| Create, and edit is a dialog | Goes back to the list | +| Edit | Stays on the page with fresh server data | + +Everything else is unchanged: validation errors stay in the form, structured +backend errors read as sentences, success raises a `sonner` toast with a +description, version conflicts show the banner with your typing intact, and the +submit button is disabled while the write is in flight. + + + The form shows the publication state read-only, on a page exactly as in a + dialog. `status` and `publishedAt` are not in the form schema, and the publish + action on the list is the one thing that moves them - two mutation paths in one + screen is how a form ends up fighting its own state. + + +## Custom layouts + +A layout decides **where the fields are**. It does not decide what happens when +you press Save. + +The Content Engine keeps the form schema, the validation, the default values, +the field overrides, the AutoForm integration, the mutation, the version +precondition, the structured errors, the publication state, the editorial state, +the translations, the permissions, the toast, the cache invalidation, the events, +the search write and the delivery effects. All of them. A layout that called an +API directly would be doing something the engine already did, twice. + +### Registering one + +Layouts live in `buildPlugin`, next to the field and column overrides - never on +the definition, which `src/database/*.ts` imports and Drizzle Kit executes. + +```tsx title="src/config.tsx" +contentTypeAdmin({ + definition: blogPostContentType, + + fields: { + content: { component: BlogArticleEditorField }, + }, + + forms: { + layout: BlogArticleFormLayout, + }, +}); +``` + +`layout` covers both actions. Override one when they genuinely differ: + +```tsx +forms: { + layout: SharedLayout, + create: { layout: FirstDraftLayout }, +} +``` + +### Writing one + +```tsx title="src/views/admin/article/form-layout.tsx" +"use client"; + +import { + ContentFormActions, + ContentFormField, + ContentFormLayoutGrid, + ContentFormMain, + ContentFormSection, + ContentFormSidebar, + ContentFormStatus, +} from "@vitnode/core/content/admin-form"; + +export const BlogArticleFormLayout = () => ( + + + + + + + + + + + + + + + + + + + + + +); +``` + +There is one `
`, one schema and one submit path. `ContentFormField` +renders the element the engine already built - **including its field override**, +so overrides and layouts compose - and an error stays attached to the input it +belongs to wherever that input ended up. + +### The primitives + +| Primitive | What it does | +| --- | --- | +| `ContentFormField` | One field, by name. Nothing if this surface has no such field | +| `ContentFormRemainingFields` | Everything the layout did not name | +| `ContentFormActions` | The submit row, with an optional `cancelHref` | +| `ContentFormStatus` | The read-only publication line | +| `ContentFormLayoutGrid` / `Main` / `Sidebar` / `Section` | AdminCP chrome: two columns above `lg`, one below | +| `useContentForm()` | `mode`, `surface`, `fieldNames`, `publication` | + +### Localized content types get one layout, not two + +A localized content type splits its fields across two surfaces - the shared tab +and one tab per language - and the same layout is rendered in both. +`ContentFormField` renders **nothing** for a name the current surface does not +have, which is what lets a single file place `title` and `categoryId` without +knowing which table either lives on: + +```text +Shared tab → categoryId, authorId in the sidebar +English tab → title, content in the main column; friendlyUrl in the sidebar +``` + +`useContentForm().surface` is `"shared"` or `"translation"` when a layout wants +to word something differently. + +### The server/client boundary + +`config.tsx` is a **server** module, so a layout referenced from it is a client +reference crossing an RSC boundary. That decides the shape of the whole API: + +- The layout receives only **serialisable** props: `mode`, `surface`, + `contentTypeId`, `pluginId`, `itemId`, `singular`, `publication`, `title`. +- Field elements, the form instance and the submit action arrive through + **client context** instead. A `renderField(name)` callback prop would read + well and would be a server closure, which cannot cross the boundary at all. + +So: `"use client"` at the top of the layout file, and no inline arrow in +`config.tsx`. + + + In development, a layout that never renders one of its surface's fields logs + which ones - a field silently missing from the payload is the one failure mode + this API has that the generated form does not. + + +## Field and column overrides + +Both are unchanged, and both compose with everything above - see +[Overriding the AdminCP](/docs/dev/content-engine/overriding-admincp). + +The blog is the worked example of all three. A **simple** record, with a colour +picker and a colour cell: + +```tsx title="src/config.tsx" +contentTypeAdmin({ + definition: blogCategoryContentType, + fields: { color: { component: BlogCategoryColorField } }, + columns: { color: { cell: BlogCategoryColorCell } }, +}); +``` + +```tsx title="src/views/admin/category/color-cell.tsx" +"use client"; + +export const BlogCategoryColorCell = ({ row }) => + row.color ? ( +
+ + + {row.color} + +
+ ) : ( + No color + ); +``` + +The swatch is `aria-hidden` and the value beside it is real text: a cell that +communicated the colour only visually would be unreadable to a screen reader and +ambiguous to anyone who cannot tell two blues apart. + +And a **rich** one, with the editor: + +```tsx title="src/views/admin/article/editor-field.tsx" +"use client"; + +const AutoFormEditor = dynamic( + async () => + await import("@vitnode/core/components/form/fields/editor").then(mod => ({ + default: mod.AutoFormEditor, + })), + { loading: () => , ssr: false }, +); + +export const BlogArticleEditorField = (props: ItemAutoFormComponentProps) => ( + }> + + +); +``` + +`field.value` and `field.onChange` are the whole integration. The editor is one +input in the same `react-hook-form` instance as the title and the category, so +dirty state and validation work without a single line about them - and the Tiptap +bundle arrives with the editor rather than with the page. diff --git a/apps/docs/content/docs/dev/content-engine/admincp.mdx b/apps/docs/content/docs/dev/content-engine/admincp.mdx index f006e0ab7..5cee7e76a 100644 --- a/apps/docs/content/docs/dev/content-engine/admincp.mdx +++ b/apps/docs/content/docs/dev/content-engine/admincp.mdx @@ -29,7 +29,9 @@ You get a nav item, a breadcrumb, and a screen at: - **Sorting** - `admin.list.orderableFields`, plus the system columns and the publication ones ([below](#what-is-sortable)) - **Pagination** - the standard cursor pagination, capped at 100 per page -- **Create / Edit** - `AutoForm` dialogs, lazy-loaded on open +- **Create / Edit** - `AutoForm` dialogs, lazy-loaded on open - or full pages, + with `admin.create.mode` / `admin.edit.mode` + ([below](#dialog-or-page)) - **Delete** - a confirmation dialog - **History** - with [`editorial`](#editorial): every version, a diff, and restore - **Empty, loading and error states** - out of the box @@ -55,6 +57,22 @@ chunks, so it is downloaded once: milliseconds of theatre either way. +## Dialog or page + +The forms open in a dialog by default. A content type people spend an hour +inside can ask for a page instead, and a plugin can rearrange either without +giving up any of the generated behaviour: + +```ts +admin: { + create: { mode: "page" }, + edit: { mode: "page" }, +} +``` + +`"dialog"` is the default and stays the default. See +[Dialog or page, and custom layouts](/docs/dev/content-engine/admin-form-layouts). + ## What is sortable The table header offers a sort control for every column the generated route diff --git a/apps/docs/content/docs/dev/content-engine/meta.json b/apps/docs/content/docs/dev/content-engine/meta.json index 71e167fdc..209a3a733 100644 --- a/apps/docs/content/docs/dev/content-engine/meta.json +++ b/apps/docs/content/docs/dev/content-engine/meta.json @@ -49,6 +49,7 @@ "permissions", "events", "overriding-admincp", + "admin-form-layouts", "production-hardening", "concurrency", "failure-and-retries", diff --git a/apps/docs/content/docs/dev/events/built-in-events.mdx b/apps/docs/content/docs/dev/events/built-in-events.mdx index 522971ecd..a41726738 100644 --- a/apps/docs/content/docs/dev/events/built-in-events.mdx +++ b/apps/docs/content/docs/dev/events/built-in-events.mdx @@ -18,10 +18,10 @@ the emitting plugin are needed, the event map is global. | `role.deleted` | `{ roleId }` | A role is deleted in the AdminCP | | `blog.post.created` | `{ postId, categoryId }` | A blog post is created | | `blog.post.updated` | `{ postId, categoryId }` | A blog post is edited | -| `blog.post.deleted` | `{ postId, categoryId }` | A blog post is deleted | +| `blog.post.deleted` | `{ postId }` | A blog post is deleted | | `blog.category.created` | `{ categoryId }` | A blog category is created | | `blog.category.updated` | `{ categoryId }` | A blog category is edited | -| `blog.category.deleted` | `{ categoryId, postIds }` | A blog category (and its posts, via cascade) is deleted | +| `blog.category.deleted` | `{ categoryId, postIds }` | A blog category is deleted (`postIds` is always empty) | ## Core @@ -166,10 +166,19 @@ themselves, and core will emit it once deletion lands. ## Blog (`@vitnode/blog`) + + The blog runs on the [Content + Engine](/docs/dev/content-engine), so the events that describe what actually + happened are `content.blog.post.*` and `content.blog.category.*` - they carry + changed fields, revision ids, publication transitions, per-locale translation + events and slug history. The four names below are re-emitted from those by + listeners in the plugin, so existing consumers keep working. Prefer the + `content.*` ones for anything new. + + ### blog.category.created / blog.category.updated -Emitted after a category (and its translated titles) is created or edited in -the AdminCP. +Re-emitted after `content.blog.category.created` / `.updated`. + + + The row is gone by the time this is emitted, so there is nothing left to read + it from - and inventing one would put a wrong id into an audit trail. A + listener that needs the category should watch `content.blog.post.deleted` and + keep its own index. + + ### blog.category.deleted -Emitted after a category is deleted. Deleting a category cascade-deletes its -posts at the database level, so the payload carries the ids of the posts that -were removed with it. +Re-emitted after `content.blog.category.deleted`. -**Use cases:** the blog plugin itself ships a listener on this event -(`cleanup-category-search`) that removes the cascade-deleted posts from the -search index - a good template for cleaning up any data your plugin keys by -post id. - ## Content Engine events Every content type declared with the diff --git a/apps/docs/content/docs/guides/blog.mdx b/apps/docs/content/docs/guides/blog.mdx index 86cbf5f65..3bbfe4b21 100644 --- a/apps/docs/content/docs/guides/blog.mdx +++ b/apps/docs/content/docs/guides/blog.mdx @@ -85,3 +85,115 @@ npm run dev + +## How it is built + +The blog is the Content Engine's reference implementation. Two content types, +three component overrides and one layout - and no CRUD of its own: + +```text +plugins/blog/src/ +├── content/category.ts Blog Category (blog.category) +├── content/post.ts Blog Article (blog.post) +├── database/{categories,posts}.ts createContentModel(...) +├── config.tsx contentTypeAdmin(...) x2 +└── views/admin/ + ├── category/color-field.tsx AutoFormColor override + ├── category/color-cell.tsx table cell override + ├── article/editor-field.tsx AutoFormEditor override + └── article/form-layout.tsx the editor screen +``` + +There is no `api/modules/admin/**`, no create/edit dialog, no manual validation, +no manual search sync and no hand-written slug uniqueness check. The generated +routes, forms, permissions, events, search documents and canonical URLs come +from the two definitions. + +### Categories - the simple example + +Dialog create and edit, because a name and a colour do not need a page: + +```ts title="src/content/category.ts" +export const blogCategoryContentType = defineContentType({ + id: "blog.category", + tableName: "blog_categories", + + localization: { enabled: true, defaultLocale: "en", fallback: "default" }, + + fields: { + color: field.text({ maxLength: 50, nullable: true }), + name: field.text({ localized: true, required: true, maxLength: 100 }), + }, + + admin: { + label: { plural: "Categories", singular: "Category" }, + permissionModule: "categories", + titleField: null, + create: { mode: "dialog" }, + edit: { mode: "dialog" }, + list: { columns: ["color", "updatedAt"] }, + }, +}); +``` + +The colour is the AdminCP's own picker through a +[field override](/docs/dev/content-engine/admin-form-layouts#field-and-column-overrides), +and the colour column is a swatch **plus** the value in words. + +### Articles - the rich example + +Page create and edit, a custom layout, `AutoFormEditor` for the body, a native +relation to the category, an author, publication, editorial history, search and +delivery: + +```text +/admin/content/blog/post list +/admin/content/blog/post/create create +/admin/content/blog/post/42/edit edit +``` + +```text +┌───────────────────────────────────────────────────────┐ +│ Title │ Publish │ +│ [............................] │ Status: Draft │ +│ │ [ Save ] │ +│ Content ├─────────────────────── +│ ┌────────────────────────────┐ │ Article settings │ +│ │ AutoFormEditor │ │ Friendly URL │ +│ └────────────────────────────┘ │ Category │ +│ │ Author │ +└───────────────────────────────────────────────────────┘ +``` + +Below `lg` it is a single column: body first, then metadata, then the actions. + +Articles are localized, so the editor is the same layout on the shared tab and +on each language tab - `ContentFormField` renders nothing for a field the +current surface does not have, so `title` and `content` appear per language +while `categoryId` and `authorId` appear once. + +### Upgrading from an older blog + +Migration `0035_migrate_blog_to_content_engine.sql` is additive. No table is +dropped and no record moves: + +- `blog_categories` and `blog_posts` keep their names, ids, colours, categories, + authors and timestamps. +- The text moves out of `core_languages_words` and into + `blog_categories_translations` / `blog_posts_translations`, one row per + language that actually had a translation. +- Every existing article becomes `published` with `publishedAt = createdAt` - + they were all publicly readable before, and that is the one publication fact + the old schema can prove. `version` starts at 1 and no revision history is + invented. +- A record with no default-locale translation gets one built from a name it + already has, rather than being left unreadable. + +Two things do change, deliberately: + +| Was | Now | +| --- | --- | +| `GET /api/@vitnode/blog/posts` | `GET /api/@vitnode/blog/content/blog` | +| `GET /api/@vitnode/blog/categories` | removed - categories have no public URL | +| `/admin/blog/posts`, `/admin/blog/categories` | redirect to the generated screens | +| `blog.post.deleted` carried `categoryId` | it does not; see [Built-in events](/docs/dev/events/built-in-events#blogpostdeleted) | diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7c394030b..86cb86a73 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -620,7 +620,7 @@ importers: version: 4.12.2(react-dom@19.2.8(react@19.2.8))(react@19.2.8) react-scan: specifier: ^0.5.7 - version: 0.5.7(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)(@opentelemetry/core@2.10.0(@opentelemetry/api@1.9.1))(@types/react@19.2.17)(esbuild@0.27.7)(eslint@10.7.0(jiti@2.7.0))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(rolldown@1.1.5)(rollup@4.62.2)(vite@8.1.5(@types/node@26.1.1)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0)) + version: 0.5.7(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)(@opentelemetry/core@2.10.0(@opentelemetry/api@1.9.1))(esbuild@0.27.7)(eslint@10.7.0(jiti@2.7.0))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(rolldown@1.1.5)(rollup@4.62.2)(vite@8.1.5(@types/node@26.1.1)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0)) recharts: specifier: ^3.10.0 version: 3.10.0(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react-is@17.0.2)(react@19.2.8)(redux@5.0.1) @@ -818,24 +818,42 @@ importers: '@swc/core': specifier: ^1.15.46 version: 1.15.46 + '@testing-library/dom': + specifier: ^10.4.1 + version: 10.4.1 + '@testing-library/react': + specifier: ^16.3.2 + version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) '@types/react': specifier: ^19.2.17 version: 19.2.17 '@types/react-dom': specifier: ^19.2.3 version: 19.2.3(@types/react@19.2.17) + '@vitejs/plugin-react': + specifier: ^6.0.4 + version: 6.0.4(babel-plugin-react-compiler@1.0.0)(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0)) '@vitnode/config': specifier: workspace:* version: link:../../packages/config eslint: specifier: ^10.7.0 version: 10.7.0(jiti@2.7.0) + jsdom: + specifier: ^29.1.1 + version: 29.1.1 + postgres: + specifier: ^3.4.9 + version: 3.4.9 tsc-alias: specifier: ^1.9.1 version: 1.9.1 typescript: specifier: ^6.0.3 version: 6.0.3 + vitest: + specifier: ^4.1.10 + version: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@26.1.1)(@vitest/coverage-v8@4.1.10)(jsdom@29.1.1)(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0)) plugins/example: dependencies: @@ -940,10 +958,6 @@ packages: resolution: {integrity: sha512-e0CpNWJUY7OxAFAnCZkw+ri9QOHWwTs1tXP42782KFGCU07qt8NiXCrCVowyCB5dP2r5/Uls+g2oPd8kOJn9dw==} engines: {node: '>=22'} - '@alcalzone/ansi-tokenize@0.3.0': - resolution: {integrity: sha512-p+CMKJ93HFmLkjXKlXiVGlMQEuRb6H0MokBSwUsX+S6BRX8eV5naFZpQJFfJHjRZY0Hmnqy1/r6UWl3x+19zYA==} - engines: {node: '>=18'} - '@alloc/quick-lru@5.2.0': resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==} engines: {node: '>=10'} @@ -4977,10 +4991,6 @@ packages: resolution: {integrity: sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==} engines: {node: '>=6'} - ansi-escapes@7.3.0: - resolution: {integrity: sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg==} - engines: {node: '>=18'} - ansi-regex@5.0.1: resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} engines: {node: '>=8'} @@ -5075,10 +5085,6 @@ packages: atomically@2.1.1: resolution: {integrity: sha512-P4w9o2dqARji6P7MHprklbfiArZAWvo07yW7qs3pdljb3BWr12FIB7W+p0zJiuiVsUpRO0iZn1kFFcpPegg0tQ==} - auto-bind@5.0.1: - resolution: {integrity: sha512-ooviqdwwgfIfNmDwo94wlshcdzfO64XV0Cg6oDsDYBJfITDz1EngD2z7DkbvCWn+XIMsIqW27sEVF6qcpJrRcg==} - engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} - available-typed-arrays@1.0.7: resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==} engines: {node: '>= 0.4'} @@ -5288,14 +5294,6 @@ packages: class-variance-authority@0.7.1: resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==} - cli-boxes@4.0.1: - resolution: {integrity: sha512-5IOn+jcCEHEraYolBPs/sT4BxYCe2nHg374OPiItB1O96KZFseS2gthU4twyYzeDcFew4DaUM/xwc5BQf08JJw==} - engines: {node: '>=18.20 <19 || >=20.10'} - - cli-cursor@4.0.0: - resolution: {integrity: sha512-VGtlMu3x/4DOtIUwEkRezxUZ2lBacNJCHash0N0WeZDBS+7Ux1dm3XWAgWYxLJFMMdOeXMHXorshEFhbMSGelg==} - engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} - cli-cursor@5.0.0: resolution: {integrity: sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==} engines: {node: '>=18'} @@ -5308,10 +5306,6 @@ packages: resolution: {integrity: sha512-bXfOC4QcT1tKXGorxL3wbJm6XJPDqEnij2gQ2m7ESQuE+/z9YFIWnl/5RpTiKWbMq3EVKR4fRLJGn6DVfu0mpw==} engines: {node: '>=18.20'} - cli-truncate@6.1.1: - resolution: {integrity: sha512-06p9vyLahLa4zkGcgsGxU6iEkSOiuI4fhCH6Emhe2lPAcoUv73n72DnODsnHA+5wwXGnV0n9M9/qOQJSjYhFhw==} - engines: {node: '>=22'} - cli-width@4.1.0: resolution: {integrity: sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==} engines: {node: '>= 12'} @@ -5344,10 +5338,6 @@ packages: code-block-writer@13.0.3: resolution: {integrity: sha512-Oofo0pq3IKnsFtuHqSF7TqBfr71aeyZDVJ0HpmqB7FBM2qEigL0iPONSCZSO9pE9dZTAxANe5XHG9Uy0YMv8cg==} - code-excerpt@4.0.0: - resolution: {integrity: sha512-xxodCmBen3iy2i0WtAK8FlFNrRzjUqjRsMfho58xT/wvZU1YTM3fCnRjcy1gJPMepaRlgm/0e6w8SpWHpn3/cA==} - engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} - collapse-white-space@2.1.0: resolution: {integrity: sha512-loKTxY1zCOuG4j9f6EPnuyyYkf58RnhhWTvRoZEokgB+WbdXehfjFviyOVYkqzEWz1Q5kRiZdBYS5SwxbQYwzw==} @@ -5445,10 +5435,6 @@ packages: convert-source-map@2.0.0: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} - convert-to-spaces@2.0.1: - resolution: {integrity: sha512-rcQ1bsQO9799wq24uE5AM2tAILy4gXGIK/njFWcVQkGNZ96edlpY+A7bjwvzjYvLDyzmG1MmMLZhpcsb+klNMQ==} - engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} - cookie-signature@1.2.2: resolution: {integrity: sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==} engines: {node: '>=6.6.0'} @@ -5655,8 +5641,8 @@ packages: resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} engines: {node: '>=6'} - deslop-js@0.9.3: - resolution: {integrity: sha512-sJcR5LLnEG+w58Oy5CdZfwAfm8XiERbXp9c941Aoeb8JmBHk/56TbZQlLJseJtClg0dkn88wpmnI82+iF0jagg==} + deslop-js@0.9.11: + resolution: {integrity: sha512-0hXU8GImv3uJZY25jeXQ1JOcajpuKyzdNTK7FTyxyGR/GtjpGPmiVM+8d+5Tacb702UzVtAHhB5xMBl/pOGxKQ==} detect-libc@2.1.2: resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} @@ -5872,10 +5858,6 @@ packages: resolution: {integrity: sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} - environment@1.1.0: - resolution: {integrity: sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==} - engines: {node: '>=18'} - error-ex@1.3.4: resolution: {integrity: sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==} @@ -5953,10 +5935,6 @@ packages: escape-html@1.0.3: resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==} - escape-string-regexp@2.0.0: - resolution: {integrity: sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==} - engines: {node: '>=8'} - escape-string-regexp@4.0.0: resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} engines: {node: '>=10'} @@ -6710,33 +6688,9 @@ packages: resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} engines: {node: '>=0.8.19'} - indent-string@5.0.0: - resolution: {integrity: sha512-m6FAo/spmsW2Ab2fU35JTYwtOKa2yAwXSwgjSv1TJzh4Mh7mC3lzAOVLBprb72XsTrgkEIsl7YrFNAiDiRhIGg==} - engines: {node: '>=12'} - inherits@2.0.4: resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} - ink-spinner@5.0.0: - resolution: {integrity: sha512-EYEasbEjkqLGyPOUc8hBJZNuC5GvXGMLu0w5gdTNskPc7Izc5vO3tdQEYnzvshucyGCBXc86ig0ujXPMWaQCdA==} - engines: {node: '>=14.16'} - peerDependencies: - ink: '>=4.0.0' - react: '>=18.0.0' - - ink@7.1.1: - resolution: {integrity: sha512-Y43xxa1ZSPvpmfLHcN5o+OdP8Rf8ykkNJEuKYOUNZKT8wXVNLFTtEm1nSDMQkfBH+YANF4Xuu0hhZ4ejqAtN2w==} - engines: {node: '>=22'} - peerDependencies: - '@types/react': '>=19.2.0' - react: '>=19.2.0' - react-devtools-core: '>=6.1.2' - peerDependenciesMeta: - '@types/react': - optional: true - react-devtools-core: - optional: true - inline-style-parser@0.2.7: resolution: {integrity: sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==} @@ -6841,10 +6795,6 @@ packages: resolution: {integrity: sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg==} engines: {node: '>= 0.4'} - is-fullwidth-code-point@5.1.0: - resolution: {integrity: sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ==} - engines: {node: '>=18'} - is-generator-function@1.1.2: resolution: {integrity: sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==} engines: {node: '>= 0.4'} @@ -6856,11 +6806,6 @@ packages: is-hexadecimal@2.0.1: resolution: {integrity: sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==} - is-in-ci@2.0.0: - resolution: {integrity: sha512-cFeerHriAnhrQSbpAxL37W1wcJKUUX07HyLWZCW1URJT/ra3GyUTzBgUnh24TMVfNTV2Hij2HLxkPHFZfOZy5w==} - engines: {node: '>=20'} - hasBin: true - is-in-ssh@1.0.0: resolution: {integrity: sha512-jYa6Q9rH90kR1vKB6NM7qqd1mge3Fx4Dhw5TVlK1MUBqhEOuCagrEHMevNuCcbECmXZ0ThXkRm+Ymr51HwEPAw==} engines: {node: '>=20'} @@ -7866,8 +7811,8 @@ packages: oxc-resolver@11.24.2: resolution: {integrity: sha512-FY91FiDBj7ls5MsFS9jN3tjz2o0/zsdSsymlakySaBwVJZorHhkWyICLZMKxlu1R9vYo+sd3z1jwb4J8x7bNDw==} - oxlint-plugin-react-doctor@0.9.3: - resolution: {integrity: sha512-7XDOw+zjVquh0yqX3zvGQC2zzWIp2rXyMjLDqFzzQ8t7TZXfIQ6ttQhBwckQSk4c7kD7Cy9If0F4LI4XXI4Gsg==} + oxlint-plugin-react-doctor@0.9.11: + resolution: {integrity: sha512-ZhW15wfFjQlUwAO6zG3jVZKse4/PBTCArhbibiidHmTlvOpPHPM1AjdYG13023pfsbbtVdy7Tb7KHfqbKt8rHg==} engines: {node: ^20.19.0 || >=22.13.0} oxlint@1.76.0: @@ -7946,10 +7891,6 @@ packages: resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} engines: {node: '>= 0.8'} - patch-console@2.0.0: - resolution: {integrity: sha512-0YNdUceMdaQwoKce1gatDScmMo5pu/tfABfnzEqeG0gtTmd7mh/WcwgUjtAeOU7N8nFFlbQBnFK2gXW5fGvmMA==} - engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} - path-browserify@1.0.1: resolution: {integrity: sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==} @@ -8272,8 +8213,8 @@ packages: '@types/react': optional: true - react-doctor@0.9.3: - resolution: {integrity: sha512-s8kWwfFKZA3e9AT5wwFilQNjxKFP30ceFIXOZDrmLPstFodbHOpE0Mv+fEY0/04uZbhRdVYKoaHYb+yaAIEwPw==} + react-doctor@0.9.11: + resolution: {integrity: sha512-y5DQ+ILL6mawXpKtAvJYrrqznl6nasXd4Xs9JGJsY4GWlplzs5UCozKaZgFS5AWzZ7KNrX88GZTuV4Vj47k+IQ==} engines: {node: ^20.19.0 || >=22.13.0} hasBin: true @@ -8308,12 +8249,6 @@ packages: react-is@17.0.2: resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==} - react-reconciler@0.33.0: - resolution: {integrity: sha512-KetWRytFv1epdpJc3J4G75I4WrplZE5jOL7Yq0p34+OVOKF4Se7WrdIdVC45XsSSmUTlht2FM/fM1FZb1mfQeA==} - engines: {node: '>=0.10.0'} - peerDependencies: - react: ^19.2.0 - react-redux@9.3.0: resolution: {integrity: sha512-KQopgqFo/p/fgmAs5qz6p5RWaNAzq40WAu7fJIXnQpYxFPbJYtsJPWvGeF2rOBaY/kEuV77AVsX8TsQzKm+A/g==} peerDependencies: @@ -8385,10 +8320,6 @@ packages: react: '*' react-dom: '*' - react@19.2.5: - resolution: {integrity: sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA==} - engines: {node: '>=0.10.0'} - react@19.2.8: resolution: {integrity: sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==} engines: {node: '>=0.10.0'} @@ -8529,10 +8460,6 @@ packages: resolution: {integrity: sha512-cGk8IbWEAnaCpdAt1BHzJ3Ahz5ewDJa0KseTsE3qIRMJ3C698W8psM7byCeWVpd/Ha7FUYzuRVzXoKoM6nRUbA==} engines: {node: '>=20'} - restore-cursor@4.0.0: - resolution: {integrity: sha512-I9fPXU9geO9bHOt9pHHOhOkYerIMsmVaWB0rA2AI9ERh/+x/i7MV5HKBNrg+ljO5eoPVgCcnFuRjJ9uH6I/3eg==} - engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} - restore-cursor@5.1.0: resolution: {integrity: sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==} engines: {node: '>=18'} @@ -8731,10 +8658,6 @@ packages: resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==} engines: {node: '>=8'} - slice-ansi@9.0.0: - resolution: {integrity: sha512-SO/3iYL5S3W57LLEniscOGPZgOqZUPCx6d3dB+52B80yJ0XstzsC/eV8gnA4tM3MHDrKz+OCFSLNjswdSC+/bA==} - engines: {node: '>=22'} - socket.io-adapter@2.5.8: resolution: {integrity: sha512-6Oy52pbg+kvdCVvjcN+FnY7BvxZ7cIHNScbvztT/It5d0vbwoJoVZmF2gjJmnV0/4WlXRfG15zc45ySk9Ah8bw==} @@ -8785,10 +8708,6 @@ packages: stack-generator@2.0.10: resolution: {integrity: sha512-mwnua/hkqM6pF4k8SnmZ2zfETsRUpWXREfA/goT8SLCV4iOFa4bzOX2nDipWAZFPTjLvQB82f5yaodMVhK0yJQ==} - stack-utils@2.0.6: - resolution: {integrity: sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==} - engines: {node: '>=10'} - stackback@0.0.2: resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} @@ -8973,10 +8892,6 @@ packages: tar-stream@3.1.7: resolution: {integrity: sha512-qJj60CXt7IU1Ffyc3NJMjh6EkuCFej46zUqJ4J7pqYlThyd9bO0XBTmcOIhSzZJVWfsLks0+nle/j538YAW9RQ==} - terminal-size@4.0.1: - resolution: {integrity: sha512-avMLDQpUI9I5XFrklECw1ZEUPJhqzcwSWsyyI8blhRLT+8N1jLJWLWWYQpB2q2xthq8xDvjZPISVh53T/+CLYQ==} - engines: {node: '>=18'} - text-decoder@1.2.7: resolution: {integrity: sha512-vlLytXkeP4xvEq2otHeJfSQIRyWxo/oZGEbXrtEEF9Hnmrdly59sUbzZ/QgyWuLYHctCHxFF4tRQZNQ9k60ExQ==} @@ -9515,18 +9430,10 @@ packages: engines: {node: '>=8'} hasBin: true - widest-line@6.0.0: - resolution: {integrity: sha512-U89AsyEeAsyoF0zVJBkG9zBgekjgjK7yk9sje3F4IQpXBJ10TF6ByLlIfjMhcmHMJgHZI4KHt4rdNfktzxIAMA==} - engines: {node: '>=20'} - word-wrap@1.2.5: resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} engines: {node: '>=0.10.0'} - wrap-ansi@10.0.0: - resolution: {integrity: sha512-SGcvg80f0wUy2/fXES19feHMz8E0JoXv2uNgHOu4Dgi2OrCy1lqwFYEJz1BLbDI0exjPMe/ZdzZ/YpGECBG/aQ==} - engines: {node: '>=20'} - wrap-ansi@9.0.2: resolution: {integrity: sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==} engines: {node: '>=18'} @@ -9667,11 +9574,6 @@ snapshots: dependencies: json-schema: 0.4.0 - '@alcalzone/ansi-tokenize@0.3.0': - dependencies: - ansi-styles: 6.2.3 - is-fullwidth-code-point: 5.1.0 - '@alloc/quick-lru@5.2.0': {} '@apm-js-collab/code-transformer-bundler-plugins@0.7.2': @@ -12917,6 +12819,13 @@ snapshots: optionalDependencies: babel-plugin-react-compiler: 1.0.0 + '@vitejs/plugin-react@6.0.4(babel-plugin-react-compiler@1.0.0)(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0))': + dependencies: + '@rolldown/pluginutils': 1.0.1 + vite: 8.1.5(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0) + optionalDependencies: + babel-plugin-react-compiler: 1.0.0 + '@vitest/coverage-v8@4.1.10(vitest@4.1.10)': dependencies: '@bcoe/v8-coverage': 1.0.2 @@ -13183,10 +13092,6 @@ snapshots: ansi-colors@4.1.3: {} - ansi-escapes@7.3.0: - dependencies: - environment: 1.1.0 - ansi-regex@5.0.1: {} ansi-regex@6.2.2: {} @@ -13289,8 +13194,6 @@ snapshots: stubborn-fs: 2.0.0 when-exit: 2.1.5 - auto-bind@5.0.1: {} - available-typed-arrays@1.0.7: dependencies: possible-typed-array-names: 1.1.0 @@ -13486,12 +13389,6 @@ snapshots: dependencies: clsx: 2.1.1 - cli-boxes@4.0.1: {} - - cli-cursor@4.0.0: - dependencies: - restore-cursor: 4.0.0 - cli-cursor@5.0.0: dependencies: restore-cursor: 5.1.0 @@ -13500,11 +13397,6 @@ snapshots: cli-spinners@3.4.0: {} - cli-truncate@6.1.1: - dependencies: - slice-ansi: 9.0.0 - string-width: 8.2.2 - cli-width@4.1.0: {} client-only@0.0.1: {} @@ -13535,10 +13427,6 @@ snapshots: code-block-writer@13.0.3: {} - code-excerpt@4.0.0: - dependencies: - convert-to-spaces: 2.0.1 - collapse-white-space@2.1.0: {} comma-separated-tokens@2.0.3: {} @@ -13619,8 +13507,6 @@ snapshots: convert-source-map@2.0.0: {} - convert-to-spaces@2.0.1: {} - cookie-signature@1.2.2: {} cookie@0.7.2: {} @@ -13801,7 +13687,7 @@ snapshots: dequal@2.0.3: {} - deslop-js@0.9.3(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1): + deslop-js@0.9.11(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1): dependencies: '@oxc-project/types': 0.142.0 fast-glob: 3.3.3 @@ -13938,8 +13824,6 @@ snapshots: env-paths@3.0.0: {} - environment@1.1.0: {} - error-ex@1.3.4: dependencies: is-arrayish: 0.2.1 @@ -14174,8 +14058,6 @@ snapshots: escape-html@1.0.3: {} - escape-string-regexp@2.0.0: {} - escape-string-regexp@4.0.0: {} escape-string-regexp@5.0.0: {} @@ -15139,50 +15021,8 @@ snapshots: imurmurhash@0.1.4: {} - indent-string@5.0.0: {} - inherits@2.0.4: {} - ink-spinner@5.0.0(ink@7.1.1(@types/react@19.2.17)(react@19.2.8))(react@19.2.5): - dependencies: - cli-spinners: 2.9.2 - ink: 7.1.1(@types/react@19.2.17)(react@19.2.5) - react: 19.2.5 - - ink@7.1.1(@types/react@19.2.17)(react@19.2.5): - dependencies: - '@alcalzone/ansi-tokenize': 0.3.0 - ansi-escapes: 7.3.0 - ansi-styles: 6.2.3 - auto-bind: 5.0.1 - chalk: 5.6.2 - cli-boxes: 4.0.1 - cli-cursor: 4.0.0 - cli-truncate: 6.1.1 - code-excerpt: 4.0.0 - es-toolkit: 1.49.0 - indent-string: 5.0.0 - is-in-ci: 2.0.0 - patch-console: 2.0.0 - react: 19.2.5 - react-reconciler: 0.33.0(react@19.2.5) - scheduler: 0.27.0 - signal-exit: 3.0.7 - slice-ansi: 9.0.0 - stack-utils: 2.0.6 - string-width: 8.2.2 - terminal-size: 4.0.1 - type-fest: 5.8.0 - widest-line: 6.0.0 - wrap-ansi: 10.0.0 - ws: 8.21.1 - yoga-layout: 3.2.1 - optionalDependencies: - '@types/react': 19.2.17 - transitivePeerDependencies: - - bufferutil - - utf-8-validate - inline-style-parser@0.2.7: {} inline-style-prefixer@7.0.1: @@ -15292,10 +15132,6 @@ snapshots: dependencies: call-bound: 1.0.4 - is-fullwidth-code-point@5.1.0: - dependencies: - get-east-asian-width: 1.6.0 - is-generator-function@1.1.2: dependencies: call-bound: 1.0.4 @@ -15310,8 +15146,6 @@ snapshots: is-hexadecimal@2.0.1: {} - is-in-ci@2.0.0: {} - is-in-ssh@1.0.0: {} is-inside-container@1.0.0: @@ -16535,12 +16369,13 @@ snapshots: - '@emnapi/core' - '@emnapi/runtime' - oxlint-plugin-react-doctor@0.9.3(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1): + oxlint-plugin-react-doctor@0.9.11(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1): dependencies: '@shaderfrog/glsl-parser': 7.0.1 '@typescript-eslint/types': 8.65.0 eslint-scope: 9.1.2 eslint-visitor-keys: 5.0.1 + lightningcss: 1.33.0 oxc-parser: 0.142.0(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1) transitivePeerDependencies: - '@emnapi/core' @@ -16634,8 +16469,6 @@ snapshots: parseurl@1.3.3: {} - patch-console@2.0.0: {} - path-browserify@1.0.1: {} path-exists@3.0.0: {} @@ -16890,7 +16723,7 @@ snapshots: optionalDependencies: '@types/react': 19.2.17 - react-doctor@0.9.3(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)(@opentelemetry/core@2.10.0(@opentelemetry/api@1.9.1))(@types/react@19.2.17)(eslint@10.7.0(jiti@2.7.0)): + react-doctor@0.9.11(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)(@opentelemetry/core@2.10.0(@opentelemetry/api@1.9.1))(eslint@10.7.0(jiti@2.7.0)): dependencies: '@astrojs/compiler': 4.0.0 '@babel/code-frame': 7.29.7 @@ -16898,35 +16731,29 @@ snapshots: agent-install: 0.0.5 conf: 15.1.0 confbox: 0.2.4 - deslop-js: 0.9.3(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1) + deslop-js: 0.9.11(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1) eslint-plugin-react-hooks: 7.1.1(eslint@10.7.0(jiti@2.7.0)) figures: 6.1.0 - ink: 7.1.1(@types/react@19.2.17)(react@19.2.5) - ink-spinner: 5.0.0(ink@7.1.1(@types/react@19.2.17)(react@19.2.8))(react@19.2.5) jiti: 2.7.0 magicast: 0.5.3 oxc-resolver: 11.24.2(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1) oxlint: 1.76.0 - oxlint-plugin-react-doctor: 0.9.3(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1) + oxlint-plugin-react-doctor: 0.9.11(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1) prompts: 2.4.2 - react: 19.2.5 typescript: 5.9.3 vscode-languageserver: 9.0.1 vscode-languageserver-textdocument: 1.0.12 vscode-uri: 3.1.0 yaml: 2.9.0 + yoga-layout: 3.2.1 transitivePeerDependencies: - '@emnapi/core' - '@emnapi/runtime' - '@opentelemetry/core' - '@opentelemetry/exporter-trace-otlp-http' - - '@types/react' - - bufferutil - eslint - oxlint-tsgolint - - react-devtools-core - supports-color - - utf-8-validate - vite-plus react-dom@19.2.8(react@19.2.8): @@ -16978,11 +16805,6 @@ snapshots: react-is@17.0.2: {} - react-reconciler@0.33.0(react@19.2.5): - dependencies: - react: 19.2.5 - scheduler: 0.27.0 - react-redux@9.3.0(@types/react@19.2.17)(react@19.2.8)(redux@5.0.1): dependencies: '@types/use-sync-external-store': 0.0.6 @@ -17016,7 +16838,7 @@ snapshots: react: 19.2.8 react-dom: 19.2.8(react@19.2.8) - react-scan@0.5.7(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)(@opentelemetry/core@2.10.0(@opentelemetry/api@1.9.1))(@types/react@19.2.17)(esbuild@0.27.7)(eslint@10.7.0(jiti@2.7.0))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(rolldown@1.1.5)(rollup@4.62.2)(vite@8.1.5(@types/node@26.1.1)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0)): + react-scan@0.5.7(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)(@opentelemetry/core@2.10.0(@opentelemetry/api@1.9.1))(esbuild@0.27.7)(eslint@10.7.0(jiti@2.7.0))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(rolldown@1.1.5)(rollup@4.62.2)(vite@8.1.5(@types/node@26.1.1)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0)): dependencies: '@babel/core': 7.29.7 '@babel/types': 7.29.7 @@ -17028,7 +16850,7 @@ snapshots: preact: 10.29.7 prompts: 2.4.2 react: 19.2.8 - react-doctor: 0.9.3(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)(@opentelemetry/core@2.10.0(@opentelemetry/api@1.9.1))(@types/react@19.2.17)(eslint@10.7.0(jiti@2.7.0)) + react-doctor: 0.9.11(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)(@opentelemetry/core@2.10.0(@opentelemetry/api@1.9.1))(eslint@10.7.0(jiti@2.7.0)) react-dom: 19.2.8(react@19.2.8) react-grab: 0.1.50(react@19.2.8) optionalDependencies: @@ -17041,18 +16863,14 @@ snapshots: - '@opentelemetry/core' - '@opentelemetry/exporter-trace-otlp-http' - '@rspack/core' - - '@types/react' - - bufferutil - bun-types-no-globals - eslint - oxlint-tsgolint - preact-render-to-string - - react-devtools-core - rolldown - rollup - supports-color - unloader - - utf-8-validate - vite - vite-plus - webpack @@ -17089,8 +16907,6 @@ snapshots: ts-easing: 0.2.0 tslib: 2.8.1 - react@19.2.5: {} - react@19.2.8: {} readdirp@3.6.0: @@ -17296,11 +17112,6 @@ snapshots: dependencies: lowercase-keys: 3.0.0 - restore-cursor@4.0.0: - dependencies: - onetime: 5.1.2 - signal-exit: 3.0.7 - restore-cursor@5.1.0: dependencies: onetime: 7.0.0 @@ -17662,11 +17473,6 @@ snapshots: slash@3.0.0: {} - slice-ansi@9.0.0: - dependencies: - ansi-styles: 6.2.3 - is-fullwidth-code-point: 5.1.0 - socket.io-adapter@2.5.8: dependencies: debug: 4.4.3 @@ -17729,10 +17535,6 @@ snapshots: dependencies: stackframe: 1.3.4 - stack-utils@2.0.6: - dependencies: - escape-string-regexp: 2.0.0 - stackback@0.0.2: {} stackframe@1.3.4: {} @@ -17928,8 +17730,6 @@ snapshots: - bare-abort-controller - react-native-b4a - terminal-size@4.0.1: {} - text-decoder@1.2.7: dependencies: b4a: 1.8.1 @@ -18526,18 +18326,8 @@ snapshots: siginfo: 2.0.0 stackback: 0.0.2 - widest-line@6.0.0: - dependencies: - string-width: 8.2.2 - word-wrap@1.2.5: {} - wrap-ansi@10.0.0: - dependencies: - ansi-styles: 6.2.3 - string-width: 8.2.2 - strip-ansi: 7.2.0 - wrap-ansi@9.0.2: dependencies: ansi-styles: 6.2.3 From 606323699f5fec123cc019b021938f24f56aadd0 Mon Sep 17 00:00:00 2001 From: aXenDeveloper Date: Mon, 10 Aug 2026 11:21:45 +0200 Subject: [PATCH 4/7] fix(blog): name categories in the article's picker A relation picker takes its labels from the target's `admin.titleField`, which has to be a shared column - and every text field on a blog category is localized, so there is none. The generic picker fell back to identifiers, and `#3` is not a category anybody recognises. The label, and only the label, now comes from the generated admin list route with `?locale=`, which already returns each row's translation. The relation is untouched: a real foreign key, a real `onDelete: "restrict"`, validated by the generated schemas, and the combobox stores the same identifier it always did. Co-Authored-By: Claude Opus 5 (1M context) --- apps/docs/content/docs/guides/blog.mdx | 8 +++ apps/docs/src/locales/@vitnode/blog/pl.json | 3 + plugins/blog/src/config.test-d.ts | 5 +- plugins/blog/src/config.tsx | 4 ++ plugins/blog/src/locales/en.json | 3 + .../views/admin/article/category-field.tsx | 41 ++++++++++++++ .../admin/article/category-options.server.ts | 56 +++++++++++++++++++ 7 files changed, 119 insertions(+), 1 deletion(-) create mode 100644 plugins/blog/src/views/admin/article/category-field.tsx create mode 100644 plugins/blog/src/views/admin/article/category-options.server.ts diff --git a/apps/docs/content/docs/guides/blog.mdx b/apps/docs/content/docs/guides/blog.mdx index 3bbfe4b21..6619cd966 100644 --- a/apps/docs/content/docs/guides/blog.mdx +++ b/apps/docs/content/docs/guides/blog.mdx @@ -140,6 +140,14 @@ The colour is the AdminCP's own picker through a [field override](/docs/dev/content-engine/admin-form-layouts#field-and-column-overrides), and the colour column is a swatch **plus** the value in words. + + Every text field on a category is localized, so there is no shared column that + could honestly be its name - left undefined, the engine would pick `color`, and + "#3260c0 has been deleted" is not a sentence anybody wants to read. The + article's category picker gets its labels from a small field override for the + same reason; the relation itself is entirely the engine's. + + ### Articles - the rich example Page create and edit, a custom layout, `AutoFormEditor` for the body, a native diff --git a/apps/docs/src/locales/@vitnode/blog/pl.json b/apps/docs/src/locales/@vitnode/blog/pl.json index e3eb86744..b20a52021 100644 --- a/apps/docs/src/locales/@vitnode/blog/pl.json +++ b/apps/docs/src/locales/@vitnode/blog/pl.json @@ -37,6 +37,9 @@ "title": "Ustawienia artykuƂu", "locale_desc": "Adres i metadane wersji w tym języku." } + }, + "category": { + "label": "Kategoria" } }, "category": { diff --git a/plugins/blog/src/config.test-d.ts b/plugins/blog/src/config.test-d.ts index a572874f4..226db0286 100644 --- a/plugins/blog/src/config.test-d.ts +++ b/plugins/blog/src/config.test-d.ts @@ -21,7 +21,10 @@ describe("blog content admin registration", () => { contentTypeAdmin({ definition: blogPostContentType, - fields: { content: { component: () => null } }, + fields: { + categoryId: { component: () => null }, + content: { component: () => null }, + }, forms: { layout: () => null }, }); }); diff --git a/plugins/blog/src/config.tsx b/plugins/blog/src/config.tsx index bed13177d..0227160f8 100644 --- a/plugins/blog/src/config.tsx +++ b/plugins/blog/src/config.tsx @@ -4,6 +4,7 @@ import { ListIcon, NotebookPenIcon } from "lucide-react"; import { CONFIG_PLUGIN } from "@/const"; import { blogCategoryContentType } from "@/content/category"; import { blogPostContentType } from "@/content/post"; +import { BlogArticleCategoryField } from "@/views/admin/article/category-field"; import { BlogArticleEditorField } from "@/views/admin/article/editor-field"; import { BlogArticleFormLayout } from "@/views/admin/article/form-layout"; import { BlogCategoryColorCell } from "@/views/admin/category/color-cell"; @@ -34,6 +35,9 @@ export const blogPlugin = () => { fields: { // The Tiptap editor, inside the same AutoForm as everything else. content: { component: BlogArticleEditorField }, + // A label override, so the picker names categories rather than + // numbering them - the relation itself is still the engine's. + categoryId: { component: BlogArticleCategoryField }, }, forms: { // One layout for both actions - they are the same screen, and writing diff --git a/plugins/blog/src/locales/en.json b/plugins/blog/src/locales/en.json index fac507f8a..20168c9bf 100644 --- a/plugins/blog/src/locales/en.json +++ b/plugins/blog/src/locales/en.json @@ -37,6 +37,9 @@ "title": "Article settings", "locale_desc": "The address and metadata of this language's version." } + }, + "category": { + "label": "Category" } }, "category": { diff --git a/plugins/blog/src/views/admin/article/category-field.tsx b/plugins/blog/src/views/admin/article/category-field.tsx new file mode 100644 index 000000000..0012141b8 --- /dev/null +++ b/plugins/blog/src/views/admin/article/category-field.tsx @@ -0,0 +1,41 @@ +"use client"; + +import type { ItemAutoFormComponentProps } from "@vitnode/core/components/form/auto-form"; + +import { AutoFormCombobox } from "@vitnode/core/components/form/fields/combobox"; +import { useLocale, useTranslations } from "next-intl"; + +import { loadBlogCategoryOptions } from "./category-options.server"; + +/** + * The category picker, labelled in the editor's own language. + * + * A **label** override and nothing more: the value it stores is the identifier + * the generated API takes, and the relation - the foreign key, the required + * check, the refusal to delete a category that still has articles - is entirely + * the Content Engine's. See `category-options.server.ts` for why the generated + * picker cannot name a localized target by itself. + */ +export const BlogArticleCategoryField = (props: ItemAutoFormComponentProps) => { + const t = useTranslations("@vitnode/blog.admin.article"); + const locale = useLocale(); + + return ( + { + const options = await loadBlogCategoryOptions(locale); + const term = search.trim().toLowerCase(); + + // Filtered here rather than by the route: the list route searches shared + // columns, and a category's name is not one - it is on the translation + // table. A blog has tens of categories, not thousands. + return term === "" + ? options + : options.filter(option => option.label.toLowerCase().includes(term)); + }} + id="categoryId" + label={t("category.label")} + {...props} + /> + ); +}; diff --git a/plugins/blog/src/views/admin/article/category-options.server.ts b/plugins/blog/src/views/admin/article/category-options.server.ts new file mode 100644 index 000000000..148109fb8 --- /dev/null +++ b/plugins/blog/src/views/admin/article/category-options.server.ts @@ -0,0 +1,56 @@ +"use server"; + +import { contentApiFetch } from "@vitnode/core/content/admin/fetch.server"; +import { z } from "zod"; + +import { CONFIG_PLUGIN } from "@/const"; +import { blogCategoryContentType } from "@/content/category"; + +const zodCategories = z.object({ + edges: z.array( + z + .object({ + id: z.number(), + translation: z.object({ title: z.string() }).nullable().optional(), + }) + .loose(), + ), +}); + +/** + * Categories, named in the language the editor is working in. + * + * The Content Engine resolves a relation picker's labels from the target's + * `admin.titleField`, which has to be a **shared** column - and every text field + * on a blog category is localized, so there is no shared column that could + * honestly be its name. The generic picker therefore falls back to identifiers, + * and `#3` is not a category anybody recognises. + * + * So the label - and only the label - is resolved here, from the generated admin + * list route the engine already publishes: `?locale=` makes it return each row's + * translation in that language. The relation itself is entirely the engine's: a + * real foreign key, a real `onDelete: "restrict"`, validated by the generated + * create and update schemas. What the combobox stores is the identifier the API + * takes, exactly as the generated picker would have stored it. + * + * The route is gated by the category's own `can_view`, so this exposes nothing a + * relation picker did not already show. + */ +export const loadBlogCategoryOptions = async ( + locale: string, +): Promise<{ label: string; value: string }[]> => { + const result = await contentApiFetch({ + definition: blogCategoryContentType, + method: "get", + pluginId: CONFIG_PLUGIN.pluginId, + query: { first: "100", locale }, + schema: zodCategories, + }); + + return (result.data?.edges ?? []).map(edge => ({ + // A category with no translation in this language is still selectable - + // hiding it would make an article unassignable for the wrong reason. + label: edge.translation?.title ?? `#${edge.id}`, + value: edge.id.toString(), + })); +}; From b2c8c84618f01ad10773212a27e85d54d0a276aa Mon Sep 17 00:00:00 2001 From: aXenDeveloper Date: Mon, 10 Aug 2026 12:49:47 +0200 Subject: [PATCH 5/7] Revert "fix(blog): name categories in the article's picker" This reverts commit 606323699. The override reached a server action, and `config.tsx` cannot: the `vitnode` CLI loads it with jiti to enumerate a plugin's routes and messages, so anything `server-only` in its static graph throws - which broke `vitnode init` outright, before the first migration ran. That is the constraint core's own overrides already respect, and the reason `ContentField` is handed a `loadOptions` callback rather than importing one. A lazy import would have hidden the trap rather than removed it. So the limitation stands and is written down instead: a relation label comes from a **shared** column on the target, and a localized content type has none, so the article's category picker labels its options `#3`. Resolving a label from the translation table is a Content Engine change, not a plugin workaround. Co-Authored-By: Claude Opus 5 (1M context) --- .../dev/content-engine/overriding-admincp.mdx | 8 +++ apps/docs/content/docs/guides/blog.mdx | 16 ++++-- .../(vitnode-blog)/blog/categories/page.tsx | 3 +- .../(vitnode-blog)/blog/posts/page.tsx | 3 +- .../@breadcrumb/content/[...slug]/page.tsx | 56 +++++++++++++++++-- apps/docs/src/locales/@vitnode/blog/pl.json | 3 - plugins/blog/src/config.test-d.ts | 5 +- plugins/blog/src/config.tsx | 4 -- plugins/blog/src/locales/en.json | 3 - .../views/admin/article/category-field.tsx | 41 -------------- .../admin/article/category-options.server.ts | 56 ------------------- 11 files changed, 74 insertions(+), 124 deletions(-) delete mode 100644 plugins/blog/src/views/admin/article/category-field.tsx delete mode 100644 plugins/blog/src/views/admin/article/category-options.server.ts diff --git a/apps/docs/content/docs/dev/content-engine/overriding-admincp.mdx b/apps/docs/content/docs/dev/content-engine/overriding-admincp.mdx index 7b78e7811..b7bd27d5a 100644 --- a/apps/docs/content/docs/dev/content-engine/overriding-admincp.mdx +++ b/apps/docs/content/docs/dev/content-engine/overriding-admincp.mdx @@ -62,6 +62,14 @@ contentTypeAdmin({ The override receives the same props the generated input would, so the field stays wired into `AutoForm`'s validation and error display. + + `config.tsx` is loaded by the `vitnode` CLI to enumerate a plugin's routes and + messages, so **anything `server-only` reachable from it breaks `vitnode init`** + - including a `"use server"` module a field override imports. Server data + reaches an override the way the generated inputs get it: through the props the + Content Engine already passes, such as `ContentField`'s `loadOptions`. + + `config.tsx` is a server module, so an inline arrow written there is a server closure and cannot be handed to the client form. Put the component in its own diff --git a/apps/docs/content/docs/guides/blog.mdx b/apps/docs/content/docs/guides/blog.mdx index 6619cd966..4ffe49884 100644 --- a/apps/docs/content/docs/guides/blog.mdx +++ b/apps/docs/content/docs/guides/blog.mdx @@ -140,12 +140,16 @@ The colour is the AdminCP's own picker through a [field override](/docs/dev/content-engine/admin-form-layouts#field-and-column-overrides), and the colour column is a swatch **plus** the value in words. - - Every text field on a category is localized, so there is no shared column that - could honestly be its name - left undefined, the engine would pick `color`, and - "#3260c0 has been deleted" is not a sentence anybody wants to read. The - article's category picker gets its labels from a small field override for the - same reason; the relation itself is entirely the engine's. + + `titleField` is `null` because every text field on a category is localized - + left undefined, the engine would pick `color`, and "#3260c0 has been deleted" + is not a sentence anybody wants to read. The consequence is that the article's + category picker labels its options `#3` rather than "Engineering": a relation + label is resolved from a **shared** column on the target, and a localized + content type has none. Resolving one from the translation table is a Content + Engine change, not something a plugin should paper over - `config.tsx` is + loaded by the `vitnode` CLI, so a field override cannot reach a server action + to look the names up itself. ### Articles - the rich example diff --git a/apps/docs/src/app/[locale]/admin/(auth)/(plugins)/(vitnode-blog)/blog/categories/page.tsx b/apps/docs/src/app/[locale]/admin/(auth)/(plugins)/(vitnode-blog)/blog/categories/page.tsx index 81d74e4c9..87b011c5c 100644 --- a/apps/docs/src/app/[locale]/admin/(auth)/(plugins)/(vitnode-blog)/blog/categories/page.tsx +++ b/apps/docs/src/app/[locale]/admin/(auth)/(plugins)/(vitnode-blog)/blog/categories/page.tsx @@ -1,7 +1,8 @@ -import { blogCategoryContentType } from "@vitnode/blog/content/category"; import { contentAdminHref } from "@vitnode/core/content"; import { redirect } from "@vitnode/core/lib/navigation"; +import { blogCategoryContentType } from "@vitnode/blog/content/category"; + /** The address categories used to live at. See the posts page next door. */ export default async function LegacyCategoriesPage() { await redirect(contentAdminHref(blogCategoryContentType.id)); diff --git a/apps/docs/src/app/[locale]/admin/(auth)/(plugins)/(vitnode-blog)/blog/posts/page.tsx b/apps/docs/src/app/[locale]/admin/(auth)/(plugins)/(vitnode-blog)/blog/posts/page.tsx index d381bcf9f..148140681 100644 --- a/apps/docs/src/app/[locale]/admin/(auth)/(plugins)/(vitnode-blog)/blog/posts/page.tsx +++ b/apps/docs/src/app/[locale]/admin/(auth)/(plugins)/(vitnode-blog)/blog/posts/page.tsx @@ -1,7 +1,8 @@ -import { blogPostContentType } from "@vitnode/blog/content/post"; import { contentAdminHref } from "@vitnode/core/content"; import { redirect } from "@vitnode/core/lib/navigation"; +import { blogPostContentType } from "@vitnode/blog/content/post"; + /** * The address articles used to live at. * diff --git a/apps/docs/src/app/[locale]/admin/(auth)/@breadcrumb/content/[...slug]/page.tsx b/apps/docs/src/app/[locale]/admin/(auth)/@breadcrumb/content/[...slug]/page.tsx index 23c72b508..ef6a13465 100644 --- a/apps/docs/src/app/[locale]/admin/(auth)/@breadcrumb/content/[...slug]/page.tsx +++ b/apps/docs/src/app/[locale]/admin/(auth)/@breadcrumb/content/[...slug]/page.tsx @@ -1,22 +1,68 @@ +import { getTranslations } from "next-intl/server"; + +import { + CONTENT_ADMIN_CREATE_SEGMENT, + CONTENT_ADMIN_EDIT_SEGMENT, +} from "@vitnode/core/content/const"; +import { contentAdminHref, contentTypeToPath } from "@vitnode/core/content/registry"; import { BreadcrumbAdmin } from "@vitnode/core/views/admin/layouts/breadcrumb/breadcrumb-admin"; import { getContentLabels, - resolveContentType, + resolveContentRoute, } from "@vitnode/core/views/admin/views/content/content-admin-view"; +/** + * The breadcrumb of every generated Content Engine screen. + * + * The list keeps the trail it always had. A create or an edit **page** appends + * one more crumb, labelled from `core.content` with the content type's own + * singular - so it reads "Blog / Articles / Create article" in whatever language + * the AdminCP is in, and "Articles" becomes a link back to the list. + * + * The record id is deliberately **not** a crumb of its own: `/42/` would render + * as a dead "42" between two words, and the page it would point at is the one + * being read. + */ export default async function BreadcrumbSlot({ params, }: { params: Promise<{ slug: string[] }>; }) { const { slug } = await params; - const entry = await resolveContentType(params); - const labels = entry ? await getContentLabels(entry) : undefined; + const route = await resolveContentRoute(params); + const labels = route ? await getContentLabels(route.entry) : undefined; + + if (!route || route.action === "list") { + return ( + + ); + } + + const t = await getTranslations("core.content"); + const { definition } = route.entry; return ( ); } diff --git a/apps/docs/src/locales/@vitnode/blog/pl.json b/apps/docs/src/locales/@vitnode/blog/pl.json index b20a52021..e3eb86744 100644 --- a/apps/docs/src/locales/@vitnode/blog/pl.json +++ b/apps/docs/src/locales/@vitnode/blog/pl.json @@ -37,9 +37,6 @@ "title": "Ustawienia artykuƂu", "locale_desc": "Adres i metadane wersji w tym języku." } - }, - "category": { - "label": "Kategoria" } }, "category": { diff --git a/plugins/blog/src/config.test-d.ts b/plugins/blog/src/config.test-d.ts index 226db0286..a572874f4 100644 --- a/plugins/blog/src/config.test-d.ts +++ b/plugins/blog/src/config.test-d.ts @@ -21,10 +21,7 @@ describe("blog content admin registration", () => { contentTypeAdmin({ definition: blogPostContentType, - fields: { - categoryId: { component: () => null }, - content: { component: () => null }, - }, + fields: { content: { component: () => null } }, forms: { layout: () => null }, }); }); diff --git a/plugins/blog/src/config.tsx b/plugins/blog/src/config.tsx index 0227160f8..bed13177d 100644 --- a/plugins/blog/src/config.tsx +++ b/plugins/blog/src/config.tsx @@ -4,7 +4,6 @@ import { ListIcon, NotebookPenIcon } from "lucide-react"; import { CONFIG_PLUGIN } from "@/const"; import { blogCategoryContentType } from "@/content/category"; import { blogPostContentType } from "@/content/post"; -import { BlogArticleCategoryField } from "@/views/admin/article/category-field"; import { BlogArticleEditorField } from "@/views/admin/article/editor-field"; import { BlogArticleFormLayout } from "@/views/admin/article/form-layout"; import { BlogCategoryColorCell } from "@/views/admin/category/color-cell"; @@ -35,9 +34,6 @@ export const blogPlugin = () => { fields: { // The Tiptap editor, inside the same AutoForm as everything else. content: { component: BlogArticleEditorField }, - // A label override, so the picker names categories rather than - // numbering them - the relation itself is still the engine's. - categoryId: { component: BlogArticleCategoryField }, }, forms: { // One layout for both actions - they are the same screen, and writing diff --git a/plugins/blog/src/locales/en.json b/plugins/blog/src/locales/en.json index 20168c9bf..fac507f8a 100644 --- a/plugins/blog/src/locales/en.json +++ b/plugins/blog/src/locales/en.json @@ -37,9 +37,6 @@ "title": "Article settings", "locale_desc": "The address and metadata of this language's version." } - }, - "category": { - "label": "Category" } }, "category": { diff --git a/plugins/blog/src/views/admin/article/category-field.tsx b/plugins/blog/src/views/admin/article/category-field.tsx deleted file mode 100644 index 0012141b8..000000000 --- a/plugins/blog/src/views/admin/article/category-field.tsx +++ /dev/null @@ -1,41 +0,0 @@ -"use client"; - -import type { ItemAutoFormComponentProps } from "@vitnode/core/components/form/auto-form"; - -import { AutoFormCombobox } from "@vitnode/core/components/form/fields/combobox"; -import { useLocale, useTranslations } from "next-intl"; - -import { loadBlogCategoryOptions } from "./category-options.server"; - -/** - * The category picker, labelled in the editor's own language. - * - * A **label** override and nothing more: the value it stores is the identifier - * the generated API takes, and the relation - the foreign key, the required - * check, the refusal to delete a category that still has articles - is entirely - * the Content Engine's. See `category-options.server.ts` for why the generated - * picker cannot name a localized target by itself. - */ -export const BlogArticleCategoryField = (props: ItemAutoFormComponentProps) => { - const t = useTranslations("@vitnode/blog.admin.article"); - const locale = useLocale(); - - return ( - { - const options = await loadBlogCategoryOptions(locale); - const term = search.trim().toLowerCase(); - - // Filtered here rather than by the route: the list route searches shared - // columns, and a category's name is not one - it is on the translation - // table. A blog has tens of categories, not thousands. - return term === "" - ? options - : options.filter(option => option.label.toLowerCase().includes(term)); - }} - id="categoryId" - label={t("category.label")} - {...props} - /> - ); -}; diff --git a/plugins/blog/src/views/admin/article/category-options.server.ts b/plugins/blog/src/views/admin/article/category-options.server.ts deleted file mode 100644 index 148109fb8..000000000 --- a/plugins/blog/src/views/admin/article/category-options.server.ts +++ /dev/null @@ -1,56 +0,0 @@ -"use server"; - -import { contentApiFetch } from "@vitnode/core/content/admin/fetch.server"; -import { z } from "zod"; - -import { CONFIG_PLUGIN } from "@/const"; -import { blogCategoryContentType } from "@/content/category"; - -const zodCategories = z.object({ - edges: z.array( - z - .object({ - id: z.number(), - translation: z.object({ title: z.string() }).nullable().optional(), - }) - .loose(), - ), -}); - -/** - * Categories, named in the language the editor is working in. - * - * The Content Engine resolves a relation picker's labels from the target's - * `admin.titleField`, which has to be a **shared** column - and every text field - * on a blog category is localized, so there is no shared column that could - * honestly be its name. The generic picker therefore falls back to identifiers, - * and `#3` is not a category anybody recognises. - * - * So the label - and only the label - is resolved here, from the generated admin - * list route the engine already publishes: `?locale=` makes it return each row's - * translation in that language. The relation itself is entirely the engine's: a - * real foreign key, a real `onDelete: "restrict"`, validated by the generated - * create and update schemas. What the combobox stores is the identifier the API - * takes, exactly as the generated picker would have stored it. - * - * The route is gated by the category's own `can_view`, so this exposes nothing a - * relation picker did not already show. - */ -export const loadBlogCategoryOptions = async ( - locale: string, -): Promise<{ label: string; value: string }[]> => { - const result = await contentApiFetch({ - definition: blogCategoryContentType, - method: "get", - pluginId: CONFIG_PLUGIN.pluginId, - query: { first: "100", locale }, - schema: zodCategories, - }); - - return (result.data?.edges ?? []).map(edge => ({ - // A category with no translation in this language is still selectable - - // hiding it would make an article unassignable for the wrong reason. - label: edge.translation?.title ?? `#${edge.id}`, - value: edge.id.toString(), - })); -}; From ae447f390b11ef02f58f2c7fe06cef6c3a759523 Mon Sep 17 00:00:00 2001 From: aXenDeveloper Date: Mon, 10 Aug 2026 13:23:12 +0200 Subject: [PATCH 6/7] fix(content): keep native button semantics on the page-mode links Base UI's `Button` assumes it renders a real ` diff --git a/packages/vitnode/src/views/admin/views/content/actions/edit-action.tsx b/packages/vitnode/src/views/admin/views/content/actions/edit-action.tsx index 75060ab29..61b400bc0 100644 --- a/packages/vitnode/src/views/admin/views/content/actions/edit-action.tsx +++ b/packages/vitnode/src/views/admin/views/content/actions/edit-action.tsx @@ -94,6 +94,7 @@ export const EditContentAction = ({ render={ ) : null} diff --git a/packages/vitnode/src/views/admin/views/content/page/page-views.tsx b/packages/vitnode/src/views/admin/views/content/page/page-views.tsx index a144bb628..230cec336 100644 --- a/packages/vitnode/src/views/admin/views/content/page/page-views.tsx +++ b/packages/vitnode/src/views/admin/views/content/page/page-views.tsx @@ -107,7 +107,11 @@ export const ContentCreatePageView = async ({ desc={t("desc", { name: singular })} h1={t("title", { name: singular })} > - @@ -203,7 +207,11 @@ export const ContentEditPageView = async ({ return (
- From e19dbcd2c1d216c9d18d86d5a33c2a0b505c6bda Mon Sep 17 00:00:00 2001 From: aXenDeveloper Date: Wed, 12 Aug 2026 21:11:19 +0200 Subject: [PATCH 7/7] feat: Move blog plugin to content engine --- apps/api/.env.example | 10 +- apps/docs/.env.example | 10 +- .../dev/content-engine/admin-form-layouts.mdx | 36 +- .../docs/dev/content-engine/admincp.mdx | 32 ++ .../content-engine-security.mdx | 6 +- .../docs/dev/content-engine/editorial.mdx | 9 +- .../docs/dev/content-engine/limitations.mdx | 26 +- .../docs/dev/content-engine/localization.mdx | 48 +- .../dev/content-engine/localized-fields.mdx | 35 +- .../content-engine/localized-public-api.mdx | 3 +- .../dev/content-engine/localized-search.mdx | 17 +- .../docs/dev/content-engine/preview.mdx | 42 +- .../content-engine/translation-editorial.mdx | 173 ++++-- .../content-engine/translation-preview.mdx | 6 +- .../content-engine/translation-revisions.mdx | 14 +- .../content-engine/translation-service.mdx | 4 +- apps/docs/content/docs/dev/index.mdx | 22 +- apps/docs/content/docs/guides/blog.mdx | 65 ++- apps/docs/src/locales/@vitnode/blog/pl.json | 3 +- .../src/api/middlewares/global.middleware.ts | 9 +- .../admin/debug/routes/integrations.route.ts | 6 +- .../vitnode/src/components/form/auto-form.tsx | 9 + .../src/components/form/fields/input.tsx | 29 +- .../components/form/fields/textarea.test.tsx | 194 +++++++ .../src/components/form/fields/textarea.tsx | 97 +++- .../src/components/i18n-provider.test.ts | 86 +++ .../vitnode/src/components/i18n-provider.tsx | 31 +- .../vitnode/src/content/admin/spec.test.ts | 163 +++++- packages/vitnode/src/content/admin/spec.ts | 309 ++++++++-- packages/vitnode/src/content/define.ts | 59 +- packages/vitnode/src/content/index.ts | 4 +- .../src/content/localization.test-d.ts | 57 +- .../vitnode/src/content/localization.test.ts | 59 +- packages/vitnode/src/content/schemas.ts | 11 +- .../src/content/server/editorial-effects.ts | 6 +- .../src/content/server/editorial-service.ts | 89 +++ .../server/localized-admin-routes.test.ts | 525 +++++++++++++++++ .../content/server/localized-admin-routes.ts | 538 ++++++++++++++++++ .../server/localized-preview-routes.test.ts | 1 + packages/vitnode/src/content/server/model.ts | 52 ++ .../src/content/server/openapi-parity.test.ts | 1 + .../content/server/permission-matrix.test.ts | 9 + .../src/content/server/preview-config.test.ts | 142 +++-- .../src/content/server/preview-config.ts | 48 +- packages/vitnode/src/content/server/routes.ts | 18 +- .../translation-advanced-revisions.test.ts | 1 + .../translation-editorial-service.test.ts | 1 + .../server/translation-editorial-service.ts | 26 +- .../src/content/server/translation-model.ts | 27 + .../translation-publication-routes.test.ts | 1 + .../content/server/translation-routes.test.ts | 26 +- .../src/content/server/translation-routes.ts | 14 +- packages/vitnode/src/content/types.ts | 74 ++- packages/vitnode/src/lib/plugin.ts | 14 - packages/vitnode/src/locales/en.json | 12 +- .../content/actions/conflict-notice.test.tsx | 1 + .../views/content/actions/content-form.tsx | 240 +++++++- .../views/content/actions/edit-action.tsx | 50 +- .../actions/history/revision-history.test.tsx | 1 + .../content/actions/mutation-api.server.ts | 152 +++++ .../views/content/actions/page-links.test.tsx | 3 +- .../content/actions/translation-api.server.ts | 55 +- .../content/actions/translations-action.tsx | 120 ++++ .../actions/translations/locale-editor.tsx | 147 ----- .../translations/translation-manager.tsx | 258 +++++++++ .../translations/translation-panel.test.tsx | 126 ---- .../translations/translation-panel.tsx | 481 ---------------- .../views/content/content-admin-view.tsx | 22 +- .../admin/views/content/form/context.tsx | 18 +- .../admin/views/content/form/layout.test.tsx | 11 +- .../admin/views/content/form/primitives.tsx | 2 +- .../views/content/lib/field-component.tsx | 21 +- .../content/lib/localized-fields.test.tsx | 303 ++++++++++ .../views/content/page/content-form-page.tsx | 55 +- .../views/content/page/page-views.test.tsx | 28 +- .../admin/views/content/page/page-views.tsx | 84 +-- .../views/admin/views/content/table/cells.tsx | 35 +- .../content/table/content-table-view.test.tsx | 138 +++-- .../content/table/content-table-view.tsx | 207 +++---- .../views/content/table/locale-selector.tsx | 81 --- plugins/blog/src/content/category.ts | 20 +- .../blog/src/content/content-types.test.ts | 63 +- plugins/blog/src/content/post.ts | 17 +- plugins/blog/src/locales/en.json | 3 +- .../src/views/admin/article/editor-field.tsx | 5 + .../views/admin/article/form-layout.test.tsx | 52 +- .../src/views/admin/article/form-layout.tsx | 20 +- .../src/database/advanced-postgres.test.ts | 5 +- .../src/database/concurrency-postgres.test.ts | 137 +++++ .../src/database/delivery-postgres.test.ts | 106 +++- plugins/example/src/database/postgres.test.ts | 308 ++++++++++ 91 files changed, 4939 insertions(+), 1745 deletions(-) create mode 100644 packages/vitnode/src/components/form/fields/textarea.test.tsx create mode 100644 packages/vitnode/src/components/i18n-provider.test.ts create mode 100644 packages/vitnode/src/content/server/localized-admin-routes.test.ts create mode 100644 packages/vitnode/src/content/server/localized-admin-routes.ts create mode 100644 packages/vitnode/src/views/admin/views/content/actions/translations-action.tsx delete mode 100644 packages/vitnode/src/views/admin/views/content/actions/translations/locale-editor.tsx create mode 100644 packages/vitnode/src/views/admin/views/content/actions/translations/translation-manager.tsx delete mode 100644 packages/vitnode/src/views/admin/views/content/actions/translations/translation-panel.test.tsx delete mode 100644 packages/vitnode/src/views/admin/views/content/actions/translations/translation-panel.tsx create mode 100644 packages/vitnode/src/views/admin/views/content/lib/localized-fields.test.tsx delete mode 100644 packages/vitnode/src/views/admin/views/content/table/locale-selector.tsx diff --git a/apps/api/.env.example b/apps/api/.env.example index af3fdeccf..9ab8297a6 100644 --- a/apps/api/.env.example +++ b/apps/api/.env.example @@ -9,12 +9,12 @@ NEXT_PUBLIC_API_URL=http://localhost:8080 # === CRON Secret for Internal API Calls === CRON_SECRET=your-secure-cron-secret-key -# === Content Preview Secret === +# === Content Preview Secret (optional) === # Signs the preview links that let a reviewer read an unpublished record -# without an account. The signature is the *only* access control on those -# links, so this is required whenever a content type has `editorial.preview` -# enabled: at least 32 random bytes, or the API refuses to boot in production -# and preview stays switched off everywhere else. +# without an account. Optional: leave it unset and the API boots normally with +# preview switched off - minting a link answers 503 naming this variable, and +# opening one answers 404. Set at least 32 random bytes to switch it on, because +# the signature is the *only* access control those links have. # # openssl rand -base64 32 # diff --git a/apps/docs/.env.example b/apps/docs/.env.example index 726860ed7..7e09103d8 100644 --- a/apps/docs/.env.example +++ b/apps/docs/.env.example @@ -7,12 +7,12 @@ NEXT_PUBLIC_WEB_URL=http://localhost:3000 # === CRON Secret for Internal API Calls === CRON_SECRET=your-secure-cron-secret-key -# === Content Preview Secret === +# === Content Preview Secret (optional) === # Signs the preview links that let a reviewer read an unpublished record -# without an account. The signature is the *only* access control on those -# links, so this is required whenever a content type has `editorial.preview` -# enabled: at least 32 random bytes, or the API refuses to boot in production -# and preview stays switched off everywhere else. +# without an account. Optional: leave it unset and the API boots normally with +# preview switched off - minting a link answers 503 naming this variable, and +# opening one answers 404. Set at least 32 random bytes to switch it on, because +# the signature is the *only* access control those links have. # # openssl rand -base64 32 # diff --git a/apps/docs/content/docs/dev/content-engine/admin-form-layouts.mdx b/apps/docs/content/docs/dev/content-engine/admin-form-layouts.mdx index acf8ff8f3..19f28d053 100644 --- a/apps/docs/content/docs/dev/content-engine/admin-form-layouts.mdx +++ b/apps/docs/content/docs/dev/content-engine/admin-form-layouts.mdx @@ -172,36 +172,40 @@ belongs to wherever that input ended up. | Primitive | What it does | | --- | --- | -| `ContentFormField` | One field, by name. Nothing if this surface has no such field | +| `ContentFormField` | One field, by name. Nothing if the form has no such field | | `ContentFormRemainingFields` | Everything the layout did not name | | `ContentFormActions` | The submit row, with an optional `cancelHref` | | `ContentFormStatus` | The read-only publication line | | `ContentFormLayoutGrid` / `Main` / `Sidebar` / `Section` | AdminCP chrome: two columns above `lg`, one below | -| `useContentForm()` | `mode`, `surface`, `fieldNames`, `publication` | +| `useContentForm()` | `mode`, `fieldNames`, `localizedFieldNames`, `publication` | -### Localized content types get one layout, not two +### Localized content types get one layout, and one form -A localized content type splits its fields across two surfaces - the shared tab -and one tab per language - and the same layout is rendered in both. -`ContentFormField` renders **nothing** for a name the current surface does not -have, which is what lets a single file place `title` and `categoryId` without -knowing which table either lives on: +A layout places every field of the content type in one screen, localized or not: -```text -Shared tab → categoryId, authorId in the sidebar -English tab → title, content in the main column; friendlyUrl in the sidebar +```tsx + {/* translation table */} + {/* translation table */} + {/* translation table */} + {/* base table */} + {/* base table */} ``` -`useContentForm().surface` is `"shared"` or `"translation"` when a layout wants -to word something differently. +Nothing here says which is which, and nothing needs to: **a localized field +renders its own language control automatically**, and a shared one does not. The +layout decides where a field appears; the Content Engine decides where its value +goes. + +`useContentForm().localizedFieldNames` is there for a layout that wants to group +or annotate them - it is never needed to *place* one. ### The server/client boundary `config.tsx` is a **server** module, so a layout referenced from it is a client reference crossing an RSC boundary. That decides the shape of the whole API: -- The layout receives only **serialisable** props: `mode`, `surface`, - `contentTypeId`, `pluginId`, `itemId`, `singular`, `publication`, `title`. +- The layout receives only **serialisable** props: `mode`, `contentTypeId`, + `pluginId`, `itemId`, `singular`, `publication`, `title`. - Field elements, the form instance and the submit action arrive through **client context** instead. A `renderField(name)` callback prop would read well and would be a server closure, which cannot cross the boundary at all. @@ -210,7 +214,7 @@ So: `"use client"` at the top of the layout file, and no inline arrow in `config.tsx`. - In development, a layout that never renders one of its surface's fields logs + In development, a layout that never renders one of the form's fields logs which ones - a field silently missing from the payload is the one failure mode this API has that the generated form does not. diff --git a/apps/docs/content/docs/dev/content-engine/admincp.mdx b/apps/docs/content/docs/dev/content-engine/admincp.mdx index 5cee7e76a..db1719bb9 100644 --- a/apps/docs/content/docs/dev/content-engine/admincp.mdx +++ b/apps/docs/content/docs/dev/content-engine/admincp.mdx @@ -34,6 +34,9 @@ You get a nav item, a breadcrumb, and a screen at: ([below](#dialog-or-page)) - **Delete** - a confirmation dialog - **History** - with [`editorial`](#editorial): every version, a diff, and restore +- **Languages** - with [`localization`](/docs/dev/content-engine/translation-editorial): + the list and the form open in *your* VitNode language, and each translated + field carries its own switcher ([below](#localized-content-types)) - **Empty, loading and error states** - out of the box ## What "lazy-loaded on open" actually means @@ -359,3 +362,32 @@ type's own `can_view`. The page checks `can_view` server-side and 404s without it. The create, edit and delete controls check their own permissions client-side - and the routes behind them check again, which is the check that actually matters. + +## Localized content types + +A localized content type gets **no extra screen and no extra control**. There is +no `Shared | English | Polish` strip and no locale in the URL: + +- the **list** shows each record in the language you are reading VitNode in, and + `Missing` where a translation does not exist yet; +- the **form** shows every field at once, and each localized one carries its own + small language switcher: + +```text +Title [ TytuƂ artykuƂu ] [ PL â–Ÿ ] +Content [ Treƛć
 ] [ PL â–Ÿ ] +Friendly URL [ tytul-artykulu ] [ PL â–Ÿ ] +Category Aktualnoƛci +Author Maciej +``` + +Switching `Title` to English leaves the others in Polish - there is no +form-global language. One Save writes the base row and every changed language in +one transaction. + +Per-language status, publication, history and delete live in a separate +**Languages** row action, because the language is part of *that* decision rather +than a mode the whole screen is in. + +The whole thing is described in [Localized +editing](/docs/dev/content-engine/translation-editorial#the-admincp). diff --git a/apps/docs/content/docs/dev/content-engine/content-engine-security.mdx b/apps/docs/content/docs/dev/content-engine/content-engine-security.mdx index dc93da8cb..a76f3f306 100644 --- a/apps/docs/content/docs/dev/content-engine/content-engine-security.mdx +++ b/apps/docs/content/docs/dev/content-engine/content-engine-security.mdx @@ -54,12 +54,14 @@ Two choices in there are worth the sentence they take: which is what makes "writes Polish and nothing else" expressible. Give a role `can_view + can_translate` and it can: -- read the record, every locale tab and every locale's history; +- read the record, every language and every language's history; - create and edit a translation in any enabled locale. It cannot: -- edit a shared field (`PUT /{id}` is `can_edit`); +- edit a shared field - `PUT /{id}` is `can_edit`, and the AdminCP's composite + `PUT /{id}/localized` re-checks `can_edit` in the handler the moment its payload + carries one, so reaching the form grants nothing; - publish or unpublish anything, record or translation (`can_publish`); - restore a shared revision *or* a locale's own (`can_restore`, which needs `can_edit`); diff --git a/apps/docs/content/docs/dev/content-engine/editorial.mdx b/apps/docs/content/docs/dev/content-engine/editorial.mdx index 5626c4fac..4c3542f55 100644 --- a/apps/docs/content/docs/dev/content-engine/editorial.mdx +++ b/apps/docs/content/docs/dev/content-engine/editorial.mdx @@ -91,10 +91,11 @@ editorial: { a set time, on a one-minute tick. - At least 32 random bytes - `openssl rand -base64 32`. The signature is the - only access control a preview link has, so without one the API refuses to - start in production, and preview fails closed everywhere else. See - [Secure preview](/docs/dev/content-engine/preview#content_preview_secret-is-required). + At least 32 random bytes - `openssl rand -base64 32`. The variable itself is + optional and the API boots without it, but the signature is the only access + control a preview link has, so until you set one preview fails closed: a + warning at boot, a 503 from the button. See + [Secure preview](/docs/dev/content-engine/preview#content_preview_secret-is-optional). ## `version` is generated, so you cannot declare it diff --git a/apps/docs/content/docs/dev/content-engine/limitations.mdx b/apps/docs/content/docs/dev/content-engine/limitations.mdx index da8342567..746745660 100644 --- a/apps/docs/content/docs/dev/content-engine/limitations.mdx +++ b/apps/docs/content/docs/dev/content-engine/limitations.mdx @@ -47,8 +47,8 @@ generated one without friction. [`localization`](/docs/dev/content-engine/localization) generates the tables, the types, the schemas, the services, the per-locale lifecycle, the per-locale history -and the AdminCP locale tabs. What it deliberately refuses is every combination -whose *reading* half is not built yet: +and the AdminCP's per-field language switchers. What it deliberately refuses is +every combination whose *reading* half is not built yet: Nothing is refused any more. [Locale-aware public reads](/docs/dev/content-engine/localized-public-api) landed @@ -75,17 +75,21 @@ positions depending on the language. Order by a column the record has one of. `filterableFields` and `searchableFields` *may* name a localized field: both are evaluated against the single translation the reader is being served. -## Localized field names cannot appear on base-table surfaces +## A localized field can be shown, but not queried A localized field has no column on the base table, so it cannot be an -`admin.list` column, an `orderableFields` or `searchableFields` entry, a -`form.fields` entry, `admin.titleField`, or part of an `indexes` declaration. All -six are compile errors and runtime errors. - -`admin.titleField` therefore falls back to `null` on a content type whose only -text fields are localized. The locale tabs show the localized title inside each -tab, and the list's language selector adds a column showing each record's title -in the language being viewed. +`orderableFields` or `searchableFields` entry, `admin.list.defaultOrderBy`, part +of an `indexes` declaration, or a key in `schemas.create`/`update`/`select`. All +of those are SQL over the base row, and every one of them is a compile error and +a runtime error. + +It *may* be an `admin.list` column, `admin.titleField` and an `admin.form.fields` +entry, because those are presentation: the AdminCP resolves the value from the +one translation it already loaded for the reader's own language. + +The practical consequence is that a localized column is **displayed but not +sortable**. Nothing about the base-table ordering guarantees changes; there is +simply no header control on that column. ## Foreign key names on a long translation table are truncated by Postgres diff --git a/apps/docs/content/docs/dev/content-engine/localization.mdx b/apps/docs/content/docs/dev/content-engine/localization.mdx index 53e99abeb..9739d4790 100644 --- a/apps/docs/content/docs/dev/content-engine/localization.mdx +++ b/apps/docs/content/docs/dev/content-engine/localization.mdx @@ -144,20 +144,27 @@ What lands where: | Every shared field | Every localized field | | | `version`, `createdAt`, `updatedAt` | -A localized field is **not** a column on the base table, which has consequences -worth knowing up front: +A localized field is **not** a column on the base table, and the engine draws a +line between *showing* one and *querying* one. -- it cannot appear in `admin.list.columns`, `orderableFields`, `searchableFields` - or `form.fields`, -- it cannot be `admin.titleField`, -- it cannot appear in `indexes`, -- it is absent from `schemas.create`, `schemas.update` and `schemas.select`. +**Showing is fine.** A localized field may appear in `admin.list.columns`, may be +`admin.titleField`, and always appears in `admin.form.fields`. The AdminCP +resolves it in the language the reader is already using VitNode in, and its form +input carries its own language switcher - see +[Localized editing](/docs/dev/content-engine/translation-editorial). -All five are compile errors *and* runtime errors: there is nowhere on the base -form or in a base-table query for them to go, and a silently-dropped title is -worse than a refused definition. Localized values have their own AdminCP surface - -the [locale tabs](/docs/dev/content-engine/translation-editorial) in the edit -dialog, and the language selector on the list. +**Querying is not.** A localized field cannot appear in: + +- `admin.list.orderableFields` or `admin.list.searchableFields`, +- `admin.list.defaultOrderBy`, +- `indexes`, +- `schemas.create`, `schemas.update` or `schemas.select`. + +Those are all SQL over the base table, and the value is not there. A list ordered +by a per-language title would reshuffle itself for every reader and make one +cursor mean two positions at once. Each of them is a compile error *and* a +runtime error, because a silently-dropped ordering is worse than a refused +definition. ## Optimistic locking per locale @@ -199,9 +206,14 @@ const { row, translation } = await localizedService.create({ ``` Either both exist or neither does. That invariant is what every later stage leans -on - a record always resolves in at least one language, so a locale tab strip -always has something to show and a public read always has something to fall back -to. +on - a record always resolves in at least one language, so the AdminCP always has +something to show and a public read always has something to fall back to. + +`localization.defaultLocale` is a **storage and fallback** rule, not a display +one. It decides which translation must exist, and which one a public reader falls +back to. It does not decide which language an editor sees first: that is their +own VitNode language. See [Localized +editing](/docs/dev/content-engine/translation-editorial#two-different-languages). Two rules protect it: @@ -313,9 +325,9 @@ answer to the same slug. | Stage | What it adds | | --- | --- | | **5A** | Tables, types, schemas, language resolution, translation service, per-locale locking, atomic create, routes, migrations | -| **5B** | Per-locale publication, per-locale revisions and restore, locale-bound preview tokens, translation events, `can_translate`, AdminCP locale tabs | +| **5B** | Per-locale publication, per-locale revisions and restore, locale-bound preview tokens, translation events, `can_translate`, per-field language switchers in the AdminCP | | **5C** | Locale-aware public API, locale precedence, fallback resolution, strict-locale slugs, locale-aware cache tags, locale preview links | -| **5D** (this one) | Per-locale search documents, the localized rebuild, per-language diagnostics, the AdminCP list language selector | +| **5D** (this one) | Per-locale search documents, the localized rebuild, per-language diagnostics, the AdminCP list in the reader's own language | Explicitly outside all four: locale-specific relations, localized media, AI translation, translation memory, external TMS integration, `hreflang` and sitemap @@ -327,7 +339,7 @@ Content Engine. - [Localized fields](/docs/dev/content-engine/localized-fields) - which kinds, and why the others are refused - [Translation tables](/docs/dev/content-engine/translation-tables) - the generated schema, keys and indexes - [Translation service](/docs/dev/content-engine/translation-service) - every method, and every conflict it can raise -- [Translation lifecycle](/docs/dev/content-engine/translation-editorial) - per-locale publish, the subordination rule, permissions and the locale tabs +- [Localized editing](/docs/dev/content-engine/translation-editorial) - the AdminCP's per-field language switchers, per-locale publish, the subordination rule and permissions - [Translation revisions](/docs/dev/content-engine/translation-revisions) - one history per language, and what a restore may not cross - [Locale preview](/docs/dev/content-engine/translation-preview) - freezing one language, both halves of it - [Localization migrations](/docs/dev/content-engine/localization-migrations) - the generated migration, and how to localize an existing content type safely diff --git a/apps/docs/content/docs/dev/content-engine/localized-fields.mdx b/apps/docs/content/docs/dev/content-engine/localized-fields.mdx index 39ff0d978..519859f34 100644 --- a/apps/docs/content/docs/dev/content-engine/localized-fields.mdx +++ b/apps/docs/content/docs/dev/content-engine/localized-fields.mdx @@ -164,28 +164,33 @@ and `ContentLocalizedValues` is `{}` - which is what makes a `translation:` key impossible to fill in by accident on a Stage 1-4 definition, and what keeps every existing type exactly as it was. -## Where a localized field may not appear +## Where a localized field may appear -Everything on this list addresses a column on the *base* table: +The engine draws one line, and it is between **showing** a value and **querying** +one. ```ts admin: { list: { - columns: ["title"], // ✗ - orderableFields: ["title"], // ✗ - searchableFields: ["title"], // ✗ + columns: ["title"], // ✓ shown in the reader's own language + orderableFields: ["title"], // ✗ ORDER BY on the base table + searchableFields: ["title"], // ✗ a predicate on the base row }, - form: { fields: ["title"] }, // ✗ - titleField: "title", // ✗ + form: { fields: ["title"] }, // ✓ one form, with its own language switcher + titleField: "title", // ✓ resolved per reader }, -indexes: [{ on: ["title"] }], // ✗ +indexes: [{ on: ["title"] }], // ✗ no such column to index ``` -All six are compile errors, and all six are runtime errors as well. The defaults -skip localized fields automatically, so a localized content type that says nothing -about `admin.list` gets a sensible shared-only list without having to opt out of -anything. +The refusals are compile errors *and* runtime errors. They are not squeamishness: +a list ordered by a per-language title would reshuffle itself for every reader, +and one cursor would mean two positions at once. -`admin.titleField` falls back to `null` when every text field is localized. A -toast whose wording depended on the reading admin's locale would be worse than no -title at all; Stage 5B gives the AdminCP a locale-aware one. +The **defaults** stay shared-only. A localized content type that says nothing +about `admin.list` gets a shared-only list of columns without opting out of +anything; naming a localized column is a decision you make. + +`admin.titleField` does fall back to a localized field when there is no shared +one, because the alternative was `#123`. The AdminCP resolves it from the +translation it already loaded for the reader's language - see [Localized +editing](/docs/dev/content-engine/translation-editorial#the-admincp). diff --git a/apps/docs/content/docs/dev/content-engine/localized-public-api.mdx b/apps/docs/content/docs/dev/content-engine/localized-public-api.mdx index 9831ed686..f6013d50d 100644 --- a/apps/docs/content/docs/dev/content-engine/localized-public-api.mdx +++ b/apps/docs/content/docs/dev/content-engine/localized-public-api.mdx @@ -113,7 +113,8 @@ cache for free. Fallback is deliberately narrow, and these are the places it does **not** apply: - **Slug lookup.** See below. -- **The AdminCP.** A locale tab shows that locale, or shows `Missing`. +- **The AdminCP.** A localized field shows the language its switcher is on, or + shows nothing; a list cell shows `Missing`. - **Preview.** A [locale preview](/docs/dev/content-engine/translation-preview) is bound to one language and refuses every other. - **History and mutations.** A revision belongs to a locale; a write names one. diff --git a/apps/docs/content/docs/dev/content-engine/localized-search.mdx b/apps/docs/content/docs/dev/content-engine/localized-search.mdx index fbef1d0fa..200a05617 100644 --- a/apps/docs/content/docs/dev/content-engine/localized-search.mdx +++ b/apps/docs/content/docs/dev/content-engine/localized-search.mdx @@ -162,15 +162,14 @@ Stage 5D adds is content that actually has languages to filter on. ## The AdminCP list -A localized content type's list gets a language selector. It is a **view control, -not a filter**: picking Polish adds a column showing each record's Polish title -and status - including `Missing`, which is the row most worth finding. Hiding -untranslated records would be the opposite of what somebody choosing a language is -looking for. - -The choice lives in the URL, so it survives a reload, paginates with the table and -can be sent to whoever is doing the translating. Changing it resets the cursor: -page three of one ordering is not page three of another. +A localized content type's list is shown in the language you are already reading +VitNode in. There is no selector above the table and nothing in the URL: the +AdminCP resolves your locale server-side and asks the list route for it. + +It is a **view, not a filter**. Every record is listed, and one with no +translation in your language shows `Missing` rather than being hidden - that is +the row most worth finding. Sorting and searching still address the base table, +so a localized column is displayed without a sort control. ## No migration diff --git a/apps/docs/content/docs/dev/content-engine/preview.mdx b/apps/docs/content/docs/dev/content-engine/preview.mdx index eb387bbc9..491dd8aca 100644 --- a/apps/docs/content/docs/dev/content-engine/preview.mdx +++ b/apps/docs/content/docs/dev/content-engine/preview.mdx @@ -60,12 +60,15 @@ the honest default: linking to a page nobody has written yet would just be a you cast past it. -## `CONTENT_PREVIEW_SECRET` is required +## `CONTENT_PREVIEW_SECRET` is optional -**Preview does not work without one.** This single value is the entire -authorization story - there is no session to fall back on - so a missing or -guessable secret is not a warning, it is every draft on the site readable by -anyone who has read the VitNode source. +**Nothing requires it except preview.** Leave it unset and the API boots exactly +as it would otherwise - an install that never sends anyone a draft link has no +reason to hold a signing key, so this is not a deployment prerequisite. Set one +when you want the feature, and set a real one: this single value is the entire +authorization story - there is no session to fall back on - so a guessable secret +is not a warning, it is every draft on the site readable by anyone who has read +the VitNode source. ```bash openssl rand -base64 32 @@ -87,24 +90,19 @@ Anything else and preview **fails closed**, everywhere: | Where | What happens | | --- | --- | -| Boot, in production | The API refuses to start, naming the content types that made it mandatory | -| Boot, in development | A warning on stdout. The app starts; preview does not | +| Boot, in every environment | A warning on stdout naming the content types that wanted it. The app starts; preview does not | | `POST /{id}/preview` | **503**, with a message that names the variable | | `GET /content/{path}/preview/{token}` | **404** - the same answer a forged token gets, so an anonymous request learns nothing about the deployment | | AdminCP → System → Integrations | `contentPreview.secure: false`, next to the same flag for `CRON_SECRET` | - - Refusing to start `pnpm dev` over a missing secret would be rude. Serving - drafts to anyone who guesses a URL would be worse. So a development install - boots and preview simply does not work until you set one - and the 503 says - exactly that, rather than failing somewhere unhelpful. - - - - Next imports every route module while collecting page data, so the API's boot - check runs on the build machine too - which has no business holding a runtime - signing key. The build logs the warning and carries on; the process that - actually serves requests still refuses to start. + + A missing secret switches preview *off*; it never switches it to unsigned. + Refusing to boot would turn one content type's opt-in feature into a + prerequisite for the entire API - and for `next build`, which imports every + route module and so runs this check on a machine that has no business holding a + runtime signing key. Honouring unsigned tokens would serve drafts to anyone who + guesses a URL. So the process starts, the warning says what is missing, and the + 503 says it again to whoever clicks the button. @@ -151,9 +149,9 @@ host. Which origin it resolves against depends on where the link actually points Two different origins, deliberately: the page is served by the web app and the endpoint by the API, and assuming they share a host is exactly the assumption a -split deployment breaks. Both are validated at boot when preview is enabled, so -a malformed `NEXT_PUBLIC_WEB_URL` is a startup error rather than a broken link -handed to a reviewer. +split deployment breaks. Both are checked at boot when preview is enabled, so a +malformed `NEXT_PUBLIC_WEB_URL` is a startup warning and a 503 rather than a +broken link handed to a reviewer. `url` is built on the server, because only the definition knows whether this install has a preview page or should link at the JSON endpoint. The token is diff --git a/apps/docs/content/docs/dev/content-engine/translation-editorial.mdx b/apps/docs/content/docs/dev/content-engine/translation-editorial.mdx index 5881aadff..77acee752 100644 --- a/apps/docs/content/docs/dev/content-engine/translation-editorial.mdx +++ b/apps/docs/content/docs/dev/content-engine/translation-editorial.mdx @@ -1,12 +1,12 @@ --- -title: Translation lifecycle -description: Each language publishes on its own schedule - and a translation is never public before the record is. +title: Localized editing +description: One form, a language switcher inside each translated field, and a lifecycle each language runs on its own. icon: Languages --- [Localization](/docs/dev/content-engine/localization) gave a record one row per language. This page is about what those rows *do*: a status of their own, a -publish button of their own, and a rule about how the two levels relate. +history of their own, and a rule about how the two levels relate. ```ts title="src/content/article.ts" export const articleContentType = defineContentType({ @@ -51,11 +51,20 @@ That is the whole model. A translation's status is **subordinate**: publishing the Polish copy of a draft article puts nothing on the internet, and unpublishing the article takes every language down at once. - - A record going live exposes the languages that were *already* marked published, - and no others. This is the difference between "we are ready to launch" and "the - Polish copy is finished", and they are rarely the same day. It also means - nobody can ship a half-finished translation by pressing one button. + + Publishing a record moves every translation it has with it, in the record's own + transaction - each through this service, so each takes its delivery address and + records the publish in its own history. Unpublishing takes them all back down. + + Publication is a decision about the *record*, and there is one control for it. + Before this, a record's publish moved only the base row, so a localized article + read as `published` in the AdminCP while every language of it was still a draft: + no canonical URL, no search document, nothing public. Both rows were telling the + truth, about different things, and nobody could see which. + + A language added to an already-published record is published as it is created, + for the same reason - otherwise it would be a language with nothing left to + publish it. ## The states @@ -63,9 +72,14 @@ the article takes every language down at once. | | What it means | | --- | --- | | **Missing** | No translation row for this language. Nothing to publish. | -| **Draft** | A translation exists and is not public. Where every one starts. | +| **Draft** | A translation exists and is not public. Where a language of a draft record starts. | | **Published** | Public - if the base record is published too. | +The per-locale `publish` and `unpublish` below still exist, and are how a single +language is held back from a record that is otherwise live. They are an override, +not the ordinary route: the AdminCP's language dialog reports each language's +status and offers no publish button of its own. + There is deliberately no **Outdated**. Its honest definition is "the source language changed after this translation did", and comparing two `updatedAt` timestamps does not mean that: a typo fix in English would mark every language @@ -178,10 +192,15 @@ if (outcome) { | Delete a non-default translation | `can_delete` | `can_translate` depends on `can_view` and **not** on `can_edit`, which is the -whole point of having it: a translator gets every locale tab without gaining the +whole point of having it: a translator can write any language without gaining the ability to touch a shared field, move the record's global publication state or delete it. +The AdminCP's single Save button posts one composite request, and the split is +enforced on the *server*: the route needs `can_translate`, and it additionally +checks `can_edit` the moment the payload carries a shared field. Nothing is +inferred from whether the browser disabled an input. + Staff permissions are stored as JSON per role, so a new one simply is not on any existing role. Grant it in AdminCP → Staff. @@ -204,7 +223,8 @@ a revision, without it they simply do not. Both take `{ "expectedVersion": 3 }` and answer `{ "changed": true, "row": { 
 } }`. A stale version comes back as the same structured 409 every translation route uses, with `locale` in every arm - which -is what lets a tab strip point at the right tab rather than at the record. +is what lets the AdminCP say *which language* moved rather than just "the record +changed". Locales are canonical strings on the outside and numeric `core_languages.id` values on the inside. A client never sends an id, so it can never point one at a @@ -212,37 +232,122 @@ language it was not shown. ## The AdminCP -The edit dialog of a localized content type opens on a tab strip: +There is **one form**. No `Shared | English | Polski` strip, no locale in the URL, +and no form-global language state: + +```text +Title [ TytuƂ artykuƂu ] [ PL â–Ÿ ] +Content [ Treƛć
 ] [ PL â–Ÿ ] +Friendly URL [ tytul-artykulu ] [ PL â–Ÿ ] +Category Aktualnoƛci +Author Maciej +``` + +Each **localized field** carries its own small language switcher - the same +`multiLang` behaviour VitNode has always used for language-aware inputs. Shared +fields sit beside them with no switcher, because there is nothing to switch. + +### Two different languages + +Two things are called "the language" and they are not the same thing: + +| | What it decides | +| --- | --- | +| **Your VitNode language** | What the AdminCP *shows you first*: the list's titles, and the language every localized input opens in | +| **`localization.defaultLocale`** | Which translation a record cannot exist without, and what a public reader falls back to | + +Reading the AdminCP in Polish opens every localized field on Polish, whatever +`defaultLocale` says. It is the language you are already in; being asked to pick +it again would be a control with one sensible answer. + +If your language is not one the install serves, the field falls back to the first +enabled one rather than writing into a language nothing renders. On a +one-language install no switcher is rendered at all. + +### Switching one field, not the screen + +Switching `Title` to English leaves the body and the URL in Polish. That is +deliberate: comparing one heading against another should not move the whole page. + +```text +Title [ Article title ] [ EN â–Ÿ ] ← switched +Content [ Treƛć
 ] [ PL â–Ÿ ] ← unchanged +Friendly URL [ tytul-artykulu ] [ PL â–Ÿ ] ← unchanged +``` + +Selecting a language whose translation does not exist shows an **empty box**, and +saving writes nothing for it. Looking at a language is not a decision to create a +translation in it. + +### One Save, one transaction + +The form holds every language at once - read in one request when it opens, not +one request per language - and one Save writes all of it: ```text -Shared | English ✓ | Polski ● | Deutsch ○ +BEGIN + update the base row with its own expectedVersion + update the EN translation with its own expectedVersion + create the PL translation +COMMIT ``` -- **Shared** holds the fields that are not per-language, plus the record's global - publication, history and scheduling. -- **Each locale tab** holds that language's fields, its status, its version, its - publish button, its history and - for anything but the default - its delete - button. +Only what actually changed is sent. A Polish-only edit sends no shared values and +no English entry, so the base version, the English version, the English revision +history and the English cache are all left exactly where they were. -The strip loads metadata only, in one request. A language's values are fetched -when its tab is opened, so opening the dialog on a record with nine languages -costs one query rather than nine. +If any part is refused - somebody saved the English copy while you were typing - +**nothing commits**, and the error names the language. -Only languages the app actually serves get a tab: they come from the app config, -already filtered to the enabled ones. And **opening a tab never creates a -translation** - a missing language shows `Missing` and an explicit create button, -because looking is not a decision to publish an empty page. +### Per-language lifecycle -### When somebody else got there first +Status, publication, history, restore and delete are genuinely per-language and +genuinely not fields, so they live in their own row action rather than around the +form: the language is a parameter of *that* decision, not a mode the whole screen +is in. -A stale save keeps the form exactly as you left it and shows a banner naming the -language that moved, with a **Reload this language** button. Nothing is retried -and nothing is merged: reloading is a decision, and so is saving over what the -reload reveals. +Only languages the app actually serves appear: they come from the app config, +already filtered to the enabled ones. The default-locale translation has no +delete button. + +### Field-local languages are not JSON storage + +Worth stating plainly, because the form makes it look otherwise: a field-local +language switcher is a **UI** decision. Nothing about the storage model changed. + +```text +Admin form Storage + +Title [PL â–Ÿ] blog_posts +Content [EN â–Ÿ] id, categoryId, authorId, status, version +Friendly URL[PL â–Ÿ] ──▶ +Category (shared) blog_posts_translations +Author (shared) itemId, languageId, title, friendlyUrl, + content, status, version +``` + +One base row, one translation row per `(itemId, languageId)`, each with its own +`version`, its own `status`, its own `publishedAt` and its own revision history. +The form holds `[{ languageCode, value }]` per field only while you are editing; +the save takes it apart again and writes rows. + +There is no JSON column, and the old `MultiLangValue` persistence model has not +come back. + +### The list + +A localized list shows the record in the language you are reading, with nothing +above the table to choose: + +```text +Name Color +Aktualnoƛci ● #3260c0 +Poradniki ● #23a06b +``` -English and Polish edits are two different rows with two different version -counters, so they never conflict with each other - only with another edit of the -*same* language. +A record with no translation in your language shows `Missing` rather than a +blank - that is the row worth spotting. Sorting and searching still address the +base table, so a localized column is displayed but not sortable. ## Stage 5B boundaries @@ -261,5 +366,5 @@ both frozen revisions - see route that mints one landed with Stage 5C. Locale-specific *scheduling* stays outside Stage 5 entirely. A scheduled global -publish exposes the languages already marked published and publishes no drafts; a +publish moves every language the record has with it; a scheduled global unpublish hides every language at once. diff --git a/apps/docs/content/docs/dev/content-engine/translation-preview.mdx b/apps/docs/content/docs/dev/content-engine/translation-preview.mdx index 5cfa3741c..f0dbaca82 100644 --- a/apps/docs/content/docs/dev/content-engine/translation-preview.mdx +++ b/apps/docs/content/docs/dev/content-engine/translation-preview.mdx @@ -119,9 +119,9 @@ AdminCP is already allowed to see it. The link is the credential from there on. It freezes the record's newest **shared** revision and that locale's newest **translation** revision, and returns both ids alongside the link. A locale with -no translation is a 404 rather than a link to the fallback - the button is on a -language tab, and a link that quietly previewed a different language would be -worse than no link. +no translation is a 404 rather than a link to the fallback - the link names one +language, and one that quietly previewed a different one would be worse than no +link. `?locale=` is a query parameter rather than a second placeholder in `editorial.preview.pathTemplate`, and that is deliberate: a new placeholder would diff --git a/apps/docs/content/docs/dev/content-engine/translation-revisions.mdx b/apps/docs/content/docs/dev/content-engine/translation-revisions.mdx index 0e5ec8a85..f5121974d 100644 --- a/apps/docs/content/docs/dev/content-engine/translation-revisions.mdx +++ b/apps/docs/content/docs/dev/content-engine/translation-revisions.mdx @@ -196,14 +196,16 @@ demand, one at a time. ## In the AdminCP -Each locale tab has its own **Show this language's history** section, loaded when -it is opened rather than with the tab - a language's history can be long, and -nobody who only wanted to fix a typo should pay for it. Restore is offered on -every version but the current one, and only with `can_restore`. +History is per-language, so it lives in the row's **Languages** action rather than +in the form: each language gets its own **Show this language's history** section, +loaded when it is opened rather than with the dialog - a language's history can be +long, and nobody who only wanted to fix a typo should pay for it. Restore is +offered on every version but the current one, and only with `can_restore`. ## Related -- [Translation lifecycle](/docs/dev/content-engine/translation-editorial) - the - per-locale publish/unpublish these revisions record +- [Localized editing](/docs/dev/content-engine/translation-editorial) - the + per-locale publish/unpublish these revisions record, and the form they are + reached from - [Revisions](/docs/dev/content-engine/revisions) - the shared history the base row keeps, and the retention rules both share diff --git a/apps/docs/content/docs/dev/content-engine/translation-service.mdx b/apps/docs/content/docs/dev/content-engine/translation-service.mdx index 24bf54446..34669fc42 100644 --- a/apps/docs/content/docs/dev/content-engine/translation-service.mdx +++ b/apps/docs/content/docs/dev/content-engine/translation-service.mdx @@ -214,8 +214,8 @@ Five distinct outcomes, because a client that cannot tell them apart can only sh | Deleting the default translation | `409` | `CONTENT_DEFAULT_TRANSLATION_REQUIRED` | | A localized slug is taken **in this language** | `409` | `CONTENT_TRANSLATION_UNIQUE_CONFLICT` | -The version conflict names the locale, which is the one thing a locale tab strip -has to know to reload the right tab: +The version conflict names the locale, which is the one thing the AdminCP needs to +say *which language* somebody else saved: ```json { diff --git a/apps/docs/content/docs/dev/index.mdx b/apps/docs/content/docs/dev/index.mdx index f04aff5ce..96cdad078 100644 --- a/apps/docs/content/docs/dev/index.mdx +++ b/apps/docs/content/docs/dev/index.mdx @@ -8,9 +8,29 @@ icon: Power We're working hard to bring you the best documentation experience. +## Support + +- [Postgres 18-19](https://www.postgresql.org/) (min: v18, recommended: v19) - database support. + +### Supported Package Managers + +- [bun](https://bun.com/) (min: v1.1, recommended: v1.3) +- [pnpm](https://pnpm.io/) (min: v10, recommended: v11) +- [node.js](https://nodejs.org/) (min: v22, recommended: v24) + +### Optional Support + +- [Redis](https://redis.io/) (min: v7, recommended: v8) - caching and session management. +- [Docker](https://www.docker.com/) (min: v24, recommended: v25) - containerization and deployment. +- [ElasticSearch](https://www.elastic.co/elasticsearch/) (min: v8, recommended: v9) - advanced search capabilities. +- [NodeMailer](https://nodemailer.com/about/) - email sending capabilities. +- [Resend](https://resend.com/) - email sending capabilities. +- [S3](https://aws.amazon.com/s3/) - file storage. +- [Supabase](https://supabase.com/) - database management and file storage. + ## Get started -import { Tab, Tabs } from 'fumadocs-ui/components/tabs'; +import { Tab, Tabs } from "fumadocs-ui/components/tabs"; diff --git a/apps/docs/content/docs/guides/blog.mdx b/apps/docs/content/docs/guides/blog.mdx index 4ffe49884..c1e79d1ea 100644 --- a/apps/docs/content/docs/guides/blog.mdx +++ b/apps/docs/content/docs/guides/blog.mdx @@ -128,10 +128,10 @@ export const blogCategoryContentType = defineContentType({ admin: { label: { plural: "Categories", singular: "Category" }, permissionModule: "categories", - titleField: null, + titleField: "name", create: { mode: "dialog" }, edit: { mode: "dialog" }, - list: { columns: ["color", "updatedAt"] }, + list: { columns: ["name", "color", "updatedAt"] }, }, }); ``` @@ -140,16 +140,35 @@ The colour is the AdminCP's own picker through a [field override](/docs/dev/content-engine/admin-form-layouts#field-and-column-overrides), and the colour column is a swatch **plus** the value in words. - - `titleField` is `null` because every text field on a category is localized - - left undefined, the engine would pick `color`, and "#3260c0 has been deleted" - is not a sentence anybody wants to read. The consequence is that the article's - category picker labels its options `#3` rather than "Engineering": a relation - label is resolved from a **shared** column on the target, and a localized - content type has none. Resolving one from the translation table is a Content - Engine change, not something a plugin should paper over - `config.tsx` is - loaded by the `vitnode` CLI, so a field override cannot reach a server action - to look the names up itself. +`name` is localized and is still the list's first column and the content type's +`titleField`. That is the split the engine draws: showing a localized value is a +projection the AdminCP resolves in *your* language, while ordering and filtering +stay on the base table. The list reads: + +```text +Name Color Updated +Aktualnoƛci ● #3260c0 2 days ago +Poradniki ● #23a06b a week ago +``` + +and the dialog is one form, with the switcher inside the field that needs one: + +```text +Name [ Aktualnoƛci ] [ PL â–Ÿ ] + +Color [ ● #3260c0 ] + + Cancel Save +``` + + + `titleField: "name"` fixes the list, the toasts and the page headings, because + the AdminCP resolves a localized title from the translation it already loaded. + The **relation picker** is a different query: a relation label is resolved from + a shared column on the target with a SQL join, and a localized content type has + none - so the article's category picker labels its options `#3` rather than + "Aktualnoƛci". Resolving one from the translation table is a Content Engine + change, not something a plugin should paper over. ### Articles - the rich example @@ -179,10 +198,24 @@ delivery: Below `lg` it is a single column: body first, then metadata, then the actions. -Articles are localized, so the editor is the same layout on the shared tab and -on each language tab - `ContentFormField` renders nothing for a field the -current surface does not have, so `title` and `content` appear per language -while `categoryId` and `authorId` appear once. +Articles are localized, and the layout does not know it. `title`, `content` and +`friendlyUrl` are stored per language and `categoryId` and `authorId` are not - +so the first three render their own small language switchers and the last two do +not, from one `ContentFormField` call each: + +```text +Title [ TytuƂ artykuƂu ] [ PL â–Ÿ ] +Content [ AutoFormEditor ] [ PL â–Ÿ ] +Friendly URL [ tytul-artykulu ] [ PL â–Ÿ ] +Category Aktualnoƛci +Author Maciej +``` + +Everything opens in the language you are reading VitNode in - not in +`defaultLocale` - and switching `Title` to English leaves the editor in Polish. +One Save writes the base row and every changed language in one transaction. +Per-language publish, history and delete live in the list's **Languages** row +action. ### Upgrading from an older blog diff --git a/apps/docs/src/locales/@vitnode/blog/pl.json b/apps/docs/src/locales/@vitnode/blog/pl.json index e3eb86744..15a622106 100644 --- a/apps/docs/src/locales/@vitnode/blog/pl.json +++ b/apps/docs/src/locales/@vitnode/blog/pl.json @@ -34,8 +34,7 @@ "form": { "publish": "Publikacja", "settings": { - "title": "Ustawienia artykuƂu", - "locale_desc": "Adres i metadane wersji w tym języku." + "title": "Ustawienia artykuƂu" } } }, diff --git a/packages/vitnode/src/api/middlewares/global.middleware.ts b/packages/vitnode/src/api/middlewares/global.middleware.ts index e46235cad..cdd278db0 100644 --- a/packages/vitnode/src/api/middlewares/global.middleware.ts +++ b/packages/vitnode/src/api/middlewares/global.middleware.ts @@ -27,7 +27,7 @@ import { SessionAdminModel } from "@/api/models/session-admin"; import { StorageModel } from "@/api/models/storage"; import { validateContentTypes } from "@/content/registry"; import { ensureContentLocalizationLanguages } from "@/content/server/language-resolver"; -import { assertContentPreviewConfig } from "@/content/server/preview-config"; +import { warnAboutContentPreviewConfig } from "@/content/server/preview-config"; import { CONFIG } from "@/lib/config"; import { collectLocaleCodes } from "@/lib/i18n/load-messages"; import { buildApiMessagesSources } from "@/lib/i18n/sources"; @@ -260,9 +260,10 @@ export const globalMiddleware = ({ ); // Once, here, because "does anything have preview enabled" is only answerable - // after every plugin's content types are in. Throws in production rather than - // booting an install whose preview links anyone could forge. - assertContentPreviewConfig({ + // after every plugin's content types are in. A warning, never a boot failure: + // `CONTENT_PREVIEW_SECRET` is optional and preview is what fails closed + // without it. + warnAboutContentPreviewConfig({ contentTypes: contentTypesMetadata, secret: process.env.CONTENT_PREVIEW_SECRET, }); diff --git a/packages/vitnode/src/api/modules/admin/debug/routes/integrations.route.ts b/packages/vitnode/src/api/modules/admin/debug/routes/integrations.route.ts index bb760c958..f896b0d37 100644 --- a/packages/vitnode/src/api/modules/admin/debug/routes/integrations.route.ts +++ b/packages/vitnode/src/api/modules/admin/debug/routes/integrations.route.ts @@ -48,9 +48,9 @@ export const integrationsDebugAdminRoute = buildRoute({ // How many content types can mint preview links. contentTypes: z.number(), // `false` when `CONTENT_PREVIEW_SECRET` is missing, left at its - // well-known default, or too short to be a signing key. Preview - // does not merely warn in that state - it refuses to serve, and - // a production boot fails outright. + // well-known default, or too short to be a signing key. The + // variable is optional and the API boots without it, but preview + // does not merely warn in that state - it refuses to serve. secure: z.boolean(), }), cron: z.object({ diff --git a/packages/vitnode/src/components/form/auto-form.tsx b/packages/vitnode/src/components/form/auto-form.tsx index 24677827a..aca27e168 100644 --- a/packages/vitnode/src/components/form/auto-form.tsx +++ b/packages/vitnode/src/components/form/auto-form.tsx @@ -65,6 +65,15 @@ export interface ItemAutoFormComponentProps { itemParams?: InputParams; label?: React.ReactNode; labelRight?: React.ReactNode; + /** + * Whether this field holds one value per language. + * + * Set by whoever builds the field list - the Content Engine reads it off + * `localized: true` - so a custom component can pass it straight through to + * `AutoFormInput`, `AutoFormTextarea` or `AutoFormEditor` and get the language + * switcher without knowing why the field has one. + */ + multiLang?: boolean; otherProps: { ["aria-invalid"]?: boolean; enum?: string[]; diff --git a/packages/vitnode/src/components/form/fields/input.tsx b/packages/vitnode/src/components/form/fields/input.tsx index 5185c6e55..1ac88c27e 100644 --- a/packages/vitnode/src/components/form/fields/input.tsx +++ b/packages/vitnode/src/components/form/fields/input.tsx @@ -43,8 +43,11 @@ const MultiLangInput = ({ )} - - + {/* `FormControl` on the input itself, not on the group: it is what hands + the field its id, and a label pointing at the wrapping div labels + nothing a screen reader can use. */} + + - {languages.length > 1 && ( - - - - )} - - + + {languages.length > 1 && ( + + + + )} + {!!description && {description}} diff --git a/packages/vitnode/src/components/form/fields/textarea.test.tsx b/packages/vitnode/src/components/form/fields/textarea.test.tsx new file mode 100644 index 000000000..f08be6feb --- /dev/null +++ b/packages/vitnode/src/components/form/fields/textarea.test.tsx @@ -0,0 +1,194 @@ +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { type FieldValues, useForm } from "react-hook-form"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import type { InputParams } from "@/lib/helpers/auto-form"; + +import { LanguagesProvider } from "@/components/languages-provider"; +import { Form, FormField } from "@/components/ui/form"; + +import { AutoFormTextarea } from "./textarea"; + +vi.mock("next-intl", () => ({ + useLocale: () => "en", + useTranslations: () => (key: string) => key, +})); + +const LANGUAGES = [ + { code: "en", name: "English" }, + { code: "pl", name: "Polski" }, +]; + +const Harness = ({ + onSubmit = vi.fn(), + defaultValue, + languages = LANGUAGES, + itemParams, + multiLang = true, +}: { + defaultValue?: unknown; + itemParams?: InputParams; + languages?: { code: string; enabled?: boolean; name: string }[]; + multiLang?: boolean; + onSubmit?: (values: FieldValues) => void; +}) => { + const form = useForm({ + defaultValues: { body: defaultValue } as FieldValues, + }); + + return ( + + + ( + + )} + /> + + + + ); +}; + +describe("AutoFormTextarea multiLang", () => { + beforeEach(() => { + Element.prototype.scrollIntoView = vi.fn(); + Element.prototype.hasPointerCapture = vi.fn(() => false); + Element.prototype.setPointerCapture = vi.fn(); + Element.prototype.releasePointerCapture = vi.fn(); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + it("renders the language select when more than one language is enabled", () => { + render(); + + expect(screen.getByRole("combobox")).toBeDefined(); + }); + + it("shows no selector on a one-language install", () => { + // A switcher with one option is a control that cannot do anything. + render(); + + expect(screen.queryByRole("combobox")).toBeNull(); + }); + + it("shows none for a shared field either", () => { + render(); + + expect(screen.queryByRole("combobox")).toBeNull(); + }); + + it("starts on the reader's own language", () => { + render( + , + ); + + // `useLocale()` is `en`, and `en` is second in the stored array - so this is + // the reader's language rather than whatever happened to be written first. + expect(screen.getByRole("textbox").value).toBe( + "Hello", + ); + }); + + it("writes the typed value as a { languageCode, value }[] array", async () => { + const onSubmit = vi.fn(); + render(); + + fireEvent.change(screen.getByRole("textbox"), { + target: { value: "Hello" }, + }); + fireEvent.click(screen.getByRole("button", { name: "submit" })); + + await waitFor(() => { + expect(onSubmit).toHaveBeenCalledWith( + { body: [{ languageCode: "en", value: "Hello" }] }, + expect.anything(), + ); + }); + }); + + it("keeps a value per language, and restores it on the way back", async () => { + const onSubmit = vi.fn(); + render( + , + ); + + const switchTo = async (name: string) => { + fireEvent.click(screen.getByRole("combobox")); + const option = await screen.findByRole("option", { name }); + fireEvent.pointerDown(option); + fireEvent.click(option); + }; + + await switchTo("Polski"); + await waitFor(() => { + expect(screen.getByRole("textbox").value).toBe( + "Czeƛć", + ); + }); + + await switchTo("English"); + await waitFor(() => { + expect(screen.getByRole("textbox").value).toBe( + "Hello", + ); + }); + }); + + it("shows an empty box for a language with no translation, and writes nothing", async () => { + const onSubmit = vi.fn(); + render( + , + ); + + fireEvent.click(screen.getByRole("combobox")); + const option = await screen.findByRole("option", { name: "Polski" }); + fireEvent.pointerDown(option); + fireEvent.click(option); + + await waitFor(() => { + expect(screen.getByRole("textbox").value).toBe(""); + }); + + fireEvent.click(screen.getByRole("button", { name: "submit" })); + + // Looking at a language is not a decision to create a translation in it. + await waitFor(() => { + expect(onSubmit).toHaveBeenCalledWith( + { body: [{ languageCode: "en", value: "Hello" }] }, + expect.anything(), + ); + }); + }); + + it("applies the value maxLength from itemParams to the textarea", () => { + render(); + + expect(screen.getByRole("textbox").getAttribute("maxLength")).toBe("12"); + }); +}); diff --git a/packages/vitnode/src/components/form/fields/textarea.tsx b/packages/vitnode/src/components/form/fields/textarea.tsx index 4f0026834..fcfa27552 100644 --- a/packages/vitnode/src/components/form/fields/textarea.tsx +++ b/packages/vitnode/src/components/form/fields/textarea.tsx @@ -3,27 +3,110 @@ import type React from "react"; import { FormControl, FormMessage } from "@/components/ui/form"; import { InputGroup, InputGroupTextarea } from "@/components/ui/input-group"; import { Textarea } from "@/components/ui/textarea"; +import { getMultiLangConstraints } from "@/lib/helpers/multi-lang"; import type { ItemAutoFormComponentProps } from "../auto-form"; import { AutoFormDesc } from "../common/desc"; import { AutoFormLabel } from "../common/label"; +import { MultiLangSelect, useMultiLangField } from "./multi-lang"; + +type AutoFormTextareaProps = ItemAutoFormComponentProps & + Omit, "value"> & { + description?: React.ReactNode; + label?: React.ReactNode; + multiLang?: boolean; + }; + +/** + * The same textarea, holding one value per language. + * + * The switcher sits beside the label rather than inside the box, which is where + * `AutoFormEditor` puts it too: a textarea is resizable and multi-line, so an + * inline addon would end up floating in the middle of the control. + */ +const MultiLangTextarea = ({ + label, + labelRight, + description, + isOptional, + field, + itemParams, + ...props +}: Omit & { + isOptional?: boolean; +}) => { + const { languages, selected, setSelected, currentValue, setValue } = + useMultiLangField(field); + const { maxLength, minLength } = getMultiLangConstraints(itemParams); + + return ( + <> +
+ {!!label && ( + + {label} + + )} + {languages.length > 1 && ( + + )} +
+ + +