@pramen/cms-editor 0.0.59 → 0.0.61
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/README.md +8 -0
- package/dist/editor.css +1 -1
- package/dist/editor.js +166 -163
- package/package.json +1 -1
- package/src/api.ts +80 -2
- package/src/app-context.tsx +42 -3
- package/src/blockkit.tsx +410 -0
- package/src/buzola.gen.ts +117 -23
- package/src/chrome.ts +16 -0
- package/src/components.tsx +102 -10
- package/src/fields.tsx +257 -2
- package/src/furniture.tsx +924 -0
- package/src/nav.ts +134 -0
- package/src/routes/_layout.tsx +36 -54
- package/src/routes/admin-page.tsx +36 -0
- package/src/routes/block-type.tsx +29 -0
- package/src/routes/content-type.tsx +32 -0
- package/src/routes/menu.tsx +30 -0
- package/src/routes/menus.tsx +18 -0
- package/src/routes/page.tsx +1 -1
- package/src/routes/redirects.tsx +17 -0
- package/src/routes/schema.tsx +34 -0
- package/src/routes/taxonomies.tsx +18 -0
- package/src/routes/taxonomy.tsx +29 -0
- package/src/routes/widget-area.tsx +29 -0
- package/src/routes/widgets.tsx +18 -0
- package/src/schema-builder.tsx +944 -0
- package/src/types.ts +237 -1
package/src/buzola.gen.ts
CHANGED
|
@@ -6,40 +6,73 @@ import { buildRouteTree } from '@buzola/router';
|
|
|
6
6
|
import type { RouteConfig } from '@buzola/router';
|
|
7
7
|
|
|
8
8
|
import Route0 from './routes/_layout';
|
|
9
|
-
const Route1 = () => import('./routes/
|
|
10
|
-
const Route2 = () => import('./routes/
|
|
11
|
-
const Route3 = () => import('./routes/
|
|
12
|
-
const Route4 = () => import('./routes/
|
|
13
|
-
const Route5 = () => import('./routes/
|
|
14
|
-
const Route6 = () => import('./routes/
|
|
15
|
-
const Route7 = () => import('./routes/
|
|
16
|
-
const Route8 = () => import('./routes/
|
|
17
|
-
const Route9 = () => import('./routes/
|
|
9
|
+
const Route1 = () => import('./routes/admin-page').then(m => ({ default: m.default.component })) as Promise<{ default: ComponentType }>;
|
|
10
|
+
const Route2 = () => import('./routes/block-type').then(m => ({ default: m.default.component })) as Promise<{ default: ComponentType }>;
|
|
11
|
+
const Route3 = () => import('./routes/collection-item').then(m => ({ default: m.default.component })) as Promise<{ default: ComponentType }>;
|
|
12
|
+
const Route4 = () => import('./routes/collection').then(m => ({ default: m.default.component })) as Promise<{ default: ComponentType }>;
|
|
13
|
+
const Route5 = () => import('./routes/content-type').then(m => ({ default: m.default.component })) as Promise<{ default: ComponentType }>;
|
|
14
|
+
const Route6 = () => import('./routes/home').then(m => ({ default: m.default.component })) as Promise<{ default: ComponentType }>;
|
|
15
|
+
const Route7 = () => import('./routes/media').then(m => ({ default: m.default.component })) as Promise<{ default: ComponentType }>;
|
|
16
|
+
const Route8 = () => import('./routes/menu').then(m => ({ default: m.default.component })) as Promise<{ default: ComponentType }>;
|
|
17
|
+
const Route9 = () => import('./routes/menus').then(m => ({ default: m.default.component })) as Promise<{ default: ComponentType }>;
|
|
18
|
+
const Route10 = () => import('./routes/page').then(m => ({ default: m.default.component })) as Promise<{ default: ComponentType }>;
|
|
19
|
+
const Route11 = () => import('./routes/redirects').then(m => ({ default: m.default.component })) as Promise<{ default: ComponentType }>;
|
|
20
|
+
const Route12 = () => import('./routes/schema').then(m => ({ default: m.default.component })) as Promise<{ default: ComponentType }>;
|
|
21
|
+
const Route13 = () => import('./routes/settings').then(m => ({ default: m.default.component })) as Promise<{ default: ComponentType }>;
|
|
22
|
+
const Route14 = () => import('./routes/taxonomies').then(m => ({ default: m.default.component })) as Promise<{ default: ComponentType }>;
|
|
23
|
+
const Route15 = () => import('./routes/taxonomy').then(m => ({ default: m.default.component })) as Promise<{ default: ComponentType }>;
|
|
24
|
+
const Route16 = () => import('./routes/type').then(m => ({ default: m.default.component })) as Promise<{ default: ComponentType }>;
|
|
25
|
+
const Route17 = () => import('./routes/users').then(m => ({ default: m.default.component })) as Promise<{ default: ComponentType }>;
|
|
26
|
+
const Route18 = () => import('./routes/widget-area').then(m => ({ default: m.default.component })) as Promise<{ default: ComponentType }>;
|
|
27
|
+
const Route19 = () => import('./routes/widgets').then(m => ({ default: m.default.component })) as Promise<{ default: ComponentType }>;
|
|
28
|
+
const Route20 = () => import('./routes/_404').then(m => ({ default: m.default.component })) as Promise<{ default: ComponentType }>;
|
|
18
29
|
|
|
19
30
|
// Module augmentation for type-safe page-centric routing
|
|
20
31
|
declare module '@buzola/router' {
|
|
21
32
|
interface BuzolaPageMap {
|
|
33
|
+
'admin-page': { slug: string };
|
|
34
|
+
'block-type': { slug: string };
|
|
22
35
|
'collection-item': { slug: string; id: string };
|
|
23
36
|
'collection': { slug: string };
|
|
37
|
+
'content-type': { slug: string };
|
|
24
38
|
'home': {};
|
|
25
39
|
'media': {};
|
|
40
|
+
'menu': { name: string };
|
|
41
|
+
'menus': {};
|
|
26
42
|
'page': { pageId: string; tab?: string };
|
|
43
|
+
'redirects': {};
|
|
44
|
+
'schema': {};
|
|
27
45
|
'settings': {};
|
|
46
|
+
'taxonomies': {};
|
|
47
|
+
'taxonomy': { slug: string };
|
|
28
48
|
'type': { slug: string };
|
|
29
49
|
'users': {};
|
|
50
|
+
'widget-area': { name: string };
|
|
51
|
+
'widgets': {};
|
|
30
52
|
}
|
|
31
53
|
}
|
|
32
54
|
|
|
33
55
|
// Page registry — page ID → route pattern
|
|
34
56
|
export const pageRegistry: Record<string, string> = {
|
|
57
|
+
'admin-page': '/apps/:slug',
|
|
58
|
+
'block-type': '/schema/blocks/:slug',
|
|
35
59
|
'collection-item': '/collections/:slug/:id',
|
|
36
60
|
'collection': '/collections/:slug',
|
|
61
|
+
'content-type': '/schema/content/:slug',
|
|
37
62
|
'home': '/',
|
|
38
63
|
'media': '/media',
|
|
64
|
+
'menu': '/menus/:name',
|
|
65
|
+
'menus': '/menus',
|
|
39
66
|
'page': '/pages/:pageId',
|
|
67
|
+
'redirects': '/redirects',
|
|
68
|
+
'schema': '/schema',
|
|
40
69
|
'settings': '/settings',
|
|
70
|
+
'taxonomies': '/taxonomies',
|
|
71
|
+
'taxonomy': '/taxonomies/:slug',
|
|
41
72
|
'type': '/types/:slug',
|
|
42
73
|
'users': '/users',
|
|
74
|
+
'widget-area': '/widgets/:name',
|
|
75
|
+
'widgets': '/widgets',
|
|
43
76
|
};
|
|
44
77
|
|
|
45
78
|
const routeConfigs: RouteConfig[] = [
|
|
@@ -49,55 +82,116 @@ const routeConfigs: RouteConfig[] = [
|
|
|
49
82
|
isLayout: true,
|
|
50
83
|
children: [
|
|
51
84
|
{
|
|
52
|
-
path: '/
|
|
53
|
-
matchPath: '/
|
|
85
|
+
path: '/admin-page',
|
|
86
|
+
matchPath: '/apps/:slug',
|
|
54
87
|
component: lazy(Route1),
|
|
55
88
|
preload: Route1,
|
|
56
89
|
},
|
|
57
90
|
{
|
|
58
|
-
path: '/
|
|
59
|
-
matchPath: '/
|
|
91
|
+
path: '/block-type',
|
|
92
|
+
matchPath: '/schema/blocks/:slug',
|
|
60
93
|
component: lazy(Route2),
|
|
61
94
|
preload: Route2,
|
|
62
95
|
},
|
|
63
96
|
{
|
|
64
|
-
path: '/
|
|
65
|
-
matchPath: '/',
|
|
97
|
+
path: '/collection-item',
|
|
98
|
+
matchPath: '/collections/:slug/:id',
|
|
66
99
|
component: lazy(Route3),
|
|
67
100
|
preload: Route3,
|
|
68
101
|
},
|
|
69
102
|
{
|
|
70
|
-
path: '/
|
|
103
|
+
path: '/collection',
|
|
104
|
+
matchPath: '/collections/:slug',
|
|
71
105
|
component: lazy(Route4),
|
|
72
106
|
preload: Route4,
|
|
73
107
|
},
|
|
74
108
|
{
|
|
75
|
-
path: '/
|
|
76
|
-
matchPath: '/
|
|
109
|
+
path: '/content-type',
|
|
110
|
+
matchPath: '/schema/content/:slug',
|
|
77
111
|
component: lazy(Route5),
|
|
78
112
|
preload: Route5,
|
|
79
113
|
},
|
|
80
114
|
{
|
|
81
|
-
path: '/
|
|
115
|
+
path: '/home',
|
|
116
|
+
matchPath: '/',
|
|
82
117
|
component: lazy(Route6),
|
|
83
118
|
preload: Route6,
|
|
84
119
|
},
|
|
85
120
|
{
|
|
86
|
-
path: '/
|
|
87
|
-
matchPath: '/types/:slug',
|
|
121
|
+
path: '/media',
|
|
88
122
|
component: lazy(Route7),
|
|
89
123
|
preload: Route7,
|
|
90
124
|
},
|
|
91
125
|
{
|
|
92
|
-
path: '/
|
|
126
|
+
path: '/menu',
|
|
127
|
+
matchPath: '/menus/:name',
|
|
93
128
|
component: lazy(Route8),
|
|
94
129
|
preload: Route8,
|
|
95
130
|
},
|
|
96
131
|
{
|
|
97
|
-
path: '
|
|
132
|
+
path: '/menus',
|
|
98
133
|
component: lazy(Route9),
|
|
99
134
|
preload: Route9,
|
|
100
135
|
},
|
|
136
|
+
{
|
|
137
|
+
path: '/page',
|
|
138
|
+
matchPath: '/pages/:pageId',
|
|
139
|
+
component: lazy(Route10),
|
|
140
|
+
preload: Route10,
|
|
141
|
+
},
|
|
142
|
+
{
|
|
143
|
+
path: '/redirects',
|
|
144
|
+
component: lazy(Route11),
|
|
145
|
+
preload: Route11,
|
|
146
|
+
},
|
|
147
|
+
{
|
|
148
|
+
path: '/schema',
|
|
149
|
+
component: lazy(Route12),
|
|
150
|
+
preload: Route12,
|
|
151
|
+
},
|
|
152
|
+
{
|
|
153
|
+
path: '/settings',
|
|
154
|
+
component: lazy(Route13),
|
|
155
|
+
preload: Route13,
|
|
156
|
+
},
|
|
157
|
+
{
|
|
158
|
+
path: '/taxonomies',
|
|
159
|
+
component: lazy(Route14),
|
|
160
|
+
preload: Route14,
|
|
161
|
+
},
|
|
162
|
+
{
|
|
163
|
+
path: '/taxonomy',
|
|
164
|
+
matchPath: '/taxonomies/:slug',
|
|
165
|
+
component: lazy(Route15),
|
|
166
|
+
preload: Route15,
|
|
167
|
+
},
|
|
168
|
+
{
|
|
169
|
+
path: '/type',
|
|
170
|
+
matchPath: '/types/:slug',
|
|
171
|
+
component: lazy(Route16),
|
|
172
|
+
preload: Route16,
|
|
173
|
+
},
|
|
174
|
+
{
|
|
175
|
+
path: '/users',
|
|
176
|
+
component: lazy(Route17),
|
|
177
|
+
preload: Route17,
|
|
178
|
+
},
|
|
179
|
+
{
|
|
180
|
+
path: '/widget-area',
|
|
181
|
+
matchPath: '/widgets/:name',
|
|
182
|
+
component: lazy(Route18),
|
|
183
|
+
preload: Route18,
|
|
184
|
+
},
|
|
185
|
+
{
|
|
186
|
+
path: '/widgets',
|
|
187
|
+
component: lazy(Route19),
|
|
188
|
+
preload: Route19,
|
|
189
|
+
},
|
|
190
|
+
{
|
|
191
|
+
path: '/:__notFound+',
|
|
192
|
+
component: lazy(Route20),
|
|
193
|
+
preload: Route20,
|
|
194
|
+
},
|
|
101
195
|
],
|
|
102
196
|
},
|
|
103
197
|
];
|
package/src/chrome.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
// The layout primitives every screen shares.
|
|
2
|
+
//
|
|
3
|
+
// A LEAF module on purpose. These were copied into four files, and the obvious fix — put
|
|
4
|
+
// them in `components.tsx`, which already has them — is not available: `components.tsx`
|
|
5
|
+
// imports from `furniture.tsx` (for `flattenTerms`, used by the page editor's Terms panel),
|
|
6
|
+
// so `furniture.tsx` importing back would be a cycle. Constants and class strings have no
|
|
7
|
+
// dependencies of their own, so they belong below both.
|
|
8
|
+
//
|
|
9
|
+
// Class strings rather than components, because that is what the call sites want: most
|
|
10
|
+
// apply them to an element they are already styling for their own reasons.
|
|
11
|
+
|
|
12
|
+
/** The page gutter every full-width screen uses. */
|
|
13
|
+
export const WRAP = "mx-auto max-w-[1200px] px-7 pb-8 pt-2";
|
|
14
|
+
|
|
15
|
+
/** One row in a list — a card-surfaced strip with the standard inset. */
|
|
16
|
+
export const ROW = "flex items-center gap-3 rounded-[14px] border border-transparent bg-surface-card px-[18px] py-3.5";
|
package/src/components.tsx
CHANGED
|
@@ -6,19 +6,26 @@ import { Button, Heading, Input, ModalDialog, ModalOverlay, ModalSurface, Textar
|
|
|
6
6
|
import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
|
|
7
7
|
import { Api, ApiError } from "./api";
|
|
8
8
|
import { CONTROL, FieldForm, formatWhen, fromLocalInput, slugify, toLocalInput } from "./fields";
|
|
9
|
+
import { ROW, WRAP } from "./chrome";
|
|
9
10
|
import type { Config } from "./api";
|
|
10
11
|
import { useApp, type Me } from "./app-context";
|
|
11
12
|
import { isRichTextDoc, richTextToPlainText } from "./rich-text";
|
|
12
|
-
import
|
|
13
|
+
import { flattenTerms } from "./furniture";
|
|
14
|
+
import type { AssembledPage, AuditEntry, BlockType, CmsCapabilities, CollectionMeta, ContentType, FieldDefinition, FieldValue, FieldValues, Media, Page, RegionDefinition, RenderedBlock, Taxonomy, Term } from "./types";
|
|
13
15
|
|
|
14
|
-
export type InspectorTab = "settings" | "seo" | "workflow" | "i18n" | "audit";
|
|
15
|
-
export const INSPECTOR_TABS: InspectorTab[] = ["settings", "seo", "workflow", "i18n", "audit"];
|
|
16
|
+
export type InspectorTab = "settings" | "seo" | "workflow" | "i18n" | "terms" | "audit";
|
|
17
|
+
export const INSPECTOR_TABS: InspectorTab[] = ["settings", "seo", "workflow", "i18n", "terms", "audit"];
|
|
16
18
|
|
|
17
19
|
/** The tabs a deployment actually shows. ONE definition, used by the tab bar, the panel
|
|
18
20
|
* switch and the route's deep-link fallback — three places that previously each re-derived
|
|
19
|
-
* "is i18n visible?" and could disagree.
|
|
20
|
-
|
|
21
|
-
|
|
21
|
+
* "is i18n visible?" and could disagree.
|
|
22
|
+
*
|
|
23
|
+
* `terms` follows `siteFurniture` for the same reason `i18n` follows `multilingual`: on a
|
|
24
|
+
* server without the taxonomy handlers the panel could only ever render an error. With them
|
|
25
|
+
* and no vocabularies defined it renders its own empty state, which is the honest answer
|
|
26
|
+
* and points at the screen that fixes it. */
|
|
27
|
+
export function visibleTabs(multilingual: boolean, siteFurniture = false): InspectorTab[] {
|
|
28
|
+
return INSPECTOR_TABS.filter((t) => (t === "i18n" ? multilingual : t === "terms" ? siteFurniture : true));
|
|
22
29
|
}
|
|
23
30
|
|
|
24
31
|
/** Collections-only deployments hide the block/page builder entirely. Read in one place so
|
|
@@ -48,8 +55,6 @@ export function splitsByType(contentTypes: ContentType[] | null, cms: CmsCapabil
|
|
|
48
55
|
|
|
49
56
|
// --- presentational primitives (podoba tokens; replaces styles.ts classes) ---
|
|
50
57
|
|
|
51
|
-
const ROW = "flex items-center gap-3 rounded-[14px] border border-transparent bg-surface-card px-[18px] py-3.5";
|
|
52
|
-
const WRAP = "mx-auto max-w-[1200px] px-7 pb-8 pt-2";
|
|
53
58
|
|
|
54
59
|
function Hero({ lead, em, children }: { lead: string; em: string; children?: ReactNode }) {
|
|
55
60
|
return (
|
|
@@ -666,7 +671,7 @@ export function CollectionEditor({ api, def, id, onSaved, onDeleted, onBack, onE
|
|
|
666
671
|
// --- page editor -------------------------------------------------------------
|
|
667
672
|
|
|
668
673
|
export function PageEditor({ api, page, blockTypes, tab, onTab, onBack, onChange, registerGuard }: { api: Api; page: Page; blockTypes: BlockType[]; tab: InspectorTab; onTab: (t: InspectorTab) => void; onBack: () => void; onChange: (p: Page) => void; registerGuard: (fn: (() => boolean) | null) => void }) {
|
|
669
|
-
const { cms: { multilingual } } = useApp();
|
|
674
|
+
const { cms: { multilingual, siteFurniture, canEdit } } = useApp();
|
|
670
675
|
const [ct, setCt] = useState<ContentType | null>(null);
|
|
671
676
|
const [assembled, setAssembled] = useState<AssembledPage | null>(null);
|
|
672
677
|
const [err, setErr] = useState("");
|
|
@@ -905,7 +910,7 @@ export function PageEditor({ api, page, blockTypes, tab, onTab, onBack, onChange
|
|
|
905
910
|
|
|
906
911
|
<div className="overflow-auto rounded-panel border border-border bg-surface-card p-5">
|
|
907
912
|
<div className="mb-3 flex gap-1">
|
|
908
|
-
{visibleTabs(multilingual).map((t) => (
|
|
913
|
+
{visibleTabs(multilingual, siteFurniture).map((t) => (
|
|
909
914
|
<Button key={t} variant="ghost" size="sm" className={tab === t ? "bg-surface-muted text-fg" : "text-fg-muted"} onPress={() => onTab(t)}>{t}</Button>
|
|
910
915
|
))}
|
|
911
916
|
</div>
|
|
@@ -913,6 +918,7 @@ export function PageEditor({ api, page, blockTypes, tab, onTab, onBack, onChange
|
|
|
913
918
|
{tab === "seo" ? <SeoPanel api={api} page={page} onError={setErr} /> : null}
|
|
914
919
|
{tab === "workflow" ? <Workflow api={api} page={page} onChanged={(p) => { onChange(p); }} onError={setErr} /> : null}
|
|
915
920
|
{tab === "i18n" && multilingual ? <I18n api={api} page={page} onError={setErr} /> : null}
|
|
921
|
+
{tab === "terms" ? <PageTerms api={api} pageId={page.id} canEdit={canEdit} onError={setErr} /> : null}
|
|
916
922
|
{tab === "audit" ? <AuditLog api={api} pageId={page.id} onError={setErr} /> : null}
|
|
917
923
|
</div>
|
|
918
924
|
</div>
|
|
@@ -1401,6 +1407,92 @@ function I18n({ api, page, onError }: { api: Api; page: Page; onError: (s: strin
|
|
|
1401
1407
|
);
|
|
1402
1408
|
}
|
|
1403
1409
|
|
|
1410
|
+
/** The page's taxonomy terms.
|
|
1411
|
+
*
|
|
1412
|
+
* Assignment has to live in the page editor: `cms_page_terms` links a term to a PAGE, so
|
|
1413
|
+
* the vocabulary screen can define terms but has no way to attach one — a taxonomy with no
|
|
1414
|
+
* assignment surface is a list of words.
|
|
1415
|
+
*
|
|
1416
|
+
* `setPageTerms` replaces the whole selection rather than adding and removing one at a
|
|
1417
|
+
* time, so the panel holds the selection and saves it as a unit. Two calls each patching
|
|
1418
|
+
* one end of it race into a state neither asked for.
|
|
1419
|
+
*/
|
|
1420
|
+
function PageTerms({ api, pageId, canEdit, onError }: { api: Api; pageId: string; canEdit: boolean; onError: (s: string) => void }) {
|
|
1421
|
+
const [taxa, setTaxa] = useState<Taxonomy[] | null>(null);
|
|
1422
|
+
const [terms, setTerms] = useState<Record<string, Term[]>>({});
|
|
1423
|
+
const [selected, setSelected] = useState<Set<string>>(() => new Set());
|
|
1424
|
+
const [busy, setBusy] = useState(false);
|
|
1425
|
+
const [ok, setOk] = useState(false);
|
|
1426
|
+
|
|
1427
|
+
useEffect(() => {
|
|
1428
|
+
let live = true;
|
|
1429
|
+
(async () => {
|
|
1430
|
+
try {
|
|
1431
|
+
const list = await api.listTaxonomies();
|
|
1432
|
+
if (!live) return;
|
|
1433
|
+
setTaxa(list);
|
|
1434
|
+
// In parallel, and alongside the page's own assignments. Awaiting one vocabulary at
|
|
1435
|
+
// a time made the panel's open cost N+1 serial round trips for data that has no
|
|
1436
|
+
// ordering dependency between the calls.
|
|
1437
|
+
const [trees, assigned] = await Promise.all([
|
|
1438
|
+
Promise.all(list.map(async (t) => [t.slug, flattenTerms(await api.getTermTree(t.slug)).map((f) => f.term)] as const)),
|
|
1439
|
+
api.listPageTerms(pageId),
|
|
1440
|
+
]);
|
|
1441
|
+
if (!live) return;
|
|
1442
|
+
setTerms(Object.fromEntries(trees));
|
|
1443
|
+
setSelected(new Set(assigned.map((t) => t.id)));
|
|
1444
|
+
} catch (e) {
|
|
1445
|
+
if (live) onError(errMsg(e));
|
|
1446
|
+
}
|
|
1447
|
+
})();
|
|
1448
|
+
return () => { live = false; };
|
|
1449
|
+
}, [api, pageId, onError]);
|
|
1450
|
+
|
|
1451
|
+
const toggle = (id: string) => {
|
|
1452
|
+
setSelected((prev) => {
|
|
1453
|
+
const next = new Set(prev);
|
|
1454
|
+
if (next.has(id)) next.delete(id); else next.add(id);
|
|
1455
|
+
return next;
|
|
1456
|
+
});
|
|
1457
|
+
};
|
|
1458
|
+
|
|
1459
|
+
const save = async () => {
|
|
1460
|
+
setBusy(true);
|
|
1461
|
+
try {
|
|
1462
|
+
await api.setPageTerms(pageId, [...selected]);
|
|
1463
|
+
setOk(true);
|
|
1464
|
+
setTimeout(() => setOk(false), 1200);
|
|
1465
|
+
} catch (e) { onError(errMsg(e)); } finally { setBusy(false); }
|
|
1466
|
+
};
|
|
1467
|
+
|
|
1468
|
+
if (taxa === null) return <p className="text-fg-subtle">Loading…</p>;
|
|
1469
|
+
if (taxa.length === 0) return <p className="text-fg-subtle">No vocabularies defined yet.</p>;
|
|
1470
|
+
|
|
1471
|
+
return (
|
|
1472
|
+
<div className="flex flex-col gap-4">
|
|
1473
|
+
{ok ? <Banner ok>saved</Banner> : null}
|
|
1474
|
+
{taxa.map((t) => (
|
|
1475
|
+
<div key={t.id}>
|
|
1476
|
+
<Section>{t.label}</Section>
|
|
1477
|
+
<div className="flex flex-col gap-1">
|
|
1478
|
+
{(terms[t.slug] ?? []).length === 0 ? <span className="text-caption text-fg-subtle">No terms yet.</span> : null}
|
|
1479
|
+
{(terms[t.slug] ?? []).map((term) => (
|
|
1480
|
+
<label key={term.id} className="flex items-center gap-2">
|
|
1481
|
+
{/* `setPageTerms` is editor-gated, so a reviewer got live checkboxes and a
|
|
1482
|
+
Save button that 403s — the one new write surface that did not take
|
|
1483
|
+
`canEdit`. */}
|
|
1484
|
+
<input type="checkbox" disabled={!canEdit} checked={selected.has(term.id)} onChange={() => toggle(term.id)} />
|
|
1485
|
+
<span className="text-sm text-fg">{term.label}</span>
|
|
1486
|
+
</label>
|
|
1487
|
+
))}
|
|
1488
|
+
</div>
|
|
1489
|
+
</div>
|
|
1490
|
+
))}
|
|
1491
|
+
{canEdit ? <Button size="sm" className="self-start" onPress={save} isDisabled={busy}>{busy ? "Saving…" : "Save terms"}</Button> : null}
|
|
1492
|
+
</div>
|
|
1493
|
+
);
|
|
1494
|
+
}
|
|
1495
|
+
|
|
1404
1496
|
function AuditLog({ api, pageId, onError }: { api: Api; pageId: string; onError: (s: string) => void }) {
|
|
1405
1497
|
const [rows, setRows] = useState<AuditEntry[]>([]);
|
|
1406
1498
|
useEffect(() => {
|
package/src/fields.tsx
CHANGED
|
@@ -8,10 +8,10 @@ import Highlight from "@tiptap/extension-highlight";
|
|
|
8
8
|
import TaskItem from "@tiptap/extension-task-item";
|
|
9
9
|
import TaskList from "@tiptap/extension-task-list";
|
|
10
10
|
import StarterKit from "@tiptap/starter-kit";
|
|
11
|
-
import { useEffect, useRef, useState, type DragEvent, type ReactNode } from "react";
|
|
11
|
+
import { useCallback, useEffect, useRef, useState, type DragEvent, type ReactNode } from "react";
|
|
12
12
|
import type { Api } from "./api";
|
|
13
13
|
import { isRichTextDoc, richTextToPlainText } from "./rich-text";
|
|
14
|
-
import type { FieldDefinition, FieldValue, FieldValues, Media, RichTextDoc } from "./types";
|
|
14
|
+
import type { FieldDefinition, FieldValue, FieldValues, Media, ReferenceOption, ReferenceResult, RichTextDoc } from "./types";
|
|
15
15
|
|
|
16
16
|
// Tokenized bare control (podoba's filled-field skin) for the native inputs that
|
|
17
17
|
// don't map cleanly onto a podoba primitive (number/date/select/file).
|
|
@@ -260,6 +260,16 @@ function FieldInput({ def, value, onChange, api, hideLabelAs, siblings }: { def:
|
|
|
260
260
|
<MediaField value={value as string | null} onChange={onChange} api={api} />
|
|
261
261
|
</FieldShell>
|
|
262
262
|
);
|
|
263
|
+
case "reference":
|
|
264
|
+
// Deliberately NOT FieldShell: the control is a row of <button>s, and a <label>
|
|
265
|
+
// wrapper would forward a click on the label text to the first one (opening the
|
|
266
|
+
// picker). Same reason `publish` and `boolean` opt out.
|
|
267
|
+
return (
|
|
268
|
+
<div className="flex w-full flex-col gap-2">
|
|
269
|
+
{label === undefined ? null : <span className="text-sm font-medium text-fg">{label}</span>}
|
|
270
|
+
<ReferenceField def={def} value={value} onChange={onChange} api={api} ariaLabel={hideLabelAs} />
|
|
271
|
+
</div>
|
|
272
|
+
);
|
|
263
273
|
case "group":
|
|
264
274
|
return (
|
|
265
275
|
<FieldShell label={label}>
|
|
@@ -742,3 +752,248 @@ export function MediaPicker({ api, onClose, onPick }: { api: Api; onClose: () =>
|
|
|
742
752
|
</ModalOverlay>
|
|
743
753
|
);
|
|
744
754
|
}
|
|
755
|
+
|
|
756
|
+
// --- reference ------------------------------------------------------------------------
|
|
757
|
+
//
|
|
758
|
+
// A pointer to a record that is not this row. `select` + `optionsFrom` already does the
|
|
759
|
+
// easy half — fetch `{ value, label }[]` once, render a `<select>` — and stays the right
|
|
760
|
+
// answer for twenty campaigns. It has no answer for a thousand records: no search, no
|
|
761
|
+
// paging, and no way to render the label of a stored value that is not in the one page it
|
|
762
|
+
// fetched, so an existing row shows a uuid.
|
|
763
|
+
//
|
|
764
|
+
// So this control asks the server two different questions, and the second is the one that
|
|
765
|
+
// makes it work on an existing row:
|
|
766
|
+
//
|
|
767
|
+
// { search, limit, offset } -> browse / search (the picker)
|
|
768
|
+
// { ids: [...] } -> resolve labels (what the closed control renders)
|
|
769
|
+
//
|
|
770
|
+
// The stored value stays an opaque id, so the same field points at a pramen row or at a
|
|
771
|
+
// record in a system we do not own.
|
|
772
|
+
|
|
773
|
+
const REFERENCE_PAGE_SIZE = 20;
|
|
774
|
+
|
|
775
|
+
/** Tolerate a handler that answers with a bare array — the `optionsFrom` shape — so a
|
|
776
|
+
* `select`'s existing options handler can be pointed at a `reference` without a rewrite. */
|
|
777
|
+
function asReferenceResult(raw: unknown): ReferenceResult {
|
|
778
|
+
if (Array.isArray(raw)) return { items: raw as ReferenceOption[] };
|
|
779
|
+
const r = raw as ReferenceResult | null;
|
|
780
|
+
return { items: Array.isArray(r?.items) ? r.items : [], hasMore: r?.hasMore };
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
/** Resolve labels for ids that are already stored.
|
|
784
|
+
*
|
|
785
|
+
* Only ever asks for ids it does not already have a label for, and keeps what it has
|
|
786
|
+
* across renders: a repeater of twenty references would otherwise re-resolve every one of
|
|
787
|
+
* them on every keystroke in a sibling field. */
|
|
788
|
+
function useReferenceLabels(api: Api, from: string | undefined, ids: readonly string[]): Map<string, ReferenceOption> {
|
|
789
|
+
const [known, setKnown] = useState<Map<string, ReferenceOption>>(() => new Map());
|
|
790
|
+
// The ids, as a stable primitive — an array literal is a new identity every render.
|
|
791
|
+
//
|
|
792
|
+
// JSON, not `join(" ")`: an id here is OPAQUE, which is the whole premise of the field, so
|
|
793
|
+
// it may contain a space. Splitting on one turned a single external id into two ids that
|
|
794
|
+
// resolve to nothing, and the control showed "Loading..." forever.
|
|
795
|
+
const key = JSON.stringify(ids);
|
|
796
|
+
useEffect(() => {
|
|
797
|
+
if (!from) return;
|
|
798
|
+
const wanted = JSON.parse(key) as string[];
|
|
799
|
+
const missing = wanted.filter((id) => !known.has(id));
|
|
800
|
+
if (missing.length === 0) return;
|
|
801
|
+
let alive = true;
|
|
802
|
+
api
|
|
803
|
+
.call<ReferenceResult>(from, { ids: missing })
|
|
804
|
+
.then((r) => {
|
|
805
|
+
if (!alive) return;
|
|
806
|
+
const items = asReferenceResult(r).items;
|
|
807
|
+
setKnown((prev) => {
|
|
808
|
+
const next = new Map(prev);
|
|
809
|
+
for (const it of items) next.set(it.value, it);
|
|
810
|
+
// An id the handler did not answer for is recorded as UNRESOLVED rather than left
|
|
811
|
+
// missing — otherwise this effect asks for it again on every render, forever, for
|
|
812
|
+
// a record that has been deleted.
|
|
813
|
+
for (const id of missing) if (!next.has(id)) next.set(id, { value: id, label: "" });
|
|
814
|
+
return next;
|
|
815
|
+
});
|
|
816
|
+
})
|
|
817
|
+
.catch(() => {
|
|
818
|
+
if (!alive) return;
|
|
819
|
+
setKnown((prev) => {
|
|
820
|
+
const next = new Map(prev);
|
|
821
|
+
for (const id of missing) if (!next.has(id)) next.set(id, { value: id, label: "" });
|
|
822
|
+
return next;
|
|
823
|
+
});
|
|
824
|
+
});
|
|
825
|
+
return () => { alive = false; };
|
|
826
|
+
}, [api, from, key, known]);
|
|
827
|
+
return known;
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
/** What a stored id renders as while it is being resolved, and after it could not be. */
|
|
831
|
+
function referenceLabel(id: string, known: Map<string, ReferenceOption>): { label: string; muted: boolean } {
|
|
832
|
+
const hit = known.get(id);
|
|
833
|
+
if (!hit) return { label: "Loading...", muted: true };
|
|
834
|
+
// An empty label is the recorded "no such record" — show the raw id, because that is the
|
|
835
|
+
// only thing left that identifies what the row points at.
|
|
836
|
+
return hit.label ? { label: hit.label, muted: false } : { label: id, muted: true };
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
function ReferenceField({ def, value, onChange, api, ariaLabel }: { def: FieldDefinition; value: FieldValue; onChange: (v: FieldValue) => void; api: Api; ariaLabel?: string }) {
|
|
840
|
+
const multiple = def.multiple === true;
|
|
841
|
+
// `unknown[]` before filtering: `FieldValue` is a union that INCLUDES array members, so
|
|
842
|
+
// `Array.isArray` narrows to a union of array types and the type-guard overload of
|
|
843
|
+
// `.filter` no longer resolves. The guard below is the real narrowing either way.
|
|
844
|
+
const ids: string[] = multiple
|
|
845
|
+
? (Array.isArray(value) ? (value as unknown[]).filter((v): v is string => typeof v === "string") : [])
|
|
846
|
+
: typeof value === "string" && value !== "" ? [value] : [];
|
|
847
|
+
const known = useReferenceLabels(api, def.referenceFrom, ids);
|
|
848
|
+
const [picking, setPicking] = useState(false);
|
|
849
|
+
|
|
850
|
+
const pick = (opt: ReferenceOption) => {
|
|
851
|
+
if (multiple) {
|
|
852
|
+
if (!ids.includes(opt.value)) onChange([...ids, opt.value]);
|
|
853
|
+
} else {
|
|
854
|
+
onChange(opt.value);
|
|
855
|
+
setPicking(false);
|
|
856
|
+
}
|
|
857
|
+
};
|
|
858
|
+
const remove = (id: string) => onChange(multiple ? ids.filter((v) => v !== id) : null);
|
|
859
|
+
|
|
860
|
+
// Nothing to pick FROM. Said out loud rather than rendered as an empty control that
|
|
861
|
+
// silently does nothing when clicked — this is a schema mistake, and the person seeing it
|
|
862
|
+
// is the one who can fix it.
|
|
863
|
+
if (!def.referenceFrom) {
|
|
864
|
+
return <span className="text-sm text-danger">This reference field declares no `referenceFrom` handler.</span>;
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
return (
|
|
868
|
+
<div className="flex flex-col gap-2">
|
|
869
|
+
{ids.length === 0 ? (
|
|
870
|
+
<span className="text-sm text-fg-muted">Nothing selected.</span>
|
|
871
|
+
) : (
|
|
872
|
+
<div className="flex flex-col gap-1">
|
|
873
|
+
{ids.map((id) => {
|
|
874
|
+
const { label, muted } = referenceLabel(id, known);
|
|
875
|
+
const hint = known.get(id)?.hint;
|
|
876
|
+
return (
|
|
877
|
+
<div key={id} className="flex items-center gap-2 rounded-[14px] bg-surface-card px-4 py-2.5">
|
|
878
|
+
<span className={`min-w-0 flex-1 truncate text-sm ${muted ? "text-fg-subtle" : "text-fg"}`}>{label}</span>
|
|
879
|
+
{hint ? <span className="shrink-0 text-caption text-fg-subtle">{hint}</span> : null}
|
|
880
|
+
<Button variant="ghost" size="sm" className="text-danger" onPress={() => remove(id)}>remove</Button>
|
|
881
|
+
</div>
|
|
882
|
+
);
|
|
883
|
+
})}
|
|
884
|
+
</div>
|
|
885
|
+
)}
|
|
886
|
+
<Button variant="secondary" size="sm" className="self-start" aria-label={ariaLabel} onPress={() => setPicking(true)}>
|
|
887
|
+
{multiple ? "+ Add" : ids.length > 0 ? "Change" : "Choose"}
|
|
888
|
+
</Button>
|
|
889
|
+
{picking ? (
|
|
890
|
+
<ReferencePicker
|
|
891
|
+
api={api}
|
|
892
|
+
from={def.referenceFrom}
|
|
893
|
+
title={def.label ?? def.name}
|
|
894
|
+
selected={ids}
|
|
895
|
+
multiple={multiple}
|
|
896
|
+
onPick={pick}
|
|
897
|
+
onClose={() => setPicking(false)}
|
|
898
|
+
/>
|
|
899
|
+
) : null}
|
|
900
|
+
</div>
|
|
901
|
+
);
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
function ReferencePicker({ api, from, title, selected, multiple, onPick, onClose }: {
|
|
905
|
+
api: Api;
|
|
906
|
+
from: string;
|
|
907
|
+
title: string;
|
|
908
|
+
selected: readonly string[];
|
|
909
|
+
multiple: boolean;
|
|
910
|
+
onPick: (opt: ReferenceOption) => void;
|
|
911
|
+
onClose: () => void;
|
|
912
|
+
}) {
|
|
913
|
+
const [search, setSearch] = useState("");
|
|
914
|
+
const [items, setItems] = useState<ReferenceOption[]>([]);
|
|
915
|
+
const [hasMore, setHasMore] = useState(false);
|
|
916
|
+
const [loading, setLoading] = useState(true);
|
|
917
|
+
const [err, setErr] = useState("");
|
|
918
|
+
// Debounced: the fetch keys on `term`, so typing does not fire a request per character.
|
|
919
|
+
const [term, setTerm] = useState("");
|
|
920
|
+
|
|
921
|
+
useEffect(() => {
|
|
922
|
+
const id = setTimeout(() => setTerm(search.trim()), 250);
|
|
923
|
+
return () => clearTimeout(id);
|
|
924
|
+
}, [search]);
|
|
925
|
+
|
|
926
|
+
const load = useCallback(
|
|
927
|
+
(offset: number) => {
|
|
928
|
+
setLoading(true);
|
|
929
|
+
setErr("");
|
|
930
|
+
return api
|
|
931
|
+
.call<ReferenceResult>(from, { search: term || undefined, limit: REFERENCE_PAGE_SIZE, offset })
|
|
932
|
+
.then((raw) => {
|
|
933
|
+
const r = asReferenceResult(raw);
|
|
934
|
+
setItems((prev) => (offset === 0 ? r.items : [...prev, ...r.items]));
|
|
935
|
+
// A handler that omits `hasMore` is taken at its word only when it returned a
|
|
936
|
+
// SHORT page; a full one is assumed to have more — the same rule the page and
|
|
937
|
+
// collection lists use.
|
|
938
|
+
setHasMore(r.hasMore ?? r.items.length === REFERENCE_PAGE_SIZE);
|
|
939
|
+
})
|
|
940
|
+
.catch((e: unknown) => setErr(String((e as Error)?.message ?? e)))
|
|
941
|
+
.finally(() => setLoading(false));
|
|
942
|
+
},
|
|
943
|
+
[api, from, term],
|
|
944
|
+
);
|
|
945
|
+
|
|
946
|
+
useEffect(() => { void load(0); }, [load]);
|
|
947
|
+
|
|
948
|
+
return (
|
|
949
|
+
<ModalOverlay isOpen isDismissable onOpenChange={(open) => !open && onClose()}>
|
|
950
|
+
<ModalSurface className="w-full max-w-[560px] px-9 py-8">
|
|
951
|
+
<ModalDialog className="max-h-[86vh] overflow-auto outline-none">
|
|
952
|
+
<Heading level="1" className="mb-5 font-normal">
|
|
953
|
+
Choose <span className="text-fg-subtle">{title.toLowerCase()}</span>
|
|
954
|
+
</Heading>
|
|
955
|
+
<input
|
|
956
|
+
className={`${CONTROL} mb-3`}
|
|
957
|
+
type="search"
|
|
958
|
+
autoFocus
|
|
959
|
+
placeholder="Search"
|
|
960
|
+
aria-label="Search"
|
|
961
|
+
value={search}
|
|
962
|
+
onChange={(e) => setSearch(e.target.value)}
|
|
963
|
+
/>
|
|
964
|
+
{err ? <div className="mb-2 rounded-lg border border-danger bg-surface-card px-3.5 py-2.5 text-small text-danger">{err}</div> : null}
|
|
965
|
+
<div className="flex flex-col gap-1">
|
|
966
|
+
{items.map((opt) => {
|
|
967
|
+
const already = selected.includes(opt.value);
|
|
968
|
+
return (
|
|
969
|
+
<button
|
|
970
|
+
key={opt.value}
|
|
971
|
+
type="button"
|
|
972
|
+
disabled={already}
|
|
973
|
+
onClick={() => onPick(opt)}
|
|
974
|
+
className="flex items-center gap-3 rounded-lg px-3 py-2 text-left transition-colors hover:bg-surface-muted disabled:opacity-40"
|
|
975
|
+
>
|
|
976
|
+
<span className="min-w-0 flex-1">
|
|
977
|
+
<span className="block truncate text-sm text-fg">{opt.label}</span>
|
|
978
|
+
{opt.hint ? <span className="block truncate text-caption text-fg-subtle">{opt.hint}</span> : null}
|
|
979
|
+
</span>
|
|
980
|
+
{already ? <span className="shrink-0 text-caption text-fg-subtle">selected</span> : null}
|
|
981
|
+
</button>
|
|
982
|
+
);
|
|
983
|
+
})}
|
|
984
|
+
{!loading && items.length === 0 ? <p className="px-3 py-2 text-sm text-fg-subtle">Nothing matches.</p> : null}
|
|
985
|
+
{loading ? <p className="px-3 py-2 text-sm text-fg-subtle">Loading...</p> : null}
|
|
986
|
+
</div>
|
|
987
|
+
{hasMore && !loading ? (
|
|
988
|
+
<div className="mt-3 text-center">
|
|
989
|
+
<Button variant="secondary" size="sm" onPress={() => void load(items.length)}>Load more</Button>
|
|
990
|
+
</div>
|
|
991
|
+
) : null}
|
|
992
|
+
<div className="mt-3 text-right">
|
|
993
|
+
<Button variant="ghost" onPress={onClose}>{multiple ? "done" : "close"}</Button>
|
|
994
|
+
</div>
|
|
995
|
+
</ModalDialog>
|
|
996
|
+
</ModalSurface>
|
|
997
|
+
</ModalOverlay>
|
|
998
|
+
);
|
|
999
|
+
}
|