@thebes/cadmea 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 BowenLabs
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,133 @@
1
+ # @thebes/cadmea
2
+
3
+ Generic SolidJS admin-UI components for `@thebes/cadmus/cms` collections.
4
+
5
+ > **0.x — active development.** APIs will change. Not production-ready.
6
+ > Star [bowenlabs/project-thebes](https://github.com/bowenlabs/project-thebes) to follow along.
7
+
8
+ ---
9
+
10
+ ## What is this?
11
+
12
+ Cadmus's `cms` subpath (collection config, schema codegen, the Local API,
13
+ admin-introspection metadata) is the engine — framework-agnostic, no UI of
14
+ its own. This package is the other half: the actual SolidJS components
15
+ that render a generic admin UI from that metadata, the same way Payload
16
+ splits `payload` core from `@payloadcms/next`/`@payloadcms/ui`.
17
+
18
+ `app/workers/cadmea` (Thebes's reference CMS) consumes this package rather
19
+ than owning the components directly.
20
+
21
+ ---
22
+
23
+ ## Install
24
+
25
+ ```bash
26
+ pnpm add @thebes/cadmea @thebes/cadmus solid-js
27
+ ```
28
+
29
+ Built with [Vite+'s `vp pack`](https://viteplus.dev/guide/pack)
30
+ (Rolldown-based, wraps [`tsdown`](https://tsdown.dev) internally) plus
31
+ [`@rolldown/plugin-babel`](https://github.com/rolldown/plugins) running
32
+ Solid's JSX through `babel-preset-solid` directly, to produce real
33
+ fine-grained-reactive output — not the generic `createElement`-style
34
+ transform plain Rolldown/esbuild would otherwise produce by default. See
35
+ `DECISIONS.md`'s 2026-06-24 entry for how the babel plugin is wired into
36
+ `vp pack`'s `pack` block in `vite.config.ts`. Ships separate
37
+ `browser`/`worker`/`node`/`deno` export conditions,
38
+ matching how `solid-js` itself ships, so the right build
39
+ (client-hydration vs. SSR) resolves automatically wherever you consume it
40
+ — including from outside this monorepo, unlike the source-only shape this
41
+ package started as (see `DECISIONS.md`'s 2026-06-22 entries).
42
+
43
+ ---
44
+
45
+ ## Components
46
+
47
+ ```typescript
48
+ import { CollectionList, CollectionEdit } from '@thebes/cadmea'
49
+ ```
50
+
51
+ **`CollectionList`** — generic table view. Renders one column per field
52
+ (excluding `id` and `richText` fields, which aren't supported as plain
53
+ table cells yet), with optional row-click navigation.
54
+
55
+ **`CollectionEdit`** — generic create/edit form. Renders one input per
56
+ field (excluding `id`), with `text`/`select`/`number` editable inputs and
57
+ `date` fields shown read-only. Fields without a supported renderer
58
+ (`richText`/`relationship`/`array`/`upload`/`checkbox`) are silently
59
+ skipped rather than crashing — contributions welcome.
60
+
61
+ Both take a `CollectionConfig` (from `@thebes/cadmus/cms`) as their
62
+ `config` prop.
63
+
64
+ ---
65
+
66
+ ## TanStack Start mounting helper
67
+
68
+ ```typescript
69
+ import {
70
+ createCollectionListPage,
71
+ createCollectionCreatePage,
72
+ createCollectionEditPage,
73
+ } from '@thebes/cadmea/tanstack-start'
74
+ ```
75
+
76
+ The equivalent of Payload's `@payloadcms/next` catch-all route pattern —
77
+ factory functions that wire `CollectionList`/`CollectionEdit` together
78
+ with `@tanstack/solid-query` (fetching, mutating, cache invalidation) and
79
+ return a ready-to-use route `component`. TanStack Router's file-based
80
+ routing still needs a real file per route (there's no runtime catch-all
81
+ the way Next.js's `[[...segments]]` works), but each route file shrinks
82
+ from ~40 hand-wired lines to ~15:
83
+
84
+ ```tsx
85
+ // src/routes/admin/pages/index.tsx
86
+ import { createCollectionListPage } from '@thebes/cadmea/tanstack-start'
87
+ import { createFileRoute, useNavigate } from '@tanstack/solid-router'
88
+ import { pagesCollection } from '../../../../cadmea.config.js'
89
+ import { getPages } from '../../server-functions/pages'
90
+
91
+ export const Route = createFileRoute('/admin/pages/')({ component: PagesPage })
92
+
93
+ function PagesPage() {
94
+ const navigate = useNavigate()
95
+ const Page = createCollectionListPage({
96
+ collection: pagesCollection,
97
+ label: 'Pages',
98
+ queryKey: ['pages'],
99
+ queryFn: () => getPages(),
100
+ newHref: '/admin/pages/new',
101
+ onRowClick: (row) => navigate({ to: '/admin/pages/$pageId', params: { pageId: String(row.id) } }),
102
+ })
103
+ return <Page />
104
+ }
105
+ ```
106
+
107
+ Navigation (`onRowClick`, `onCreated`, `onDeleted`) stays in the route
108
+ file rather than this package calling `useNavigate()` itself — TanStack
109
+ Router's route-typing is generated per-app, so a generic package can't
110
+ produce a correctly-typed `navigate()` call for routes it doesn't know
111
+ about. See `app/workers/cadmea/src/routes/admin/pages/` in this repo for
112
+ all three factories in real use (list, create, edit+delete).
113
+
114
+ ---
115
+
116
+ ## What this isn't (yet)
117
+
118
+ No code-generation CLI that writes these route files for you from a
119
+ collection config — you still create one thin file per collection per
120
+ view. Worth revisiting once enough collections exist to justify that
121
+ investment. See this repo's `DECISIONS.md`.
122
+
123
+ ---
124
+
125
+ ## Licensing
126
+
127
+ MIT. See [LICENSE](./LICENSE) for full terms.
128
+
129
+ ---
130
+
131
+ ## Maintained by
132
+
133
+ [BowenLabs](https://bowenlabs.com)
@@ -0,0 +1,36 @@
1
+ import { effect, setAttribute, template, use } from "solid-js/web";
2
+ import { onCleanup, onMount } from "solid-js";
3
+ import { Editor } from "@tiptap/core";
4
+ import StarterKit from "@tiptap/starter-kit";
5
+ //#region src/RichTextEditor.tsx
6
+ var _tmpl$ = /*#__PURE__*/ template(`<div class="textarea h-auto min-h-32 w-full">`);
7
+ function RichTextEditor(props) {
8
+ let container;
9
+ let editor;
10
+ onMount(() => {
11
+ if (!container) return;
12
+ editor = new Editor({
13
+ element: container,
14
+ extensions: [StarterKit],
15
+ content: props.content ?? "",
16
+ onUpdate: ({ editor: current }) => {
17
+ props.onChange(current.getJSON());
18
+ }
19
+ });
20
+ });
21
+ onCleanup(() => {
22
+ editor?.destroy();
23
+ });
24
+ return (() => {
25
+ var _el$ = _tmpl$();
26
+ use((el) => {
27
+ container = el;
28
+ }, _el$);
29
+ effect(() => setAttribute(_el$, "id", props.id));
30
+ return _el$;
31
+ })();
32
+ }
33
+ //#endregion
34
+ export { RichTextEditor };
35
+
36
+ //# sourceMappingURL=RichTextEditor-BPilh7Pw.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"RichTextEditor-BPilh7Pw.js","names":["Editor","StarterKit","onCleanup","onMount","RichTextEditorProps","id","content","onChange","doc","RichTextEditor","props","container","HTMLDivElement","editor","element","extensions","onUpdate","current","getJSON","destroy","_el$","_tmpl$","_$use","el","_$effect","_$setAttribute"],"sources":["../src/RichTextEditor.tsx"],"sourcesContent":["import { Editor } from \"@tiptap/core\";\nimport StarterKit from \"@tiptap/starter-kit\";\nimport { onCleanup, onMount } from \"solid-js\";\n\nexport interface RichTextEditorProps {\n id?: string;\n /** TipTap's native JSON document shape — stored as-is, no transform layer. */\n content?: object;\n onChange: (doc: object) => void;\n}\n\n// No official Solid binding for TipTap exists, so this wraps @tiptap/core's\n// vanilla `Editor` class directly in Solid's onMount/onCleanup lifecycle —\n// per CLAUDE.md's preference for the framework-agnostic core API over an\n// unofficial community port (same reasoning already applied to Phosphor\n// icons). `content` is only read once at mount, matching how this form's\n// other fields are initialized from `initialValues` rather than reacting\n// to prop changes after the fact — there is no live re-sync if `content`\n// changes out from under an already-mounted editor.\nexport function RichTextEditor(props: RichTextEditorProps) {\n let container: HTMLDivElement | undefined;\n let editor: Editor | undefined;\n\n onMount(() => {\n if (!container) return;\n editor = new Editor({\n element: container,\n extensions: [StarterKit],\n content: props.content ?? \"\",\n onUpdate: ({ editor: current }) => {\n props.onChange(current.getJSON());\n },\n });\n });\n\n onCleanup(() => {\n editor?.destroy();\n });\n\n return (\n <div\n id={props.id}\n class=\"textarea h-auto min-h-32 w-full\"\n ref={(el) => {\n container = el;\n }}\n />\n );\n}\n"],"mappings":";;;;;;AAmBA,SAAgBS,eAAeC,OAA4B;CACzD,IAAIC;CACJ,IAAIE;CAEJV,cAAc;EACZ,IAAI,CAACQ,WAAW;EAChBE,SAAS,IAAIb,OAAO;GAClBc,SAASH;GACTI,YAAY,CAACd,UAAU;GACvBK,SAASI,MAAMJ,WAAW;GAC1BU,WAAW,EAAEH,QAAQI,cAAc;IACjCP,MAAMH,SAASU,QAAQC,QAAQ,CAAC;GAClC;EACF,CAAC;CACH,CAAC;CAEDhB,gBAAgB;EACdW,QAAQM,QAAQ;CAClB,CAAC;CAED,cAAA;EAAA,IAAAC,OAAAC,OAAA;EAAAC,KAIUC,OAAO;GACXZ,YAAYY;EACd,GAACH,IAAA;EAAAI,aAAAC,aAAAL,MAAA,MAJGV,MAAML,EAAE,CAAA;EAAA,OAAAe;CAAA,EAAA,CAAA;AAOlB"}
@@ -0,0 +1,15 @@
1
+ import { escape, ssr, ssrAttribute } from "solid-js/web";
2
+ import { onCleanup, onMount } from "solid-js";
3
+ import "@tiptap/core";
4
+ import "@tiptap/starter-kit";
5
+ //#region src/RichTextEditor.tsx
6
+ var _tmpl$ = ["<div", " class=\"textarea h-auto min-h-32 w-full\"></div>"];
7
+ function RichTextEditor(props) {
8
+ onMount(() => {});
9
+ onCleanup(() => {});
10
+ return ssr(_tmpl$, ssrAttribute("id", escape(props.id, true), false));
11
+ }
12
+ //#endregion
13
+ export { RichTextEditor };
14
+
15
+ //# sourceMappingURL=RichTextEditor-DcLqdFY7.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"RichTextEditor-DcLqdFY7.js","names":["Editor","StarterKit","onCleanup","onMount","RichTextEditorProps","id","content","onChange","doc","RichTextEditor","props","container","HTMLDivElement","editor","element","extensions","onUpdate","current","getJSON","destroy","_$ssr","_tmpl$","_$ssrAttribute","_$escape"],"sources":["../src/RichTextEditor.tsx"],"sourcesContent":["import { Editor } from \"@tiptap/core\";\nimport StarterKit from \"@tiptap/starter-kit\";\nimport { onCleanup, onMount } from \"solid-js\";\n\nexport interface RichTextEditorProps {\n id?: string;\n /** TipTap's native JSON document shape — stored as-is, no transform layer. */\n content?: object;\n onChange: (doc: object) => void;\n}\n\n// No official Solid binding for TipTap exists, so this wraps @tiptap/core's\n// vanilla `Editor` class directly in Solid's onMount/onCleanup lifecycle —\n// per CLAUDE.md's preference for the framework-agnostic core API over an\n// unofficial community port (same reasoning already applied to Phosphor\n// icons). `content` is only read once at mount, matching how this form's\n// other fields are initialized from `initialValues` rather than reacting\n// to prop changes after the fact — there is no live re-sync if `content`\n// changes out from under an already-mounted editor.\nexport function RichTextEditor(props: RichTextEditorProps) {\n let container: HTMLDivElement | undefined;\n let editor: Editor | undefined;\n\n onMount(() => {\n if (!container) return;\n editor = new Editor({\n element: container,\n extensions: [StarterKit],\n content: props.content ?? \"\",\n onUpdate: ({ editor: current }) => {\n props.onChange(current.getJSON());\n },\n });\n });\n\n onCleanup(() => {\n editor?.destroy();\n });\n\n return (\n <div\n id={props.id}\n class=\"textarea h-auto min-h-32 w-full\"\n ref={(el) => {\n container = el;\n }}\n />\n );\n}\n"],"mappings":";;;;;;AAmBA,SAAgBS,eAAeC,OAA4B;CAIzDP,cAAc,CAUd,CAAC;CAEDD,gBAAgB,CAEhB,CAAC;CAED,OAAAkB,IAAAC,QAAAC,aAAA,MAAAC,OAEQb,MAAML,IAAE,IAAA,GAAA,KAAA,CAAA;AAOlB"}
@@ -0,0 +1,147 @@
1
+ import { JSX } from "solid-js";
2
+ import { CollectionConfig } from "@thebes/cadmus/cms";
3
+
4
+ //#region src/capabilities.d.ts
5
+ /**
6
+ * Drives action gating in `CollectionEdit` and the `tanstack-start` list/
7
+ * edit page factories — hide/disable an action a context can't perform
8
+ * rather than let it fail server-side after a click. A field left
9
+ * `undefined` reads as "allowed", mirroring `@thebes/cadmus/cms`'s own
10
+ * "no access fn configured = allowed" default, so collections that don't
11
+ * wire this up at all keep today's unrestricted behavior.
12
+ */
13
+ interface CollectionCapabilities {
14
+ canCreate?: boolean;
15
+ canUpdate?: boolean;
16
+ canDelete?: boolean;
17
+ }
18
+ //#endregion
19
+ //#region src/CollectionEdit.d.ts
20
+ interface RelationshipOption {
21
+ id: number;
22
+ label: string;
23
+ }
24
+ /**
25
+ * Replaces the generic "Save" button with "Save draft"/"Publish" when the
26
+ * collection has `versions: { drafts: true }` — a separate privilege from
27
+ * a plain update, matching `access.publish` in `@thebes/cadmus/cms`.
28
+ * `onPublish` takes no values: publishing acts on whatever was last saved
29
+ * as a draft (the consuming route tracks which version that is), not on
30
+ * the live form state.
31
+ */
32
+ interface DraftActions {
33
+ onSaveDraft: (values: Record<string, unknown>) => void | Promise<void>;
34
+ onPublish?: () => void | Promise<void>;
35
+ /**
36
+ * Opens a live preview of the last saved draft (issue #28) — like
37
+ * `onPublish`, acts on whatever was last saved as a draft, not the live
38
+ * form state. Omit to not render the Preview button at all.
39
+ */
40
+ onPreview?: () => void | Promise<void>;
41
+ saving?: boolean;
42
+ publishing?: boolean;
43
+ previewing?: boolean;
44
+ /** Disables Publish — e.g. until a draft has been saved at least once. */
45
+ canPublish?: boolean;
46
+ /** Disables Preview — same gating as canPublish, a draft must exist first. */
47
+ canPreview?: boolean;
48
+ saveDraftLabel?: string;
49
+ publishLabel?: string;
50
+ previewLabel?: string;
51
+ }
52
+ interface CollectionEditProps {
53
+ config: CollectionConfig;
54
+ initialValues?: Record<string, unknown>;
55
+ onSubmit: (values: Record<string, unknown>) => void | Promise<void>;
56
+ submitLabel?: string;
57
+ error?: string;
58
+ /** Disables the Save button and shows a spinner in its place. */
59
+ saving?: boolean;
60
+ /**
61
+ * Resolves an `upload` field's selected file to a stored URL. Required
62
+ * if the collection has any `upload` fields — `CollectionEdit` never
63
+ * talks to storage directly (stays agnostic of R2/cadmus/storage), so
64
+ * the consuming route wires this to a server function that calls an
65
+ * `ImageService`'s `upload()`.
66
+ */
67
+ onUploadFile?: (file: File) => Promise<{
68
+ url: string;
69
+ }>;
70
+ /**
71
+ * Options for `relationship` fields (hasMany:false only — see
72
+ * RelationshipFieldConfig's `hasMany` caveat), keyed by the field's
73
+ * `relationTo` collection slug. `CollectionEdit` can't query another
74
+ * collection itself, so the consuming route fetches the related rows
75
+ * and passes them in.
76
+ */
77
+ relationshipOptions?: Partial<Record<string, RelationshipOption[]>>;
78
+ /**
79
+ * Fired whenever the dirty (unsaved-changes) state changes — wire this
80
+ * to a router-level navigation guard (e.g. `useBlocker` in the
81
+ * consuming route) since `CollectionEdit` has no router access itself.
82
+ */
83
+ onDirtyChange?: (dirty: boolean) => void;
84
+ /** Only rendered when `config.versions?.drafts` is also true. */
85
+ draftActions?: DraftActions;
86
+ /**
87
+ * Hides the Save button when `canUpdate` is `false` — see issue #26's
88
+ * RBAC-aware admin UI. Undefined (the default — most collections don't
89
+ * wire this up) reads as "allowed", same as `@thebes/cadmus/cms`'s own
90
+ * "no access fn = allowed" default.
91
+ */
92
+ capabilities?: CollectionCapabilities;
93
+ }
94
+ declare function CollectionEdit(props: CollectionEditProps): import("solid-js").JSX.Element;
95
+ //#endregion
96
+ //#region src/CollectionList.d.ts
97
+ interface CollectionListProps {
98
+ config: CollectionConfig;
99
+ rows: Record<string, unknown>[];
100
+ onRowClick?: (row: Record<string, unknown>) => void;
101
+ /**
102
+ * 1-based current page. Omit (along with `pageSize`) to render without
103
+ * the pagination bar entirely — list views with no `find()` paging
104
+ * wired up yet still render correctly.
105
+ */
106
+ page?: number;
107
+ pageSize?: number;
108
+ /** Total row count across all pages — see `LocalApi.count()`. Enables
109
+ * disabling "Next" exactly at the last page; omit to fall back to a
110
+ * `rows.length < pageSize` heuristic. */
111
+ totalCount?: number;
112
+ onPageChange?: (page: number) => void;
113
+ /** Field key currently sorted on. Omit to hide the sort control. */
114
+ sortField?: string;
115
+ sortDirection?: "asc" | "desc";
116
+ onSortChange?: (field: string, direction: "asc" | "desc") => void;
117
+ /** Shows the "Select" bulk-select mode toggle. */
118
+ selectable?: boolean;
119
+ selectedIds?: ReadonlySet<number>;
120
+ onSelectionChange?: (selectedIds: Set<number>) => void;
121
+ }
122
+ declare function CollectionList(props: CollectionListProps): import("solid-js").JSX.Element;
123
+ //#endregion
124
+ //#region src/SearchPalette.d.ts
125
+ interface SearchPaletteResult {
126
+ collection: string;
127
+ id: number;
128
+ label: string;
129
+ }
130
+ interface SearchPaletteProps {
131
+ /** Runs a query against `LocalApi.search()` for every searchable collection — see `@thebes/cadmus/cms`'s `getCollectionsMeta`. */
132
+ onSearch: (query: string) => Promise<SearchPaletteResult[]>;
133
+ /** Navigates to the chosen result; the palette closes itself afterward. */
134
+ onSelect: (result: SearchPaletteResult) => void;
135
+ }
136
+ /**
137
+ * Self-contained Cmd+K (Ctrl+K on non-Mac) search palette — issue #29.
138
+ * Owns its own open/closed state and keyboard listener; the host app only
139
+ * supplies `onSearch` (wired to a server function that fans out across
140
+ * every collection with `search` configured) and `onSelect` (navigation).
141
+ * Mirrors PanelNav's focus-trap-on-open / restore-focus-on-close pattern
142
+ * rather than introducing a second one.
143
+ */
144
+ declare function SearchPalette(props: SearchPaletteProps): JSX.Element;
145
+ //#endregion
146
+ export { type CollectionCapabilities, CollectionEdit, type CollectionEditProps, CollectionList, type CollectionListProps, SearchPalette, type SearchPaletteProps, type SearchPaletteResult };
147
+ //# sourceMappingURL=index.d.ts.map