@kidecms/core 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.
Files changed (93) hide show
  1. package/README.md +28 -0
  2. package/admin/components/AdminCard.astro +25 -0
  3. package/admin/components/AiGenerateButton.tsx +102 -0
  4. package/admin/components/AssetsGrid.tsx +711 -0
  5. package/admin/components/BlockEditor.tsx +996 -0
  6. package/admin/components/CheckboxField.tsx +31 -0
  7. package/admin/components/DocumentActions.tsx +317 -0
  8. package/admin/components/DocumentLock.tsx +54 -0
  9. package/admin/components/DocumentsDataTable.tsx +804 -0
  10. package/admin/components/FieldControl.astro +397 -0
  11. package/admin/components/FocalPointSelector.tsx +100 -0
  12. package/admin/components/ImageBrowseDialog.tsx +176 -0
  13. package/admin/components/ImagePicker.tsx +149 -0
  14. package/admin/components/InternalLinkPicker.tsx +80 -0
  15. package/admin/components/LiveHeading.tsx +17 -0
  16. package/admin/components/MobileSidebar.tsx +29 -0
  17. package/admin/components/RelationField.tsx +204 -0
  18. package/admin/components/RichTextEditor.tsx +685 -0
  19. package/admin/components/SelectField.tsx +65 -0
  20. package/admin/components/SidebarUserMenu.tsx +99 -0
  21. package/admin/components/SlugField.tsx +77 -0
  22. package/admin/components/TaxonomySelect.tsx +52 -0
  23. package/admin/components/Toast.astro +40 -0
  24. package/admin/components/TreeItemsEditor.tsx +790 -0
  25. package/admin/components/TreeSelect.tsx +166 -0
  26. package/admin/components/UnsavedGuard.tsx +181 -0
  27. package/admin/components/tree-utils.ts +86 -0
  28. package/admin/components/ui/alert-dialog.tsx +92 -0
  29. package/admin/components/ui/badge.tsx +83 -0
  30. package/admin/components/ui/button.tsx +53 -0
  31. package/admin/components/ui/card.tsx +70 -0
  32. package/admin/components/ui/checkbox.tsx +28 -0
  33. package/admin/components/ui/collapsible.tsx +26 -0
  34. package/admin/components/ui/command.tsx +88 -0
  35. package/admin/components/ui/dialog.tsx +92 -0
  36. package/admin/components/ui/dropdown-menu.tsx +259 -0
  37. package/admin/components/ui/input.tsx +20 -0
  38. package/admin/components/ui/label.tsx +20 -0
  39. package/admin/components/ui/popover.tsx +42 -0
  40. package/admin/components/ui/select.tsx +165 -0
  41. package/admin/components/ui/separator.tsx +21 -0
  42. package/admin/components/ui/sheet.tsx +104 -0
  43. package/admin/components/ui/skeleton.tsx +7 -0
  44. package/admin/components/ui/table.tsx +74 -0
  45. package/admin/components/ui/textarea.tsx +18 -0
  46. package/admin/components/ui/tooltip.tsx +52 -0
  47. package/admin/layouts/AdminLayout.astro +340 -0
  48. package/admin/lib/utils.ts +19 -0
  49. package/dist/admin.js +92 -0
  50. package/dist/ai.js +67 -0
  51. package/dist/api.js +827 -0
  52. package/dist/assets.js +163 -0
  53. package/dist/auth.js +132 -0
  54. package/dist/blocks.js +110 -0
  55. package/dist/content.js +29 -0
  56. package/dist/create-admin.js +23 -0
  57. package/dist/define.js +36 -0
  58. package/dist/generator.js +370 -0
  59. package/dist/image.js +69 -0
  60. package/dist/index.js +16 -0
  61. package/dist/integration.js +256 -0
  62. package/dist/locks.js +37 -0
  63. package/dist/richtext.js +1 -0
  64. package/dist/runtime.js +26 -0
  65. package/dist/schema.js +13 -0
  66. package/dist/seed.js +84 -0
  67. package/dist/values.js +102 -0
  68. package/middleware/auth.ts +100 -0
  69. package/package.json +102 -0
  70. package/routes/api/cms/[collection]/[...path].ts +366 -0
  71. package/routes/api/cms/ai/alt-text.ts +25 -0
  72. package/routes/api/cms/ai/seo.ts +25 -0
  73. package/routes/api/cms/ai/translate.ts +31 -0
  74. package/routes/api/cms/assets/[id].ts +82 -0
  75. package/routes/api/cms/assets/folders.ts +81 -0
  76. package/routes/api/cms/assets/index.ts +23 -0
  77. package/routes/api/cms/assets/upload.ts +112 -0
  78. package/routes/api/cms/auth/invite.ts +166 -0
  79. package/routes/api/cms/auth/login.ts +124 -0
  80. package/routes/api/cms/auth/logout.ts +33 -0
  81. package/routes/api/cms/auth/setup.ts +77 -0
  82. package/routes/api/cms/cron/publish.ts +33 -0
  83. package/routes/api/cms/img/[...path].ts +24 -0
  84. package/routes/api/cms/locks/[...path].ts +37 -0
  85. package/routes/api/cms/preview/render.ts +36 -0
  86. package/routes/api/cms/references/[collection]/[id].ts +60 -0
  87. package/routes/pages/admin/[...path].astro +1104 -0
  88. package/routes/pages/admin/assets/[id].astro +183 -0
  89. package/routes/pages/admin/assets/index.astro +58 -0
  90. package/routes/pages/admin/invite.astro +116 -0
  91. package/routes/pages/admin/login.astro +57 -0
  92. package/routes/pages/admin/setup.astro +91 -0
  93. package/virtual.d.ts +61 -0
