@saena-io/content 0.1.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/drizzle/0000_tranquil_golden_guardian.sql +16 -0
- package/drizzle/0001_little_darwin.sql +12 -0
- package/drizzle/meta/0000_snapshot.json +94 -0
- package/drizzle/meta/0001_snapshot.json +197 -0
- package/drizzle/meta/_journal.json +20 -0
- package/package.json +41 -0
- package/src/admin/client.ts +43 -0
- package/src/admin/collection-manager.tsx +328 -0
- package/src/admin/editors.tsx +284 -0
- package/src/admin/field-registry.tsx +33 -0
- package/src/admin/form-engine.tsx +394 -0
- package/src/admin/icons.tsx +45 -0
- package/src/admin/index.tsx +343 -0
- package/src/contract.ts +57 -0
- package/src/db/schema.ts +70 -0
- package/src/define.ts +92 -0
- package/src/field/builtins.ts +524 -0
- package/src/field/field.test.ts +292 -0
- package/src/field/index.ts +49 -0
- package/src/field/infer.ts +10 -0
- package/src/field/keys.ts +69 -0
- package/src/field/registry.ts +32 -0
- package/src/field/types.ts +83 -0
- package/src/index.test.ts +23 -0
- package/src/index.ts +108 -0
- package/src/public/index.tsx +133 -0
- package/src/service.ts +375 -0
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
import { Page } from '@saena-io/ui/admin';
|
|
2
|
+
import { Button } from '@saena-io/ui/components/button';
|
|
3
|
+
import { Input } from '@saena-io/ui/components/input';
|
|
4
|
+
import { RichTextDndProvider } from '@saena-io/ui/components/rich-text/rich-text-editor';
|
|
5
|
+
import { useEffect, useId, useRef, useState } from 'react';
|
|
6
|
+
import { type CollectionDef, getCollection } from '../define';
|
|
7
|
+
import { fieldEmpty, slugify, softSlugify } from '../field';
|
|
8
|
+
import { type CollectionItemDto, contentClient } from './client';
|
|
9
|
+
import './editors'; // registers the built-in field-type editors (shared with the section form)
|
|
10
|
+
import { FieldView } from './form-engine';
|
|
11
|
+
import { GripVertical } from './icons';
|
|
12
|
+
|
|
13
|
+
// @saena-io/content/admin — the collection MANAGER (ADR-0011): one screen per routed collection. Lists its entries
|
|
14
|
+
// (drag-to-reorder, edit, delete) and edits one entry at a time with the same generated form the sections use.
|
|
15
|
+
// Structure is code (the schema from ./define); this only manages the editable entries over the content API.
|
|
16
|
+
|
|
17
|
+
/** A human label for an entry row — the first stringy title-ish field of the hydrated value, else the slug. */
|
|
18
|
+
function entryTitle(value: unknown, slug: string): string {
|
|
19
|
+
if (value && typeof value === 'object') {
|
|
20
|
+
for (const k of ['title', 'name', 'label', 'heading']) {
|
|
21
|
+
const v = (value as Record<string, unknown>)[k];
|
|
22
|
+
if (typeof v === 'string' && v.trim()) return v;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
return slug || '(untitled)';
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** The manager for one collection: the entry list, or the single-entry editor when creating/editing. */
|
|
29
|
+
function CollectionScreen({ collectionId }: { collectionId: string }) {
|
|
30
|
+
const def = getCollection(collectionId);
|
|
31
|
+
const [items, setItems] = useState<CollectionItemDto[] | null>(null);
|
|
32
|
+
// null = list view; { id } edits an entry; {} creates a new one.
|
|
33
|
+
const [editing, setEditing] = useState<{ id?: string } | null>(null);
|
|
34
|
+
const [error, setError] = useState<string | null>(null);
|
|
35
|
+
|
|
36
|
+
const refresh = () => {
|
|
37
|
+
setError(null);
|
|
38
|
+
contentClient.collection
|
|
39
|
+
.list(collectionId)
|
|
40
|
+
.then(setItems)
|
|
41
|
+
.catch((e) => setError(String(e)));
|
|
42
|
+
};
|
|
43
|
+
// biome-ignore lint/correctness/useExhaustiveDependencies: reload the list when the collection changes.
|
|
44
|
+
useEffect(refresh, [collectionId]);
|
|
45
|
+
|
|
46
|
+
if (!def) {
|
|
47
|
+
return (
|
|
48
|
+
<Page title="Collections" className="p-6">
|
|
49
|
+
<p className="text-destructive text-sm">Unknown collection: {collectionId}</p>
|
|
50
|
+
</Page>
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if (editing) {
|
|
55
|
+
return (
|
|
56
|
+
<CollectionItemEditor
|
|
57
|
+
def={def}
|
|
58
|
+
id={editing.id}
|
|
59
|
+
onDone={() => {
|
|
60
|
+
setEditing(null);
|
|
61
|
+
refresh();
|
|
62
|
+
}}
|
|
63
|
+
onCancel={() => setEditing(null)}
|
|
64
|
+
/>
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return (
|
|
69
|
+
<Page title={def.label} className="gap-4 p-6">
|
|
70
|
+
<div className="flex items-center justify-between">
|
|
71
|
+
<p className="text-muted-foreground text-sm">
|
|
72
|
+
{items ? `${items.length} ${items.length === 1 ? 'entry' : 'entries'}` : 'Loading…'}
|
|
73
|
+
</p>
|
|
74
|
+
<Button size="sm" onClick={() => setEditing({})}>
|
|
75
|
+
+ New entry
|
|
76
|
+
</Button>
|
|
77
|
+
</div>
|
|
78
|
+
{error ? <p className="text-destructive text-sm">{error}</p> : null}
|
|
79
|
+
{items ? (
|
|
80
|
+
<CollectionList
|
|
81
|
+
collectionId={collectionId}
|
|
82
|
+
items={items}
|
|
83
|
+
onChange={setItems}
|
|
84
|
+
onEdit={(id) => setEditing({ id })}
|
|
85
|
+
onError={setError}
|
|
86
|
+
/>
|
|
87
|
+
) : null}
|
|
88
|
+
</Page>
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** The reorderable entry list. Drag a row by its grip to reorder (persisted via `reorder`); per-row Edit +
|
|
93
|
+
* Delete (with an inline confirm). Reorder is optimistic — the new order is sent, the list reverts on error. */
|
|
94
|
+
function CollectionList({
|
|
95
|
+
collectionId,
|
|
96
|
+
items,
|
|
97
|
+
onChange,
|
|
98
|
+
onEdit,
|
|
99
|
+
onError,
|
|
100
|
+
}: {
|
|
101
|
+
collectionId: string;
|
|
102
|
+
items: CollectionItemDto[];
|
|
103
|
+
onChange: (items: CollectionItemDto[]) => void;
|
|
104
|
+
onEdit: (id: string) => void;
|
|
105
|
+
onError: (msg: string) => void;
|
|
106
|
+
}) {
|
|
107
|
+
const dragId = useRef<string | null>(null);
|
|
108
|
+
const [confirming, setConfirming] = useState<string | null>(null);
|
|
109
|
+
|
|
110
|
+
if (items.length === 0) {
|
|
111
|
+
return (
|
|
112
|
+
<p className="rounded-md border border-dashed p-6 text-center text-muted-foreground text-sm">
|
|
113
|
+
No entries yet. Use “New entry” to add one.
|
|
114
|
+
</p>
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const move = (fromId: string, toId: string) => {
|
|
119
|
+
if (fromId === toId) return;
|
|
120
|
+
const from = items.findIndex((i) => i.id === fromId);
|
|
121
|
+
const to = items.findIndex((i) => i.id === toId);
|
|
122
|
+
if (from < 0 || to < 0) return;
|
|
123
|
+
const next = [...items];
|
|
124
|
+
const [moved] = next.splice(from, 1);
|
|
125
|
+
if (!moved) return;
|
|
126
|
+
next.splice(to, 0, moved);
|
|
127
|
+
const prev = items;
|
|
128
|
+
onChange(next); // optimistic
|
|
129
|
+
contentClient.collection
|
|
130
|
+
.reorder(
|
|
131
|
+
collectionId,
|
|
132
|
+
next.map((i) => i.id),
|
|
133
|
+
)
|
|
134
|
+
.catch((e) => {
|
|
135
|
+
onChange(prev); // revert
|
|
136
|
+
onError(String(e));
|
|
137
|
+
});
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
const remove = (id: string) => {
|
|
141
|
+
const prev = items;
|
|
142
|
+
onChange(items.filter((i) => i.id !== id));
|
|
143
|
+
setConfirming(null);
|
|
144
|
+
contentClient.collection.delete(collectionId, id).catch((e) => {
|
|
145
|
+
onChange(prev);
|
|
146
|
+
onError(String(e));
|
|
147
|
+
});
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
return (
|
|
151
|
+
<ul className="flex flex-col gap-1.5">
|
|
152
|
+
{items.map((item) => (
|
|
153
|
+
<li
|
|
154
|
+
key={item.id}
|
|
155
|
+
data-collection-row
|
|
156
|
+
onDragOver={(e) => {
|
|
157
|
+
if (dragId.current) e.preventDefault();
|
|
158
|
+
}}
|
|
159
|
+
onDrop={(e) => {
|
|
160
|
+
e.preventDefault();
|
|
161
|
+
if (dragId.current) move(dragId.current, item.id);
|
|
162
|
+
dragId.current = null;
|
|
163
|
+
}}
|
|
164
|
+
className="flex items-center gap-3 rounded-md border border-input bg-input/20 px-3 py-2 dark:bg-input/30"
|
|
165
|
+
>
|
|
166
|
+
<button
|
|
167
|
+
type="button"
|
|
168
|
+
aria-label="Drag to reorder"
|
|
169
|
+
draggable
|
|
170
|
+
onDragStart={() => {
|
|
171
|
+
dragId.current = item.id;
|
|
172
|
+
}}
|
|
173
|
+
onDragEnd={() => {
|
|
174
|
+
dragId.current = null;
|
|
175
|
+
}}
|
|
176
|
+
className="cursor-grab text-muted-foreground hover:text-foreground active:cursor-grabbing"
|
|
177
|
+
>
|
|
178
|
+
<GripVertical />
|
|
179
|
+
</button>
|
|
180
|
+
<div className="flex min-w-0 flex-1 flex-col">
|
|
181
|
+
<span className="truncate font-medium text-sm">
|
|
182
|
+
{entryTitle(item.value, item.slug)}
|
|
183
|
+
</span>
|
|
184
|
+
<span className="truncate font-mono text-muted-foreground text-xs">/{item.slug}</span>
|
|
185
|
+
</div>
|
|
186
|
+
{confirming === item.id ? (
|
|
187
|
+
<>
|
|
188
|
+
<span className="text-muted-foreground text-xs">Delete?</span>
|
|
189
|
+
<Button type="button" size="sm" variant="destructive" onClick={() => remove(item.id)}>
|
|
190
|
+
Confirm
|
|
191
|
+
</Button>
|
|
192
|
+
<Button type="button" size="sm" variant="ghost" onClick={() => setConfirming(null)}>
|
|
193
|
+
Cancel
|
|
194
|
+
</Button>
|
|
195
|
+
</>
|
|
196
|
+
) : (
|
|
197
|
+
<>
|
|
198
|
+
<Button type="button" size="sm" variant="outline" onClick={() => onEdit(item.id)}>
|
|
199
|
+
Edit
|
|
200
|
+
</Button>
|
|
201
|
+
<Button
|
|
202
|
+
type="button"
|
|
203
|
+
size="sm"
|
|
204
|
+
variant="ghost"
|
|
205
|
+
onClick={() => setConfirming(item.id)}
|
|
206
|
+
>
|
|
207
|
+
Delete
|
|
208
|
+
</Button>
|
|
209
|
+
</>
|
|
210
|
+
)}
|
|
211
|
+
</li>
|
|
212
|
+
))}
|
|
213
|
+
</ul>
|
|
214
|
+
);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** Create or edit one entry: a slug input (auto-derived from the configured field while untouched on create) +
|
|
218
|
+
* the generated form for the collection's schema. */
|
|
219
|
+
function CollectionItemEditor({
|
|
220
|
+
def,
|
|
221
|
+
id,
|
|
222
|
+
onDone,
|
|
223
|
+
onCancel,
|
|
224
|
+
}: {
|
|
225
|
+
def: CollectionDef;
|
|
226
|
+
id?: string;
|
|
227
|
+
onDone: () => void;
|
|
228
|
+
onCancel: () => void;
|
|
229
|
+
}) {
|
|
230
|
+
const isNew = id === undefined;
|
|
231
|
+
const [value, setValue] = useState<unknown>(() => (isNew ? fieldEmpty(def.schema) : undefined));
|
|
232
|
+
const [slug, setSlug] = useState('');
|
|
233
|
+
const [slugTouched, setSlugTouched] = useState(!isNew);
|
|
234
|
+
const [saving, setSaving] = useState(false);
|
|
235
|
+
const [error, setError] = useState<string | null>(null);
|
|
236
|
+
|
|
237
|
+
// Load an existing entry's editor value (storage shape, source text filled).
|
|
238
|
+
// biome-ignore lint/correctness/useExhaustiveDependencies: load once per entry id.
|
|
239
|
+
useEffect(() => {
|
|
240
|
+
if (isNew) return;
|
|
241
|
+
let live = true;
|
|
242
|
+
contentClient.collection
|
|
243
|
+
.getForEdit(def.id, id)
|
|
244
|
+
.then((item) => {
|
|
245
|
+
if (!live || !item) return;
|
|
246
|
+
setValue(item.value);
|
|
247
|
+
setSlug(item.slug);
|
|
248
|
+
})
|
|
249
|
+
.catch((e) => live && setError(String(e)));
|
|
250
|
+
return () => {
|
|
251
|
+
live = false;
|
|
252
|
+
};
|
|
253
|
+
}, [def.id, id]);
|
|
254
|
+
|
|
255
|
+
// Auto-derive the slug from the configured source field until the user edits it (create only).
|
|
256
|
+
const fromField = def.slug?.from;
|
|
257
|
+
const source =
|
|
258
|
+
fromField && value && typeof value === 'object'
|
|
259
|
+
? (value as Record<string, unknown>)[fromField]
|
|
260
|
+
: undefined;
|
|
261
|
+
useEffect(() => {
|
|
262
|
+
if (!slugTouched && typeof source === 'string') setSlug(slugify(source));
|
|
263
|
+
}, [source, slugTouched]);
|
|
264
|
+
const slugId = useId();
|
|
265
|
+
|
|
266
|
+
const save = async () => {
|
|
267
|
+
setSaving(true);
|
|
268
|
+
setError(null);
|
|
269
|
+
try {
|
|
270
|
+
await contentClient.collection.save({ collection: def.id, id, slug, value });
|
|
271
|
+
onDone();
|
|
272
|
+
} catch (e) {
|
|
273
|
+
setError(String(e));
|
|
274
|
+
setSaving(false);
|
|
275
|
+
}
|
|
276
|
+
};
|
|
277
|
+
|
|
278
|
+
return (
|
|
279
|
+
<Page title={`${isNew ? 'New' : 'Edit'} ${def.label} entry`} className="min-h-0">
|
|
280
|
+
<div className="flex min-h-0 flex-1 flex-col overflow-y-auto">
|
|
281
|
+
<div className="flex flex-col gap-4 px-6 py-4">
|
|
282
|
+
<div className="flex max-w-md flex-col gap-1.5 text-sm">
|
|
283
|
+
<label htmlFor={slugId} className="font-medium text-foreground">
|
|
284
|
+
Slug (URL)
|
|
285
|
+
</label>
|
|
286
|
+
<Input
|
|
287
|
+
id={slugId}
|
|
288
|
+
value={slug}
|
|
289
|
+
placeholder="entry-slug"
|
|
290
|
+
onChange={(e) => {
|
|
291
|
+
setSlugTouched(true);
|
|
292
|
+
setSlug(softSlugify(e.target.value));
|
|
293
|
+
}}
|
|
294
|
+
onBlur={() => setSlug((s) => slugify(s))}
|
|
295
|
+
/>
|
|
296
|
+
</div>
|
|
297
|
+
{value === undefined ? (
|
|
298
|
+
<p className="text-muted-foreground text-sm">Loading…</p>
|
|
299
|
+
) : (
|
|
300
|
+
<RichTextDndProvider>
|
|
301
|
+
<FieldView
|
|
302
|
+
field={def.schema}
|
|
303
|
+
value={value}
|
|
304
|
+
onChange={setValue}
|
|
305
|
+
path={[]}
|
|
306
|
+
locale="source"
|
|
307
|
+
/>
|
|
308
|
+
</RichTextDndProvider>
|
|
309
|
+
)}
|
|
310
|
+
</div>
|
|
311
|
+
</div>
|
|
312
|
+
<footer className="flex items-center justify-end gap-2 border-t px-6 py-3">
|
|
313
|
+
{error ? <span className="mr-auto text-destructive text-xs">{error}</span> : null}
|
|
314
|
+
<Button variant="outline" size="lg" onClick={onCancel} disabled={saving}>
|
|
315
|
+
Cancel
|
|
316
|
+
</Button>
|
|
317
|
+
<Button size="lg" onClick={save} disabled={saving || value === undefined}>
|
|
318
|
+
{saving ? 'Saving…' : 'Save'}
|
|
319
|
+
</Button>
|
|
320
|
+
</footer>
|
|
321
|
+
</Page>
|
|
322
|
+
);
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/** The screen component a collection route mounts (used by buildContentAdmin). */
|
|
326
|
+
export function CollectionManager({ collectionId }: { collectionId: string }) {
|
|
327
|
+
return <CollectionScreen collectionId={collectionId} />;
|
|
328
|
+
}
|
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
import { AssetInput } from '@saena-io/ui/components/asset-input';
|
|
2
|
+
import { Button } from '@saena-io/ui/components/button';
|
|
3
|
+
import { FocalPointPicker } from '@saena-io/ui/components/focal-point-picker';
|
|
4
|
+
import { Input } from '@saena-io/ui/components/input';
|
|
5
|
+
import { NativeSelect, NativeSelectOption } from '@saena-io/ui/components/native-select';
|
|
6
|
+
import {
|
|
7
|
+
Plate,
|
|
8
|
+
PlateController,
|
|
9
|
+
RichTextContent,
|
|
10
|
+
RichTextToolbar,
|
|
11
|
+
createAiCommandExtension,
|
|
12
|
+
createAiCopilotExtension,
|
|
13
|
+
parseRichText,
|
|
14
|
+
serializeRichText,
|
|
15
|
+
useRichTextEditor,
|
|
16
|
+
} from '@saena-io/ui/components/rich-text/rich-text-editor';
|
|
17
|
+
import {
|
|
18
|
+
Sheet,
|
|
19
|
+
SheetContent,
|
|
20
|
+
SheetDescription,
|
|
21
|
+
SheetFooter,
|
|
22
|
+
SheetHeader,
|
|
23
|
+
SheetTitle,
|
|
24
|
+
} from '@saena-io/ui/components/sheet';
|
|
25
|
+
import { useEffect, useId, useMemo, useRef, useState } from 'react';
|
|
26
|
+
import { type ImageValue, type LinkValue, slugify, softSlugify } from '../field';
|
|
27
|
+
import { type AdminEditorProps, registerEditor } from './field-registry';
|
|
28
|
+
|
|
29
|
+
// The built-in field-type editors. Each is the React half of a built-in (../field/builtins.ts) and registers
|
|
30
|
+
// by the same id. Composite types (object/list/union) are rendered structurally by the form engine, not here.
|
|
31
|
+
|
|
32
|
+
function TextEditor({ value, onChange, field }: AdminEditorProps<string>) {
|
|
33
|
+
return (
|
|
34
|
+
<Input
|
|
35
|
+
value={value ?? ''}
|
|
36
|
+
placeholder={field.label}
|
|
37
|
+
onChange={(e) => onChange(e.target.value)}
|
|
38
|
+
/>
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function SlugEditor({ value, onChange, field, siblings }: AdminEditorProps<string>) {
|
|
43
|
+
const from = (field.cfg as { from?: string } | undefined)?.from;
|
|
44
|
+
const source = from && siblings ? siblings[from] : undefined;
|
|
45
|
+
// Auto-derive from the source field until the user edits the slug. touched starts true if a slug already
|
|
46
|
+
// exists (never clobber a saved slug). Sync only when the source actually CHANGES (the ref makes this
|
|
47
|
+
// mount-safe + StrictMode-safe — opening a form never mutates/dirties the slug).
|
|
48
|
+
const [touched, setTouched] = useState(() => !!value);
|
|
49
|
+
const prevSource = useRef(source);
|
|
50
|
+
// biome-ignore lint/correctness/useExhaustiveDependencies: syncs on source change; value/onChange are stable.
|
|
51
|
+
useEffect(() => {
|
|
52
|
+
if (prevSource.current === source) return;
|
|
53
|
+
prevSource.current = source;
|
|
54
|
+
if (!touched && typeof source === 'string') onChange(slugify(source));
|
|
55
|
+
}, [source, touched]);
|
|
56
|
+
return (
|
|
57
|
+
<Input
|
|
58
|
+
value={value ?? ''}
|
|
59
|
+
placeholder={field.label}
|
|
60
|
+
onChange={(e) => {
|
|
61
|
+
setTouched(true);
|
|
62
|
+
onChange(softSlugify(e.target.value)); // soft while typing so word boundaries stay typeable
|
|
63
|
+
}}
|
|
64
|
+
onBlur={() => {
|
|
65
|
+
const norm = slugify(value ?? '');
|
|
66
|
+
if (norm !== (value ?? '')) onChange(norm); // tidy on blur, only if it actually changes
|
|
67
|
+
}}
|
|
68
|
+
/>
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function RichtextEditor({ value, onChange, path }: AdminEditorProps<string>) {
|
|
73
|
+
// Initialise the Plate editor once from the loaded value; it owns its state thereafter and reports changes
|
|
74
|
+
// via onValueChange. The single RichTextDndProvider is mounted at the form root (see ./index.tsx).
|
|
75
|
+
// biome-ignore lint/correctness/useExhaustiveDependencies: initial value captured once by design.
|
|
76
|
+
const initial = useMemo(() => parseRichText(value, { plainTextFallback: true }), []);
|
|
77
|
+
// The AI copilot (ADR-0010): ghost-text autocomplete wired to the shared 'ai' capability via the plugin
|
|
78
|
+
// dispatch. Degrades to no suggestion when no provider key is configured.
|
|
79
|
+
const aiCopilot = useMemo(() => createAiCopilotExtension(), []);
|
|
80
|
+
// The AI command menu (ADR-0010): space / mod+J / toolbar button → streaming generate + edit via the
|
|
81
|
+
// /api/ai/command route (the shared 'ai' capability, 'command' feature model).
|
|
82
|
+
const aiCommand = useMemo(() => createAiCommandExtension(), []);
|
|
83
|
+
const editor = useRichTextEditor({
|
|
84
|
+
id: `rt-${path.join('.')}`,
|
|
85
|
+
value: initial,
|
|
86
|
+
extensions: [aiCopilot, aiCommand],
|
|
87
|
+
});
|
|
88
|
+
// One bordered shell (toolbar + content) styled like the other inputs (Input: border-input + bg-input/20),
|
|
89
|
+
// with a focus ring driven by :focus-within so editing the contenteditable lights the whole field. The
|
|
90
|
+
// toolbar's own border-b is the internal divider; the content surface is borderless.
|
|
91
|
+
return (
|
|
92
|
+
<PlateController>
|
|
93
|
+
<div className="rounded-md border border-input bg-background transition-[color,box-shadow] focus-within:border-ring focus-within:ring-2 focus-within:ring-ring/30">
|
|
94
|
+
<RichTextToolbar
|
|
95
|
+
extensions={[aiCommand]}
|
|
96
|
+
className="rounded-t-[5px] border-input bg-input/20 dark:bg-input/30"
|
|
97
|
+
/>
|
|
98
|
+
<Plate editor={editor} onValueChange={({ value: v }) => onChange(serializeRichText(v))}>
|
|
99
|
+
<RichTextContent placeholder="Write…" className="min-h-40 px-3 py-2" />
|
|
100
|
+
</Plate>
|
|
101
|
+
</div>
|
|
102
|
+
</PlateController>
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function NumberEditor({ value, onChange }: AdminEditorProps<number>) {
|
|
107
|
+
return (
|
|
108
|
+
<Input
|
|
109
|
+
type="number"
|
|
110
|
+
value={Number.isFinite(value) ? value : 0}
|
|
111
|
+
onChange={(e) => onChange(e.target.value === '' ? 0 : Number(e.target.value))}
|
|
112
|
+
/>
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function BooleanEditor({ value, onChange, field }: AdminEditorProps<boolean>) {
|
|
117
|
+
return (
|
|
118
|
+
<label className="flex items-center gap-2 text-sm">
|
|
119
|
+
<input type="checkbox" checked={!!value} onChange={(e) => onChange(e.target.checked)} />
|
|
120
|
+
{field.label ? <span className="text-muted-foreground">{field.label}</span> : null}
|
|
121
|
+
</label>
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function DateEditor({ value, onChange }: AdminEditorProps<string>) {
|
|
126
|
+
return <Input type="date" value={value ?? ''} onChange={(e) => onChange(e.target.value)} />;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function SelectEditor({ value, onChange, field }: AdminEditorProps<string>) {
|
|
130
|
+
const options = ((field.cfg as { options?: string[] } | undefined)?.options ?? []) as string[];
|
|
131
|
+
return (
|
|
132
|
+
<NativeSelect value={value ?? ''} onChange={(e) => onChange(e.target.value)}>
|
|
133
|
+
{options.map((o) => (
|
|
134
|
+
<NativeSelectOption key={o} value={o}>
|
|
135
|
+
{o}
|
|
136
|
+
</NativeSelectOption>
|
|
137
|
+
))}
|
|
138
|
+
</NativeSelect>
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function LinkEditor({ value, onChange, field }: AdminEditorProps<LinkValue>) {
|
|
143
|
+
const v = value ?? { href: '', label: '' };
|
|
144
|
+
return (
|
|
145
|
+
<div className="flex flex-wrap gap-2">
|
|
146
|
+
<Input
|
|
147
|
+
className="flex-1"
|
|
148
|
+
value={v.label}
|
|
149
|
+
placeholder={field.label ? `${field.label} label` : 'Link label'}
|
|
150
|
+
onChange={(e) => onChange({ ...v, label: e.target.value })}
|
|
151
|
+
/>
|
|
152
|
+
<Input
|
|
153
|
+
className="flex-1"
|
|
154
|
+
value={v.href}
|
|
155
|
+
placeholder="https://…"
|
|
156
|
+
onChange={(e) => onChange({ ...v, href: e.target.value })}
|
|
157
|
+
/>
|
|
158
|
+
</div>
|
|
159
|
+
);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// The image field editor (ADR-0009): the inline control stays compact (thumbnail + Edit/Replace/Remove via
|
|
163
|
+
// AssetInput); "Edit" opens a bottom Sheet — the same surface the translations editor uses — holding the large
|
|
164
|
+
// focal-point picker + alt text, so framing/alt get full-width space without leaving the page.
|
|
165
|
+
function ImageEditor({ value, onChange, field, inList }: AdminEditorProps<ImageValue>) {
|
|
166
|
+
const v = value ?? { ref: null, alt: '' };
|
|
167
|
+
const guideRatio = (field.cfg as { ratio?: number } | undefined)?.ratio;
|
|
168
|
+
const [open, setOpen] = useState(false);
|
|
169
|
+
return (
|
|
170
|
+
<>
|
|
171
|
+
{/* In a list, the row's ✕ removes the item, so suppress AssetInput's own Remove (no duplicate delete). */}
|
|
172
|
+
<AssetInput
|
|
173
|
+
value={v.ref}
|
|
174
|
+
showRemove={!inList}
|
|
175
|
+
onEdit={v.ref ? () => setOpen(true) : undefined}
|
|
176
|
+
onChange={(ref) => onChange({ ...v, ref })}
|
|
177
|
+
/>
|
|
178
|
+
{v.ref ? (
|
|
179
|
+
<ImageEditSheet
|
|
180
|
+
open={open}
|
|
181
|
+
onOpenChange={setOpen}
|
|
182
|
+
value={v}
|
|
183
|
+
onChange={onChange}
|
|
184
|
+
guideRatio={guideRatio}
|
|
185
|
+
/>
|
|
186
|
+
) : null}
|
|
187
|
+
</>
|
|
188
|
+
);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function ImageEditSheet({
|
|
192
|
+
open,
|
|
193
|
+
onOpenChange,
|
|
194
|
+
value,
|
|
195
|
+
onChange,
|
|
196
|
+
guideRatio,
|
|
197
|
+
}: {
|
|
198
|
+
open: boolean;
|
|
199
|
+
onOpenChange: (open: boolean) => void;
|
|
200
|
+
value: ImageValue;
|
|
201
|
+
onChange: (next: ImageValue) => void;
|
|
202
|
+
guideRatio?: number;
|
|
203
|
+
}) {
|
|
204
|
+
const { ref, alt, hotspot } = value;
|
|
205
|
+
const altId = useId();
|
|
206
|
+
return (
|
|
207
|
+
<Sheet open={open} onOpenChange={onOpenChange}>
|
|
208
|
+
<SheetContent
|
|
209
|
+
side="bottom"
|
|
210
|
+
showCloseButton={false}
|
|
211
|
+
className="flex max-h-[88svh] flex-col gap-0 p-0"
|
|
212
|
+
>
|
|
213
|
+
<SheetHeader className="flex-row items-center justify-between gap-2 space-y-0 border-b px-4 py-2.5">
|
|
214
|
+
<SheetTitle className="text-xs">Edit image</SheetTitle>
|
|
215
|
+
<SheetDescription className="sr-only">
|
|
216
|
+
Set the focal point and alt text. The focal point stays in frame when the image is
|
|
217
|
+
cropped to a layout's aspect ratio.
|
|
218
|
+
</SheetDescription>
|
|
219
|
+
</SheetHeader>
|
|
220
|
+
<div className="flex flex-1 flex-col gap-6 overflow-auto p-4 md:flex-row md:gap-8">
|
|
221
|
+
<div className="flex min-w-0 flex-1 flex-col gap-2">
|
|
222
|
+
<span className="font-medium text-foreground text-xs">Focal point</span>
|
|
223
|
+
<span className="text-muted-foreground text-xs">
|
|
224
|
+
Drag the dot to the part that must stay in frame when the image is cropped (e.g. a
|
|
225
|
+
face). Defaults to the centre.
|
|
226
|
+
</span>
|
|
227
|
+
{ref ? (
|
|
228
|
+
<FocalPointPicker
|
|
229
|
+
src={ref.url}
|
|
230
|
+
alt={alt}
|
|
231
|
+
value={hotspot}
|
|
232
|
+
onChange={(h) => onChange({ ...value, hotspot: h })}
|
|
233
|
+
guideRatio={guideRatio}
|
|
234
|
+
className="mt-1"
|
|
235
|
+
/>
|
|
236
|
+
) : null}
|
|
237
|
+
</div>
|
|
238
|
+
<div className="flex w-full flex-col gap-4 md:max-w-xs">
|
|
239
|
+
<div className="flex flex-col gap-1.5 text-xs">
|
|
240
|
+
<label htmlFor={altId} className="font-medium text-foreground">
|
|
241
|
+
Alt text
|
|
242
|
+
</label>
|
|
243
|
+
<Input
|
|
244
|
+
id={altId}
|
|
245
|
+
value={alt}
|
|
246
|
+
placeholder="Describe the image"
|
|
247
|
+
onChange={(e) => onChange({ ...value, alt: e.target.value })}
|
|
248
|
+
/>
|
|
249
|
+
<span className="text-muted-foreground">
|
|
250
|
+
Shown to screen readers and when the image fails to load.
|
|
251
|
+
</span>
|
|
252
|
+
</div>
|
|
253
|
+
{hotspot ? (
|
|
254
|
+
<Button
|
|
255
|
+
type="button"
|
|
256
|
+
variant="outline"
|
|
257
|
+
size="sm"
|
|
258
|
+
className="self-start"
|
|
259
|
+
onClick={() => onChange({ ...value, hotspot: undefined })}
|
|
260
|
+
>
|
|
261
|
+
Reset focal point to centre
|
|
262
|
+
</Button>
|
|
263
|
+
) : null}
|
|
264
|
+
</div>
|
|
265
|
+
</div>
|
|
266
|
+
<SheetFooter className="flex-row justify-end gap-2 border-t p-4">
|
|
267
|
+
<Button type="button" size="sm" onClick={() => onOpenChange(false)}>
|
|
268
|
+
Done
|
|
269
|
+
</Button>
|
|
270
|
+
</SheetFooter>
|
|
271
|
+
</SheetContent>
|
|
272
|
+
</Sheet>
|
|
273
|
+
);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
registerEditor('text', TextEditor);
|
|
277
|
+
registerEditor('richtext', RichtextEditor);
|
|
278
|
+
registerEditor('number', NumberEditor);
|
|
279
|
+
registerEditor('boolean', BooleanEditor);
|
|
280
|
+
registerEditor('date', DateEditor);
|
|
281
|
+
registerEditor('select', SelectEditor);
|
|
282
|
+
registerEditor('slug', SlugEditor);
|
|
283
|
+
registerEditor('link', LinkEditor);
|
|
284
|
+
registerEditor('image', ImageEditor);
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { ReactNode } from 'react';
|
|
2
|
+
import type { FieldDecl, FieldPath } from '../field';
|
|
3
|
+
|
|
4
|
+
// The React half of the field-type registry: a field-type id → its admin editor. Built-in editors register on
|
|
5
|
+
// import (./editors); custom field types register their editor the same way. The form engine renders
|
|
6
|
+
// `getEditor(id)` for every leaf field — so generated (built-in) and custom inputs are one mechanism.
|
|
7
|
+
|
|
8
|
+
export interface AdminEditorProps<S = unknown> {
|
|
9
|
+
value: S;
|
|
10
|
+
onChange: (next: S) => void;
|
|
11
|
+
/** Editing locale (the main/source locale in v1). */
|
|
12
|
+
locale: string;
|
|
13
|
+
/** Structural path (for stable keys + unique editor ids). */
|
|
14
|
+
path: FieldPath;
|
|
15
|
+
field: FieldDecl;
|
|
16
|
+
/** The parent object's value — read sibling fields here (e.g. a slug deriving from a sibling `title`). */
|
|
17
|
+
siblings?: Record<string, unknown>;
|
|
18
|
+
/** True when this editor is a list item — the list row owns remove/reorder, so suppress an editor's own
|
|
19
|
+
* removal affordance (e.g. the image editor hides its Remove to avoid a duplicate delete). */
|
|
20
|
+
inList?: boolean;
|
|
21
|
+
}
|
|
22
|
+
export type AdminEditor<S = unknown> = (props: AdminEditorProps<S>) => ReactNode;
|
|
23
|
+
|
|
24
|
+
const editors = new Map<string, AdminEditor>();
|
|
25
|
+
|
|
26
|
+
/** Register the admin editor for a field-type id (built-in or custom). */
|
|
27
|
+
export function registerEditor<S>(id: string, editor: AdminEditor<S>): void {
|
|
28
|
+
editors.set(id, editor as AdminEditor);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function getEditor(id: string): AdminEditor | undefined {
|
|
32
|
+
return editors.get(id);
|
|
33
|
+
}
|