@@ -0,0 +1,996 @@
1
+ "use client";
2
+
3
+ import { useState, useCallback, useEffect, useMemo, useRef } from "react";
4
+ import { GripVertical, ChevronRight, Plus, Trash2 } from "lucide-react";
5
+ import { cn } from "../lib/utils";
6
+ import {
7
+ DndContext,
8
+ closestCenter,
9
+ KeyboardSensor,
10
+ PointerSensor,
11
+ useSensor,
12
+ useSensors,
13
+ type DragEndEvent,
14
+ } from "@dnd-kit/core";
15
+ import { SortableContext, useSortable, verticalListSortingStrategy, arrayMove } from "@dnd-kit/sortable";
16
+ import { CSS } from "@dnd-kit/utilities";
17
+ import { Button } from "./ui/button";
18
+ import { Input } from "./ui/input";
19
+ import { Label } from "./ui/label";
20
+ import { Textarea } from "./ui/textarea";
21
+ import RichTextEditor from "./RichTextEditor";
22
+ import ImagePicker from "./ImagePicker";
23
+ import SelectField from "./SelectField";
24
+
25
+ type SubFieldMeta = {
26
+ type: string;
27
+ label?: string;
28
+ required?: boolean;
29
+ options?: string[];
30
+ from?: string;
31
+ admin?: { placeholder?: string; rows?: number; help?: string; component?: string };
32
+ defaultValue?: unknown;
33
+ of?: { type: string };
34
+ collection?: string;
35
+ hasMany?: boolean;
36
+ };
37
+
38
+ type BlockTypesMeta = Record<string, Record<string, SubFieldMeta>>;
39
+
40
+ type Block = {
41
+ _key: string;
42
+ type: string;
43
+ [field: string]: unknown;
44
+ };
45
+
46
+ type RelationOption = { value: string; label: string };
47
+
48
+ type Props = {
49
+ name: string;
50
+ value?: string;
51
+ types: BlockTypesMeta;
52
+ blockRelationOptions?: Record<string, RelationOption[]>;
53
+ };
54
+
55
+ function generateKey() {
56
+ return "blk_" + Math.random().toString(36).slice(2, 9);
57
+ }
58
+
59
+ function humanize(value: string) {
60
+ return value
61
+ .replace(/^_+/, "")
62
+ .replace(/([a-z0-9])([A-Z])/g, "$1 $2")
63
+ .replace(/[-_]/g, " ")
64
+ .replace(/\s+/g, " ")
65
+ .trim()
66
+ .replace(/^\w/, (char) => char.toUpperCase());
67
+ }
68
+
69
+ function getPreviewText(block: Block, fieldsMeta: Record<string, SubFieldMeta>): string {
70
+ for (const [key, meta] of Object.entries(fieldsMeta)) {
71
+ if (meta.type === "text" && block[key]) {
72
+ const text = String(block[key]);
73
+ return text.length > 60 ? text.slice(0, 60) + "..." : text;
74
+ }
75
+ }
76
+ return "";
77
+ }
78
+
79
+ function parseBlocks(value: string | undefined, types: BlockTypesMeta): Block[] {
80
+ if (!value) return [];
81
+ try {
82
+ const parsed = JSON.parse(value);
83
+ if (!Array.isArray(parsed)) return [];
84
+ return parsed.map((item: Record<string, unknown>) => ({
85
+ ...item,
86
+ _key: generateKey(),
87
+ type: String(item.type ?? Object.keys(types)[0] ?? "unknown"),
88
+ }));
89
+ } catch {
90
+ return [];
91
+ }
92
+ }
93
+
94
+ function serializeBlocks(blocks: Block[]): string {
95
+ return JSON.stringify(blocks.map(({ _key, ...rest }) => rest));
96
+ }
97
+
98
+ // -----------------------------------------------
99
+ // Sortable block card
100
+ // -----------------------------------------------
101
+
102
+ function SortableBlock({
103
+ block,
104
+ fieldsMeta,
105
+ isExpanded,
106
+ autoFocus,
107
+ onAutoFocused,
108
+ onToggle,
109
+ onRemove,
110
+ onUpdateField,
111
+ getRelationOptions,
112
+ }: {
113
+ block: Block;
114
+ fieldsMeta: Record<string, SubFieldMeta>;
115
+ isExpanded: boolean;
116
+ autoFocus?: boolean;
117
+ onAutoFocused?: () => void;
118
+ onToggle: () => void;
119
+ onRemove: () => void;
120
+ onUpdateField: (fieldName: string, value: unknown) => void;
121
+ getRelationOptions: (blockType: string, fieldName: string) => RelationOption[];
122
+ }) {
123
+ const contentRef = useRef<HTMLDivElement>(null);
124
+ const { attributes, listeners, setNodeRef, setActivatorNodeRef, transform, transition, isDragging } = useSortable({
125
+ id: block._key,
126
+ });
127
+
128
+ useEffect(() => {
129
+ if (autoFocus && isExpanded && contentRef.current) {
130
+ const input = contentRef.current.querySelector<HTMLElement>("input, textarea, [contenteditable]");
131
+ if (input) {
132
+ input.focus();
133
+ onAutoFocused?.();
134
+ }
135
+ }
136
+ }, [autoFocus, isExpanded, onAutoFocused]);
137
+
138
+ const style = {
139
+ transform: CSS.Transform.toString(transform),
140
+ transition,
141
+ };
142
+
143
+ const preview = getPreviewText(block, fieldsMeta);
144
+
145
+ return (
146
+ <div
147
+ ref={setNodeRef}
148
+ style={style}
149
+ className={cn("overflow-hidden rounded-lg border", isDragging && "z-10 opacity-90 shadow-lg")}
150
+ >
151
+ {/* Header — entire row is clickable to expand/collapse */}
152
+ <div
153
+ className="group/row hover:bg-muted/80 flex items-center gap-2 px-3 py-2 transition-colors select-none"
154
+ onClick={onToggle}
155
+ >
156
+ {/* Drag handle */}
157
+ <button
158
+ type="button"
159
+ ref={setActivatorNodeRef}
160
+ className="text-muted-foreground/50 hover:text-muted-foreground -ml-1 cursor-grab touch-none rounded p-1 transition-colors active:cursor-grabbing"
161
+ {...attributes}
162
+ {...listeners}
163
+ onClick={(e) => e.stopPropagation()}
164
+ >
165
+ <GripVertical className="size-4" />
166
+ </button>
167
+
168
+ <ChevronRight
169
+ className={cn(
170
+ "text-muted-foreground group-hover/row:text-foreground/70 size-4 shrink-0 transition-[color,transform]",
171
+ isExpanded && "rotate-90",
172
+ )}
173
+ />
174
+
175
+ <span className="bg-secondary text-secondary-foreground rounded px-2 py-0.5 text-xs font-medium">
176
+ {humanize(block.type)}
177
+ </span>
178
+
179
+ {!isExpanded && preview && (
180
+ <span className="text-muted-foreground group-hover/row:text-foreground/70 min-w-0 truncate text-sm transition-colors">
181
+ {preview}
182
+ </span>
183
+ )}
184
+
185
+ <div className="ml-auto flex shrink-0 items-center">
186
+ <Button
187
+ type="button"
188
+ variant="ghost"
189
+ size="icon"
190
+ title="Remove block"
191
+ className="text-muted-foreground hover:text-destructive size-7"
192
+ onClick={(e) => {
193
+ e.stopPropagation();
194
+ onRemove();
195
+ }}
196
+ >
197
+ <Trash2 className="size-3.5" />
198
+ </Button>
199
+ </div>
200
+ </div>
201
+
202
+ {/* Content */}
203
+ {isExpanded && (
204
+ <div ref={contentRef} className="space-y-4 border-t px-4 py-4">
205
+ {Object.entries(fieldsMeta).map(([fieldName, meta]) => (
206
+ <SubField
207
+ key={fieldName}
208
+ blockKey={block._key}
209
+ fieldName={fieldName}
210
+ meta={meta}
211
+ value={block[fieldName]}
212
+ onChange={(v) => onUpdateField(fieldName, v)}
213
+ relationOptions={meta.type === "relation" ? getRelationOptions(block.type, fieldName) : []}
214
+ />
215
+ ))}
216
+ </div>
217
+ )}
218
+ </div>
219
+ );
220
+ }
221
+
222
+ // -----------------------------------------------
223
+ // Main editor
224
+ // -----------------------------------------------
225
+
226
+ export default function BlockEditor({ name, value, types, blockRelationOptions = {} }: Props) {
227
+ const [blocks, setBlocks] = useState<Block[]>(() => parseBlocks(value, types));
228
+ const [expandedKeys, setExpandedKeys] = useState<Set<string>>(() => new Set());
229
+ const [newBlockKey, setNewBlockKey] = useState<string | null>(null);
230
+ const savedExpandedRef = useRef<Set<string> | null>(null);
231
+ const hiddenRef = useRef<HTMLInputElement>(null);
232
+ const previewChannelRef = useRef<BroadcastChannel | null>(null);
233
+
234
+ const typeNames = Object.keys(types);
235
+
236
+ const sensors = useSensors(
237
+ useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
238
+ useSensor(KeyboardSensor),
239
+ );
240
+
241
+ const dispatchChange = useCallback(() => {
242
+ if (hiddenRef.current) {
243
+ hiddenRef.current.dispatchEvent(new Event("change", { bubbles: true }));
244
+ }
245
+ }, []);
246
+
247
+ // Live preview: broadcast block data for server-side rendering
248
+ useEffect(() => {
249
+ if (!previewChannelRef.current) previewChannelRef.current = new BroadcastChannel("cms-preview");
250
+ previewChannelRef.current.postMessage({ field: name, value: serializeBlocks(blocks), render: "blocks" });
251
+ }, [blocks, name]);
252
+
253
+ const updateBlocks = useCallback(
254
+ (updater: (prev: Block[]) => Block[]) => {
255
+ setBlocks((prev) => {
256
+ const next = updater(prev);
257
+ setTimeout(dispatchChange, 0);
258
+ return next;
259
+ });
260
+ },
261
+ [dispatchChange],
262
+ );
263
+
264
+ const handleDragStart = useCallback(() => {
265
+ savedExpandedRef.current = new Set(expandedKeys);
266
+ setExpandedKeys(new Set());
267
+ }, [expandedKeys]);
268
+
269
+ const handleDragEnd = useCallback(
270
+ (event: DragEndEvent) => {
271
+ if (savedExpandedRef.current) {
272
+ setExpandedKeys(savedExpandedRef.current);
273
+ savedExpandedRef.current = null;
274
+ }
275
+ const { active, over } = event;
276
+ if (!over || active.id === over.id) return;
277
+ updateBlocks((prev) => {
278
+ const oldIndex = prev.findIndex((b) => b._key === active.id);
279
+ const newIndex = prev.findIndex((b) => b._key === over.id);
280
+ if (oldIndex === -1 || newIndex === -1) return prev;
281
+ return arrayMove(prev, oldIndex, newIndex);
282
+ });
283
+ },
284
+ [updateBlocks],
285
+ );
286
+
287
+ const addBlock = useCallback(
288
+ (typeName: string) => {
289
+ const fieldsMeta = types[typeName] ?? {};
290
+ const newBlock: Block = { _key: generateKey(), type: typeName };
291
+ for (const [fieldName, meta] of Object.entries(fieldsMeta)) {
292
+ if (meta.defaultValue !== undefined) {
293
+ newBlock[fieldName] = meta.defaultValue;
294
+ } else if (meta.type === "boolean") {
295
+ newBlock[fieldName] = false;
296
+ } else if (meta.type === "array") {
297
+ newBlock[fieldName] = [];
298
+ } else {
299
+ newBlock[fieldName] = "";
300
+ }
301
+ }
302
+ setNewBlockKey(newBlock._key);
303
+ updateBlocks((prev) => [...prev, newBlock]);
304
+ setExpandedKeys((prev) => new Set(prev).add(newBlock._key));
305
+ },
306
+ [types, updateBlocks],
307
+ );
308
+
309
+ const removeBlock = useCallback(
310
+ (key: string) => {
311
+ updateBlocks((prev) => prev.filter((b) => b._key !== key));
312
+ },
313
+ [updateBlocks],
314
+ );
315
+
316
+ const updateField = useCallback(
317
+ (key: string, fieldName: string, fieldValue: unknown) => {
318
+ updateBlocks((prev) => prev.map((b) => (b._key === key ? { ...b, [fieldName]: fieldValue } : b)));
319
+ },
320
+ [updateBlocks],
321
+ );
322
+
323
+ const toggleExpanded = useCallback((key: string) => {
324
+ setExpandedKeys((prev) => {
325
+ const next = new Set(prev);
326
+ if (next.has(key)) next.delete(key);
327
+ else next.add(key);
328
+ return next;
329
+ });
330
+ }, []);
331
+
332
+ const serialized = serializeBlocks(blocks);
333
+
334
+ return (
335
+ <div className="space-y-3">
336
+ <input type="hidden" ref={hiddenRef} name={name} value={serialized} />
337
+
338
+ {blocks.length === 0 && (
339
+ <div className="bg-muted/20 flex h-20 items-center justify-center rounded-lg border border-dashed">
340
+ <p className="text-muted-foreground text-sm">No blocks added yet</p>
341
+ </div>
342
+ )}
343
+
344
+ <DndContext
345
+ sensors={sensors}
346
+ collisionDetection={closestCenter}
347
+ onDragStart={handleDragStart}
348
+ onDragEnd={handleDragEnd}
349
+ >
350
+ <SortableContext items={blocks.map((b) => b._key)} strategy={verticalListSortingStrategy}>
351
+ <div className="space-y-3">
352
+ {blocks.map((block) => {
353
+ const fieldsMeta = types[block.type] ?? {};
354
+ return (
355
+ <SortableBlock
356
+ key={block._key}
357
+ block={block}
358
+ fieldsMeta={fieldsMeta}
359
+ isExpanded={expandedKeys.has(block._key)}
360
+ autoFocus={newBlockKey === block._key}
361
+ onAutoFocused={() => setNewBlockKey(null)}
362
+ onToggle={() => toggleExpanded(block._key)}
363
+ onRemove={() => removeBlock(block._key)}
364
+ onUpdateField={(fn, v) => updateField(block._key, fn, v)}
365
+ getRelationOptions={(blockType, fieldName) =>
366
+ blockRelationOptions[`block:${blockType}:${fieldName}`] ?? []
367
+ }
368
+ />
369
+ );
370
+ })}
371
+ </div>
372
+ </SortableContext>
373
+ </DndContext>
374
+
375
+ {/* Add block buttons */}
376
+ <div className="flex flex-wrap gap-2">
377
+ {typeNames.map((typeName) => (
378
+ <Button
379
+ key={typeName}
380
+ type="button"
381
+ variant="outline"
382
+ size="sm"
383
+ className="text-foreground/70"
384
+ onClick={() => addBlock(typeName)}
385
+ >
386
+ <Plus className="mr-1 size-3.5" />
387
+ {humanize(typeName)}
388
+ </Button>
389
+ ))}
390
+ </div>
391
+ </div>
392
+ );
393
+ }
394
+
395
+ // -----------------------------------------------
396
+ // Sub-field renderer
397
+ // -----------------------------------------------
398
+
399
+ function SubField({
400
+ blockKey,
401
+ fieldName,
402
+ meta,
403
+ value,
404
+ onChange,
405
+ relationOptions = [],
406
+ }: {
407
+ blockKey: string;
408
+ fieldName: string;
409
+ meta: SubFieldMeta;
410
+ value: unknown;
411
+ onChange: (value: unknown) => void;
412
+ relationOptions?: RelationOption[];
413
+ }) {
414
+ const label = meta.label ?? humanize(fieldName);
415
+ const fieldId = `${blockKey}_${fieldName}`;
416
+
417
+ return (
418
+ <div className="grid gap-2">
419
+ <Label htmlFor={fieldId}>
420
+ {label}
421
+ {meta.required ? " *" : ""}
422
+ </Label>
423
+ {meta.admin?.help && <p className="text-muted-foreground text-xs leading-5">{meta.admin.help}</p>}
424
+ <SubFieldControl
425
+ fieldId={fieldId}
426
+ meta={meta}
427
+ value={value}
428
+ onChange={onChange}
429
+ relationOptions={relationOptions}
430
+ />
431
+ </div>
432
+ );
433
+ }
434
+
435
+ function JsonTextarea({
436
+ fieldId,
437
+ rows,
438
+ value,
439
+ fallback,
440
+ onChange,
441
+ }: {
442
+ fieldId: string;
443
+ rows: number;
444
+ value: unknown;
445
+ fallback: unknown;
446
+ onChange: (value: unknown) => void;
447
+ }) {
448
+ const strValue = value == null ? "" : String(value);
449
+ return (
450
+ <Textarea
451
+ id={fieldId}
452
+ rows={rows}
453
+ value={typeof value === "string" ? strValue : JSON.stringify(value ?? fallback, null, 2)}
454
+ onChange={(e) => {
455
+ try {
456
+ onChange(JSON.parse(e.target.value));
457
+ } catch {
458
+ onChange(e.target.value);
459
+ }
460
+ }}
461
+ />
462
+ );
463
+ }
464
+
465
+ function parseSelected(meta: SubFieldMeta, value: unknown): string[] {
466
+ if (!meta.hasMany) return value ? [String(value)] : [];
467
+ if (Array.isArray(value)) return value as string[];
468
+ if (typeof value === "string" && value) {
469
+ try {
470
+ return JSON.parse(value) as string[];
471
+ } catch {
472
+ return [];
473
+ }
474
+ }
475
+ return [];
476
+ }
477
+
478
+ function RelationControl({
479
+ fieldId,
480
+ meta,
481
+ value,
482
+ onChange,
483
+ relationOptions,
484
+ }: {
485
+ fieldId: string;
486
+ meta: SubFieldMeta;
487
+ value: unknown;
488
+ onChange: (value: unknown) => void;
489
+ relationOptions: RelationOption[];
490
+ }) {
491
+ const selected = parseSelected(meta, value);
492
+ const getLabel = (id: string) => relationOptions?.find((o) => o.value === id)?.label ?? id;
493
+ const toggle = (id: string) => {
494
+ if (meta.hasMany) {
495
+ const next = selected.includes(id) ? selected.filter((v) => v !== id) : [...selected, id];
496
+ onChange(next);
497
+ } else {
498
+ onChange(selected[0] === id ? "" : id);
499
+ }
500
+ };
501
+ return (
502
+ <div className="space-y-2">
503
+ {meta.hasMany && selected.length > 0 && (
504
+ <div className="flex flex-wrap gap-1.5">
505
+ {selected.map((id) => (
506
+ <span
507
+ key={id}
508
+ className="bg-secondary text-secondary-foreground inline-flex items-center gap-1 rounded-md border px-2 py-0.5 text-sm"
509
+ >
510
+ {getLabel(id)}
511
+ <button
512
+ type="button"
513
+ onClick={() => toggle(id)}
514
+ className="text-muted-foreground hover:text-foreground -mr-0.5 rounded p-0.5"
515
+ >
516
+ <Trash2 className="size-3" />
517
+ </button>
518
+ </span>
519
+ ))}
520
+ </div>
521
+ )}
522
+ <SelectField
523
+ name={fieldId}
524
+ value={meta.hasMany ? "" : (selected[0] ?? "")}
525
+ placeholder={`Select ${meta.label?.toLowerCase() ?? "item"}...`}
526
+ items={(relationOptions ?? []).map((o) => ({ value: o.value, label: o.label }))}
527
+ onChange={(v) => {
528
+ if (meta.hasMany && v) {
529
+ if (!selected.includes(v)) onChange([...selected, v]);
530
+ } else {
531
+ onChange(v);
532
+ }
533
+ }}
534
+ />
535
+ </div>
536
+ );
537
+ }
538
+
539
+ function ArrayControl({
540
+ fieldId,
541
+ meta,
542
+ value,
543
+ onChange,
544
+ }: {
545
+ fieldId: string;
546
+ meta: SubFieldMeta;
547
+ value: unknown;
548
+ onChange: (value: unknown) => void;
549
+ }) {
550
+ if (meta.of?.type === "image") {
551
+ return <ArrayImageField fieldId={fieldId} value={value} onChange={onChange} />;
552
+ }
553
+ if (meta.of?.type === "text") {
554
+ const items = Array.isArray(value) ? value : [];
555
+ return (
556
+ <Input
557
+ id={fieldId}
558
+ value={items.join(", ")}
559
+ placeholder={meta.admin?.placeholder ?? "item1, item2, item3"}
560
+ onChange={(e) => {
561
+ const parts = e.target.value
562
+ .split(",")
563
+ .map((s: string) => s.trim())
564
+ .filter(Boolean);
565
+ onChange(parts);
566
+ }}
567
+ />
568
+ );
569
+ }
570
+ return (
571
+ <JsonTextarea fieldId={fieldId} rows={meta.admin?.rows ?? 5} value={value} fallback={[]} onChange={onChange} />
572
+ );
573
+ }
574
+
575
+ function SubFieldControl({
576
+ fieldId,
577
+ meta,
578
+ value,
579
+ onChange,
580
+ relationOptions = [],
581
+ }: {
582
+ fieldId: string;
583
+ meta: SubFieldMeta;
584
+ value: unknown;
585
+ onChange: (value: unknown) => void;
586
+ relationOptions?: RelationOption[];
587
+ }) {
588
+ const strValue = value == null ? "" : String(value);
589
+
590
+ switch (meta.type) {
591
+ case "text":
592
+ case "email":
593
+ case "date":
594
+ return (
595
+ <Input
596
+ id={fieldId}
597
+ type={meta.type === "email" ? "email" : meta.type === "date" ? "date" : "text"}
598
+ value={strValue}
599
+ placeholder={meta.admin?.placeholder ?? ""}
600
+ required={meta.required}
601
+ onChange={(e) => onChange(e.target.value)}
602
+ />
603
+ );
604
+
605
+ case "number":
606
+ return (
607
+ <Input
608
+ id={fieldId}
609
+ type="number"
610
+ value={strValue}
611
+ required={meta.required}
612
+ onChange={(e) => onChange(e.target.value === "" ? "" : Number(e.target.value))}
613
+ />
614
+ );
615
+
616
+ case "boolean":
617
+ return (
618
+ <span className="border-input bg-background inline-flex items-center gap-3 rounded-md border px-3 py-2 text-sm">
619
+ <input
620
+ id={fieldId}
621
+ type="checkbox"
622
+ className="border-input text-primary focus:ring-primary size-4 rounded"
623
+ checked={Boolean(value)}
624
+ onChange={(e) => onChange(e.target.checked)}
625
+ />
626
+ <span className="text-muted-foreground">{value ? "Enabled" : "Disabled"}</span>
627
+ </span>
628
+ );
629
+
630
+ case "select":
631
+ return (
632
+ <SelectField
633
+ name={fieldId}
634
+ value={strValue}
635
+ placeholder="Select an option"
636
+ items={(meta.options ?? []).map((o) => ({ value: o, label: o }))}
637
+ onChange={(v) => onChange(v)}
638
+ />
639
+ );
640
+
641
+ case "richText":
642
+ return (
643
+ <RichTextEditor
644
+ name={fieldId}
645
+ initialValue={typeof value === "string" ? strValue : JSON.stringify(value ?? "")}
646
+ rows={meta.admin?.rows ?? 8}
647
+ onChange={(v) => {
648
+ try {
649
+ onChange(JSON.parse(v));
650
+ } catch {
651
+ onChange(v);
652
+ }
653
+ }}
654
+ />
655
+ );
656
+
657
+ case "image":
658
+ return (
659
+ <ImagePicker
660
+ name={fieldId}
661
+ value={strValue}
662
+ placeholder={meta.admin?.placeholder}
663
+ onChange={(v) => onChange(v)}
664
+ />
665
+ );
666
+
667
+ case "relation":
668
+ return (
669
+ <RelationControl
670
+ fieldId={fieldId}
671
+ meta={meta}
672
+ value={value}
673
+ onChange={onChange}
674
+ relationOptions={relationOptions}
675
+ />
676
+ );
677
+
678
+ case "array":
679
+ return <ArrayControl fieldId={fieldId} meta={meta} value={value} onChange={onChange} />;
680
+
681
+ case "json":
682
+ if (meta.admin?.component === "repeater") {
683
+ return <RepeaterField fieldId={fieldId} value={value} onChange={onChange} />;
684
+ }
685
+ return (
686
+ <JsonTextarea fieldId={fieldId} rows={meta.admin?.rows ?? 5} value={value} fallback={{}} onChange={onChange} />
687
+ );
688
+
689
+ default:
690
+ return (
691
+ <JsonTextarea fieldId={fieldId} rows={meta.admin?.rows ?? 5} value={value} fallback="" onChange={onChange} />
692
+ );
693
+ }
694
+ }
695
+
696
+ // -----------------------------------------------
697
+ // Array of images sub-component
698
+ // -----------------------------------------------
699
+
700
+ function getRepeaterPreview(item: Record<string, string>, fieldKeys: string[]): string {
701
+ for (const key of fieldKeys) {
702
+ if (item[key]) {
703
+ const text = String(item[key]);
704
+ return text.length > 60 ? text.slice(0, 60) + "..." : text;
705
+ }
706
+ }
707
+ return "";
708
+ }
709
+
710
+ function SortableRepeaterItem({
711
+ item,
712
+ fieldKeys,
713
+ index,
714
+ isExpanded,
715
+ autoFocus,
716
+ onAutoFocused,
717
+ onToggle,
718
+ onRemove,
719
+ onUpdate,
720
+ }: {
721
+ item: Record<string, string>;
722
+ fieldKeys: string[];
723
+ index: number;
724
+ isExpanded: boolean;
725
+ autoFocus?: boolean;
726
+ onAutoFocused?: () => void;
727
+ onToggle: () => void;
728
+ onRemove: () => void;
729
+ onUpdate: (key: string, val: string) => void;
730
+ }) {
731
+ const contentRef = useRef<HTMLDivElement>(null);
732
+ const { attributes, listeners, setNodeRef, setActivatorNodeRef, transform, transition, isDragging } = useSortable({
733
+ id: item._key,
734
+ });
735
+
736
+ useEffect(() => {
737
+ if (autoFocus && isExpanded && contentRef.current) {
738
+ const input = contentRef.current.querySelector<HTMLElement>("input, textarea");
739
+ if (input) {
740
+ input.focus();
741
+ onAutoFocused?.();
742
+ }
743
+ }
744
+ }, [autoFocus, isExpanded, onAutoFocused]);
745
+
746
+ const style = {
747
+ transform: CSS.Transform.toString(transform),
748
+ transition,
749
+ };
750
+
751
+ const preview = getRepeaterPreview(item, fieldKeys);
752
+
753
+ return (
754
+ <div
755
+ ref={setNodeRef}
756
+ style={style}
757
+ className={cn("overflow-hidden rounded-lg border", isDragging && "z-10 opacity-90 shadow-lg")}
758
+ >
759
+ <div
760
+ className="group/row hover:bg-muted/80 flex items-center gap-2 px-3 py-2 transition-colors select-none"
761
+ onClick={onToggle}
762
+ >
763
+ <button
764
+ type="button"
765
+ ref={setActivatorNodeRef}
766
+ className="text-muted-foreground/50 hover:text-muted-foreground -ml-1 cursor-grab touch-none rounded p-1 transition-colors active:cursor-grabbing"
767
+ {...attributes}
768
+ {...listeners}
769
+ onClick={(e) => e.stopPropagation()}
770
+ >
771
+ <GripVertical className="size-4" />
772
+ </button>
773
+
774
+ <ChevronRight
775
+ className={cn(
776
+ "text-muted-foreground group-hover/row:text-foreground/70 size-4 shrink-0 transition-[color,transform]",
777
+ isExpanded && "rotate-90",
778
+ )}
779
+ />
780
+
781
+ <span className="text-muted-foreground group-hover/row:text-foreground/70 text-xs font-medium transition-colors">
782
+ #{index + 1}
783
+ </span>
784
+
785
+ {!isExpanded && preview && (
786
+ <span className="text-muted-foreground group-hover/row:text-foreground/70 min-w-0 truncate text-sm transition-colors">
787
+ {preview}
788
+ </span>
789
+ )}
790
+
791
+ <div className="ml-auto">
792
+ <Button
793
+ type="button"
794
+ variant="ghost"
795
+ size="icon-xs"
796
+ title="Remove item"
797
+ className="text-muted-foreground hover:text-destructive"
798
+ onClick={(e) => {
799
+ e.stopPropagation();
800
+ onRemove();
801
+ }}
802
+ >
803
+ <Trash2 className="size-3.5" />
804
+ </Button>
805
+ </div>
806
+ </div>
807
+
808
+ {isExpanded && (
809
+ <div ref={contentRef} className="space-y-2 border-t px-4 py-3">
810
+ {fieldKeys.map((key) => (
811
+ <div key={key} className="grid gap-1">
812
+ <Label className="text-xs">{humanize(key)}</Label>
813
+ {key.includes("description") ||
814
+ key.includes("body") ||
815
+ key.includes("content") ||
816
+ key.includes("answer") ? (
817
+ <Textarea rows={3} value={item[key] ?? ""} onChange={(e) => onUpdate(key, e.target.value)} />
818
+ ) : (
819
+ <Input value={item[key] ?? ""} onChange={(e) => onUpdate(key, e.target.value)} />
820
+ )}
821
+ </div>
822
+ ))}
823
+ </div>
824
+ )}
825
+ </div>
826
+ );
827
+ }
828
+
829
+ function parseArray(value: unknown): unknown[] {
830
+ if (Array.isArray(value)) return value;
831
+ if (typeof value === "string") {
832
+ try {
833
+ const p = JSON.parse(value);
834
+ if (Array.isArray(p)) return p;
835
+ } catch {}
836
+ }
837
+ return [];
838
+ }
839
+
840
+ function RepeaterField({ value, onChange }: { fieldId: string; value: unknown; onChange: (value: unknown) => void }) {
841
+ const [newItemKey, setNewItemKey] = useState<string | null>(null);
842
+ const [expandedKeys, setExpandedKeys] = useState<Set<string>>(() => new Set());
843
+ const savedExpandedRef = useRef<Set<string> | null>(null);
844
+ const [items, setItems] = useState<Array<Record<string, string>>>(() => {
845
+ const raw = parseArray(value);
846
+ return raw.map((item: unknown) => {
847
+ const obj = item as Record<string, string>;
848
+ return obj._key ? obj : { ...obj, _key: generateKey() };
849
+ });
850
+ });
851
+
852
+ useEffect(() => {
853
+ onChange(items);
854
+ }, [items]); // eslint-disable-line react-hooks/exhaustive-deps
855
+
856
+ // Detect fields from first item or default to title + description
857
+ const fieldKeys = items.length > 0 ? Object.keys(items[0]).filter((k) => k !== "_key") : ["title", "description"];
858
+
859
+ const sensors = useSensors(
860
+ useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
861
+ useSensor(KeyboardSensor),
862
+ );
863
+
864
+ const addItem = () => {
865
+ const key = generateKey();
866
+ const blank: Record<string, string> = { _key: key };
867
+ for (const k of fieldKeys) blank[k] = "";
868
+ setNewItemKey(key);
869
+ setItems((prev) => [...prev, blank]);
870
+ setExpandedKeys((prev) => new Set(prev).add(key));
871
+ };
872
+
873
+ const removeItem = (index: number) => {
874
+ setItems((prev) => prev.filter((_, i) => i !== index));
875
+ };
876
+
877
+ const updateItem = (index: number, key: string, val: string) => {
878
+ setItems((prev) => prev.map((item, i) => (i === index ? { ...item, [key]: val } : item)));
879
+ };
880
+
881
+ const toggleExpanded = (key: string) => {
882
+ setExpandedKeys((prev) => {
883
+ const next = new Set(prev);
884
+ if (next.has(key)) next.delete(key);
885
+ else next.add(key);
886
+ return next;
887
+ });
888
+ };
889
+
890
+ const handleDragStart = () => {
891
+ savedExpandedRef.current = new Set(expandedKeys);
892
+ setExpandedKeys(new Set());
893
+ };
894
+
895
+ const handleDragEnd = (event: DragEndEvent) => {
896
+ if (savedExpandedRef.current) {
897
+ setExpandedKeys(savedExpandedRef.current);
898
+ savedExpandedRef.current = null;
899
+ }
900
+ const { active, over } = event;
901
+ if (!over || active.id === over.id) return;
902
+ setItems((prev) => {
903
+ const oldIndex = prev.findIndex((item) => item._key === active.id);
904
+ const newIndex = prev.findIndex((item) => item._key === over.id);
905
+ if (oldIndex === -1 || newIndex === -1) return prev;
906
+ return arrayMove(prev, oldIndex, newIndex);
907
+ });
908
+ };
909
+
910
+ return (
911
+ <div className="space-y-3">
912
+ <DndContext
913
+ sensors={sensors}
914
+ collisionDetection={closestCenter}
915
+ onDragStart={handleDragStart}
916
+ onDragEnd={handleDragEnd}
917
+ >
918
+ <SortableContext items={items.map((item) => item._key)} strategy={verticalListSortingStrategy}>
919
+ {items.map((item, index) => (
920
+ <SortableRepeaterItem
921
+ key={item._key ?? index}
922
+ item={item}
923
+ fieldKeys={fieldKeys}
924
+ index={index}
925
+ isExpanded={expandedKeys.has(item._key)}
926
+ autoFocus={newItemKey === item._key}
927
+ onAutoFocused={() => setNewItemKey(null)}
928
+ onToggle={() => toggleExpanded(item._key)}
929
+ onRemove={() => removeItem(index)}
930
+ onUpdate={(key, val) => updateItem(index, key, val)}
931
+ />
932
+ ))}
933
+ </SortableContext>
934
+ </DndContext>
935
+ <Button type="button" variant="outline" size="sm" className="text-foreground/70" onClick={addItem}>
936
+ <Plus className="size-3.5" />
937
+ Add item
938
+ </Button>
939
+ </div>
940
+ );
941
+ }
942
+
943
+ function ArrayImageField({
944
+ fieldId,
945
+ value,
946
+ onChange,
947
+ }: {
948
+ fieldId: string;
949
+ value: unknown;
950
+ onChange: (value: unknown) => void;
951
+ }) {
952
+ const items: string[] = useMemo(() => (Array.isArray(value) ? value.map(String) : []), [value]);
953
+
954
+ const addImage = useCallback(() => {
955
+ onChange([...items, ""]);
956
+ }, [items, onChange]);
957
+
958
+ const removeImage = useCallback(
959
+ (index: number) => {
960
+ onChange(items.filter((_, i) => i !== index));
961
+ },
962
+ [items, onChange],
963
+ );
964
+
965
+ const updateImage = useCallback(
966
+ (index: number, v: string) => {
967
+ const next = [...items];
968
+ next[index] = v;
969
+ onChange(next);
970
+ },
971
+ [items, onChange],
972
+ );
973
+
974
+ return (
975
+ <div className="space-y-3">
976
+ {items.map((img, i) => (
977
+ <div key={`${fieldId}_img_${i}`} className="relative">
978
+ <ImagePicker name={`${fieldId}_img_${i}`} value={img} onChange={(v) => updateImage(i, v)} />
979
+ <Button
980
+ type="button"
981
+ variant="ghost"
982
+ size="icon"
983
+ className="text-muted-foreground hover:text-destructive absolute top-0 right-0 size-7"
984
+ onClick={() => removeImage(i)}
985
+ >
986
+ <Trash2 className="size-3.5" />
987
+ </Button>
988
+ </div>
989
+ ))}
990
+ <Button type="button" variant="outline" size="sm" onClick={addImage}>
991
+ <Plus className="mr-1 size-3.5" />
992
+ Add Image
993
+ </Button>
994
+ </div>
995
+ );
996
+ }