@actuate-media/cms-admin 0.1.3 → 0.2.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 (92) hide show
  1. package/dist/AdminRoot.d.ts.map +1 -1
  2. package/dist/AdminRoot.js +16 -10
  3. package/dist/AdminRoot.js.map +1 -1
  4. package/dist/actuate-admin.css +2 -0
  5. package/dist/lib/useApiData.d.ts +8 -1
  6. package/dist/lib/useApiData.d.ts.map +1 -1
  7. package/dist/lib/useApiData.js +39 -7
  8. package/dist/lib/useApiData.js.map +1 -1
  9. package/dist/views/Dashboard.d.ts.map +1 -1
  10. package/dist/views/Dashboard.js +8 -3
  11. package/dist/views/Dashboard.js.map +1 -1
  12. package/package.json +10 -5
  13. package/src/AdminRoot.tsx +312 -0
  14. package/src/__tests__/lib/search.test.ts +138 -0
  15. package/src/__tests__/lib/utils.test.ts +19 -0
  16. package/src/__tests__/router/match-route.test.ts +47 -0
  17. package/src/__tests__/router/strip-base.test.ts +30 -0
  18. package/src/components/Breadcrumbs.tsx +92 -0
  19. package/src/components/CommandPalette.tsx +384 -0
  20. package/src/components/ErrorBoundary.tsx +52 -0
  21. package/src/components/FocalPointPicker.tsx +54 -0
  22. package/src/components/FolderTree.tsx +427 -0
  23. package/src/components/LivePreview.tsx +136 -0
  24. package/src/components/LocaleProvider.tsx +51 -0
  25. package/src/components/LocaleSwitcher.tsx +51 -0
  26. package/src/components/MediaPickerModal.tsx +183 -0
  27. package/src/components/PresenceIndicator.tsx +71 -0
  28. package/src/components/SEOPanel.tsx +767 -0
  29. package/src/components/ThemeProvider.tsx +98 -0
  30. package/src/components/TipTapEditor.tsx +469 -0
  31. package/src/components/VersionHistory.tsx +167 -0
  32. package/src/components/ui/Avatar.tsx +42 -0
  33. package/src/components/ui/Badge.tsx +25 -0
  34. package/src/components/ui/Button.tsx +52 -0
  35. package/src/components/ui/CommandPalette.tsx +119 -0
  36. package/src/components/ui/ConfirmDialog.tsx +52 -0
  37. package/src/components/ui/DataTable.tsx +194 -0
  38. package/src/components/ui/EmptyState.tsx +29 -0
  39. package/src/components/ui/Modal.tsx +48 -0
  40. package/src/components/ui/Pagination.tsx +79 -0
  41. package/src/components/ui/SearchInput.tsx +44 -0
  42. package/src/components/ui/Skeleton.tsx +48 -0
  43. package/src/components/ui/Toast.tsx +66 -0
  44. package/src/components/ui/index.ts +24 -0
  45. package/src/fields/ArrayField.tsx +92 -0
  46. package/src/fields/BlockBuilderField.tsx +421 -0
  47. package/src/fields/DateField.tsx +41 -0
  48. package/src/fields/FieldRenderer.tsx +84 -0
  49. package/src/fields/GroupField.tsx +41 -0
  50. package/src/fields/MediaField.tsx +48 -0
  51. package/src/fields/NavBuilderField.tsx +78 -0
  52. package/src/fields/NumberField.tsx +45 -0
  53. package/src/fields/RelationshipField.tsx +245 -0
  54. package/src/fields/RichTextField.tsx +26 -0
  55. package/src/fields/SelectField.tsx +117 -0
  56. package/src/fields/SlugField.tsx +65 -0
  57. package/src/fields/TextField.tsx +48 -0
  58. package/src/fields/ToggleField.tsx +36 -0
  59. package/src/fields/block-types.ts +95 -0
  60. package/src/fields/index.ts +17 -0
  61. package/src/hooks/useContentLock.ts +52 -0
  62. package/src/hooks/useDebounce.ts +14 -0
  63. package/src/hooks/useKeyboardShortcuts.ts +32 -0
  64. package/src/index.ts +55 -0
  65. package/src/layout/Header.tsx +135 -0
  66. package/src/layout/Layout.tsx +77 -0
  67. package/src/layout/Sidebar.tsx +216 -0
  68. package/src/lib/api.ts +67 -0
  69. package/src/lib/search.ts +59 -0
  70. package/src/lib/useApiData.ts +95 -0
  71. package/src/lib/utils.ts +6 -0
  72. package/src/router/index.ts +81 -0
  73. package/src/styles/build-input.css +11 -0
  74. package/src/styles/tailwind.css +7 -2
  75. package/src/styles/theme.css +2 -1
  76. package/src/views/CollectionList.tsx +270 -0
  77. package/src/views/Dashboard.tsx +207 -0
  78. package/src/views/DocumentEdit.tsx +377 -0
  79. package/src/views/FormEditor.tsx +533 -0
  80. package/src/views/FormSubmissions.tsx +316 -0
  81. package/src/views/Forms.tsx +106 -0
  82. package/src/views/Login.tsx +322 -0
  83. package/src/views/MediaBrowser.tsx +774 -0
  84. package/src/views/PageEditor.tsx +192 -0
  85. package/src/views/Pages.tsx +354 -0
  86. package/src/views/PostEditor.tsx +251 -0
  87. package/src/views/Posts.tsx +243 -0
  88. package/src/views/Redirects.tsx +293 -0
  89. package/src/views/SEO.tsx +458 -0
  90. package/src/views/Settings.tsx +811 -0
  91. package/src/views/SetupWizard.tsx +207 -0
  92. package/src/views/Users.tsx +282 -0
@@ -0,0 +1,421 @@
1
+ 'use client';
2
+
3
+ import { useState, useCallback, useMemo } from 'react';
4
+ import {
5
+ DndContext,
6
+ closestCenter,
7
+ KeyboardSensor,
8
+ PointerSensor,
9
+ useSensor,
10
+ useSensors,
11
+ type DragEndEvent,
12
+ } from '@dnd-kit/core';
13
+ import {
14
+ SortableContext,
15
+ sortableKeyboardCoordinates,
16
+ verticalListSortingStrategy,
17
+ useSortable,
18
+ arrayMove,
19
+ } from '@dnd-kit/sortable';
20
+ import { CSS } from '@dnd-kit/utilities';
21
+ import {
22
+ Plus,
23
+ GripVertical,
24
+ Trash2,
25
+ ChevronDown,
26
+ ChevronRight,
27
+ Layout,
28
+ Megaphone,
29
+ Columns3,
30
+ Image,
31
+ Quote,
32
+ Play,
33
+ X,
34
+ } from 'lucide-react';
35
+ import { PRESET_BLOCKS, type BlockTypeDefinition } from './block-types.js';
36
+
37
+ const BLOCK_ICONS: Record<string, React.ComponentType<{ className?: string }>> = {
38
+ layout: Layout,
39
+ megaphone: Megaphone,
40
+ columns: Columns3,
41
+ image: Image,
42
+ quote: Quote,
43
+ play: Play,
44
+ };
45
+
46
+ interface BlockValue {
47
+ id: string;
48
+ type: string;
49
+ data: Record<string, unknown>;
50
+ }
51
+
52
+ export interface BlockBuilderFieldProps {
53
+ label?: string;
54
+ value?: BlockValue[];
55
+ onChange?: (value: BlockValue[]) => void;
56
+ blocks?: BlockTypeDefinition[];
57
+ helpText?: string;
58
+ }
59
+
60
+ interface BlockFieldProps {
61
+ field: BlockTypeDefinition['fields'][number];
62
+ value: unknown;
63
+ onChange: (value: unknown) => void;
64
+ }
65
+
66
+ function BlockField({ field, value, onChange }: BlockFieldProps) {
67
+ if (field.type === 'select' && field.options) {
68
+ return (
69
+ <div>
70
+ <label className="mb-1 block text-sm font-medium">
71
+ {field.label}
72
+ {field.required && <span className="ml-0.5 text-[var(--destructive)]">*</span>}
73
+ </label>
74
+ <select
75
+ value={(value as string) ?? ''}
76
+ onChange={(e) => onChange(e.target.value)}
77
+ className="w-full rounded-md border border-[var(--border)] bg-[var(--input-background)] px-3 py-2 text-sm outline-none transition-colors focus:ring-2 focus:ring-[var(--ring)]"
78
+ >
79
+ <option value="">Select...</option>
80
+ {field.options.map((opt) => (
81
+ <option key={opt.value} value={opt.value}>
82
+ {opt.label}
83
+ </option>
84
+ ))}
85
+ </select>
86
+ </div>
87
+ );
88
+ }
89
+
90
+ if (field.type === 'richText') {
91
+ return (
92
+ <div>
93
+ <label className="mb-1 block text-sm font-medium">
94
+ {field.label}
95
+ {field.required && <span className="ml-0.5 text-[var(--destructive)]">*</span>}
96
+ </label>
97
+ <textarea
98
+ value={(value as string) ?? ''}
99
+ onChange={(e) => onChange(e.target.value)}
100
+ rows={4}
101
+ className="w-full rounded-md border border-[var(--border)] bg-[var(--input-background)] px-3 py-2 text-sm outline-none transition-colors focus:ring-2 focus:ring-[var(--ring)]"
102
+ />
103
+ </div>
104
+ );
105
+ }
106
+
107
+ if (field.type === 'media') {
108
+ return (
109
+ <div>
110
+ <label className="mb-1 block text-sm font-medium">
111
+ {field.label}
112
+ {field.required && <span className="ml-0.5 text-[var(--destructive)]">*</span>}
113
+ </label>
114
+ <div className="flex items-center gap-3 rounded-md border border-dashed border-[var(--border)] p-3">
115
+ <div className="flex h-12 w-12 shrink-0 items-center justify-center rounded-md bg-[var(--muted)]">
116
+ <Image className="h-5 w-5 text-[var(--muted-foreground)]" />
117
+ </div>
118
+ <span className="text-sm text-[var(--muted-foreground)]">
119
+ {value ? 'Media selected' : 'No media selected'}
120
+ </span>
121
+ </div>
122
+ </div>
123
+ );
124
+ }
125
+
126
+ return (
127
+ <div>
128
+ <label className="mb-1 block text-sm font-medium">
129
+ {field.label}
130
+ {field.required && <span className="ml-0.5 text-[var(--destructive)]">*</span>}
131
+ </label>
132
+ <input
133
+ type="text"
134
+ value={(value as string) ?? ''}
135
+ onChange={(e) => onChange(e.target.value)}
136
+ required={field.required}
137
+ className="w-full rounded-md border border-[var(--border)] bg-[var(--input-background)] px-3 py-2 text-sm outline-none transition-colors focus:ring-2 focus:ring-[var(--ring)]"
138
+ />
139
+ </div>
140
+ );
141
+ }
142
+
143
+ interface SortableBlockProps {
144
+ block: BlockValue;
145
+ blockDef: BlockTypeDefinition | undefined;
146
+ expanded: boolean;
147
+ onToggle: () => void;
148
+ onRemove: () => void;
149
+ onFieldChange: (fieldName: string, fieldValue: unknown) => void;
150
+ }
151
+
152
+ function SortableBlock({
153
+ block,
154
+ blockDef,
155
+ expanded,
156
+ onToggle,
157
+ onRemove,
158
+ onFieldChange,
159
+ }: SortableBlockProps) {
160
+ const {
161
+ attributes,
162
+ listeners,
163
+ setNodeRef,
164
+ transform,
165
+ transition,
166
+ isDragging,
167
+ } = useSortable({ id: block.id });
168
+
169
+ const style: React.CSSProperties = {
170
+ transform: CSS.Transform.toString(transform),
171
+ transition,
172
+ opacity: isDragging ? 0.5 : 1,
173
+ zIndex: isDragging ? 10 : undefined,
174
+ };
175
+
176
+ const IconComponent = blockDef ? BLOCK_ICONS[blockDef.icon] : undefined;
177
+
178
+ return (
179
+ <div
180
+ ref={setNodeRef}
181
+ style={style}
182
+ className="rounded-md border border-[var(--border)] bg-[var(--card)]"
183
+ >
184
+ <div className="flex items-center gap-2 border-b border-[var(--border)] px-3 py-2">
185
+ <button
186
+ type="button"
187
+ className="cursor-grab touch-none text-[var(--muted-foreground)] hover:text-[var(--foreground)]"
188
+ {...attributes}
189
+ {...listeners}
190
+ >
191
+ <GripVertical className="h-4 w-4" />
192
+ </button>
193
+
194
+ {IconComponent && (
195
+ <IconComponent className="h-4 w-4 text-[var(--muted-foreground)]" />
196
+ )}
197
+
198
+ <button
199
+ type="button"
200
+ onClick={onToggle}
201
+ className="flex flex-1 items-center gap-1.5 text-sm font-medium"
202
+ >
203
+ {blockDef?.label ?? block.type}
204
+ {expanded ? (
205
+ <ChevronDown className="h-3.5 w-3.5 text-[var(--muted-foreground)]" />
206
+ ) : (
207
+ <ChevronRight className="h-3.5 w-3.5 text-[var(--muted-foreground)]" />
208
+ )}
209
+ </button>
210
+
211
+ <button
212
+ type="button"
213
+ onClick={onRemove}
214
+ className="text-[var(--muted-foreground)] hover:text-[var(--destructive)]"
215
+ aria-label="Remove block"
216
+ >
217
+ <Trash2 className="h-4 w-4" />
218
+ </button>
219
+ </div>
220
+
221
+ {expanded && blockDef && (
222
+ <div className="space-y-4 p-4">
223
+ {blockDef.fields.map((field) => (
224
+ <BlockField
225
+ key={field.name}
226
+ field={field}
227
+ value={block.data[field.name]}
228
+ onChange={(val) => onFieldChange(field.name, val)}
229
+ />
230
+ ))}
231
+ </div>
232
+ )}
233
+
234
+ {expanded && !blockDef && (
235
+ <div className="p-4 text-sm text-[var(--muted-foreground)]">
236
+ Unknown block type: <code>{block.type}</code>
237
+ </div>
238
+ )}
239
+ </div>
240
+ );
241
+ }
242
+
243
+ export function BlockBuilderField({
244
+ label,
245
+ value = [],
246
+ onChange,
247
+ blocks: customBlocks,
248
+ helpText,
249
+ }: BlockBuilderFieldProps) {
250
+ const [expandedIds, setExpandedIds] = useState<Set<string>>(new Set());
251
+ const [pickerOpen, setPickerOpen] = useState(false);
252
+
253
+ const allBlockTypes = useMemo(() => {
254
+ if (!customBlocks?.length) return PRESET_BLOCKS;
255
+ const customTypes = new Set(customBlocks.map((b) => b.type));
256
+ const filtered = PRESET_BLOCKS.filter((b) => !customTypes.has(b.type));
257
+ return [...filtered, ...customBlocks];
258
+ }, [customBlocks]);
259
+
260
+ const blockDefMap = useMemo(() => {
261
+ const map = new Map<string, BlockTypeDefinition>();
262
+ for (const def of allBlockTypes) {
263
+ map.set(def.type, def);
264
+ }
265
+ return map;
266
+ }, [allBlockTypes]);
267
+
268
+ const sensors = useSensors(
269
+ useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
270
+ useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
271
+ );
272
+
273
+ const handleDragEnd = useCallback(
274
+ (event: DragEndEvent) => {
275
+ const { active, over } = event;
276
+ if (!over || active.id === over.id) return;
277
+
278
+ const oldIndex = value.findIndex((b) => b.id === active.id);
279
+ const newIndex = value.findIndex((b) => b.id === over.id);
280
+ if (oldIndex === -1 || newIndex === -1) return;
281
+
282
+ onChange?.(arrayMove(value, oldIndex, newIndex));
283
+ },
284
+ [value, onChange],
285
+ );
286
+
287
+ function addBlock(type: string) {
288
+ const newBlock: BlockValue = {
289
+ id: crypto.randomUUID(),
290
+ type,
291
+ data: {},
292
+ };
293
+ const newId = newBlock.id;
294
+ onChange?.([...value, newBlock]);
295
+ setExpandedIds((prev) => new Set(prev).add(newId));
296
+ setPickerOpen(false);
297
+ }
298
+
299
+ function removeBlock(id: string) {
300
+ onChange?.(value.filter((b) => b.id !== id));
301
+ setExpandedIds((prev) => {
302
+ const next = new Set(prev);
303
+ next.delete(id);
304
+ return next;
305
+ });
306
+ }
307
+
308
+ function toggleExpand(id: string) {
309
+ setExpandedIds((prev) => {
310
+ const next = new Set(prev);
311
+ if (next.has(id)) {
312
+ next.delete(id);
313
+ } else {
314
+ next.add(id);
315
+ }
316
+ return next;
317
+ });
318
+ }
319
+
320
+ function updateBlockField(blockId: string, fieldName: string, fieldValue: unknown) {
321
+ onChange?.(
322
+ value.map((b) =>
323
+ b.id === blockId
324
+ ? { ...b, data: { ...b.data, [fieldName]: fieldValue } }
325
+ : b,
326
+ ),
327
+ );
328
+ }
329
+
330
+ const blockIds = value.map((b) => b.id);
331
+
332
+ return (
333
+ <div>
334
+ {label && (
335
+ <label className="mb-2 block text-sm font-medium">{label}</label>
336
+ )}
337
+
338
+ <DndContext
339
+ sensors={sensors}
340
+ collisionDetection={closestCenter}
341
+ onDragEnd={handleDragEnd}
342
+ >
343
+ <SortableContext items={blockIds} strategy={verticalListSortingStrategy}>
344
+ <div className="space-y-2">
345
+ {value.map((block) => (
346
+ <SortableBlock
347
+ key={block.id}
348
+ block={block}
349
+ blockDef={blockDefMap.get(block.type)}
350
+ expanded={expandedIds.has(block.id)}
351
+ onToggle={() => toggleExpand(block.id)}
352
+ onRemove={() => removeBlock(block.id)}
353
+ onFieldChange={(name, val) =>
354
+ updateBlockField(block.id, name, val)
355
+ }
356
+ />
357
+ ))}
358
+ </div>
359
+ </SortableContext>
360
+ </DndContext>
361
+
362
+ {value.length === 0 && (
363
+ <div className="rounded-md border border-dashed border-[var(--border)] px-4 py-8 text-center text-sm text-[var(--muted-foreground)]">
364
+ No blocks added yet. Click &ldquo;Add Block&rdquo; to get started.
365
+ </div>
366
+ )}
367
+
368
+ <div className="relative mt-3">
369
+ <button
370
+ type="button"
371
+ onClick={() => setPickerOpen((prev) => !prev)}
372
+ className="inline-flex items-center gap-1.5 rounded-md border border-dashed border-[var(--border)] px-3 py-2 text-sm text-[var(--muted-foreground)] transition-colors hover:border-[var(--foreground)] hover:text-[var(--foreground)]"
373
+ >
374
+ <Plus className="h-4 w-4" />
375
+ Add Block
376
+ </button>
377
+ </div>
378
+
379
+ {pickerOpen && (
380
+ <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
381
+ <div className="w-full max-w-2xl rounded-lg border border-[var(--border)] bg-[var(--card)] shadow-xl">
382
+ <div className="flex items-center justify-between border-b border-[var(--border)] px-5 py-4">
383
+ <h3 className="text-base font-semibold">Add Block</h3>
384
+ <button
385
+ type="button"
386
+ onClick={() => setPickerOpen(false)}
387
+ className="text-[var(--muted-foreground)] hover:text-[var(--foreground)]"
388
+ aria-label="Close"
389
+ >
390
+ <X className="h-5 w-5" />
391
+ </button>
392
+ </div>
393
+ <div className="grid grid-cols-2 gap-3 p-5 sm:grid-cols-3">
394
+ {allBlockTypes.map((bt) => {
395
+ const Icon = BLOCK_ICONS[bt.icon];
396
+ return (
397
+ <button
398
+ key={bt.type}
399
+ type="button"
400
+ onClick={() => addBlock(bt.type)}
401
+ className="flex flex-col items-start gap-1.5 rounded-md border border-[var(--border)] p-3 text-left transition-colors hover:border-[var(--primary)] hover:bg-[var(--accent)]"
402
+ >
403
+ {Icon && <Icon className="h-5 w-5 text-[var(--primary)]" />}
404
+ <span className="text-sm font-medium">{bt.label}</span>
405
+ <span className="text-xs leading-snug text-[var(--muted-foreground)]">
406
+ {bt.description}
407
+ </span>
408
+ </button>
409
+ );
410
+ })}
411
+ </div>
412
+ </div>
413
+ </div>
414
+ )}
415
+
416
+ {helpText && (
417
+ <p className="mt-1 text-xs text-[var(--muted-foreground)]">{helpText}</p>
418
+ )}
419
+ </div>
420
+ );
421
+ }
@@ -0,0 +1,41 @@
1
+ 'use client';
2
+
3
+ export interface DateFieldProps {
4
+ label: string;
5
+ value?: string;
6
+ onChange: (value: string) => void;
7
+ required?: boolean;
8
+ helpText?: string;
9
+ }
10
+
11
+ export function DateField({ label, value = '', onChange, required, helpText }: DateFieldProps) {
12
+ return (
13
+ <div>
14
+ <label className="mb-1 block text-sm font-medium">
15
+ {label}
16
+ {required && <span className="ml-0.5 text-[var(--destructive)]">*</span>}
17
+ </label>
18
+ <div className="relative">
19
+ <input
20
+ type="date"
21
+ value={value}
22
+ onChange={(e) => onChange(e.target.value)}
23
+ required={required}
24
+ className="w-full rounded-md border border-[var(--border)] bg-[var(--input-background)] px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-[var(--ring)]"
25
+ />
26
+ <button
27
+ type="button"
28
+ className="absolute right-2 top-1/2 -translate-y-1/2 text-[var(--muted-foreground)]"
29
+ aria-label="Open calendar"
30
+ >
31
+ <svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
32
+ <path strokeLinecap="round" strokeLinejoin="round" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
33
+ </svg>
34
+ </button>
35
+ </div>
36
+ {helpText && (
37
+ <p className="mt-1 text-xs text-[var(--muted-foreground)]">{helpText}</p>
38
+ )}
39
+ </div>
40
+ );
41
+ }
@@ -0,0 +1,84 @@
1
+ 'use client';
2
+
3
+ import { TextField } from './TextField.js';
4
+ import { RichTextField } from './RichTextField.js';
5
+ import { SlugField } from './SlugField.js';
6
+ import { SelectField } from './SelectField.js';
7
+ import { MediaField } from './MediaField.js';
8
+ import { RelationshipField } from './RelationshipField.js';
9
+ import { DateField } from './DateField.js';
10
+ import { ToggleField } from './ToggleField.js';
11
+ import { ArrayField } from './ArrayField.js';
12
+ import { BlockBuilderField } from './BlockBuilderField.js';
13
+ import { GroupField } from './GroupField.js';
14
+ import { NavBuilderField } from './NavBuilderField.js';
15
+ import { NumberField } from './NumberField.js';
16
+
17
+ export interface FieldDefinition {
18
+ name: string;
19
+ type: string;
20
+ label: string;
21
+ required?: boolean;
22
+ maxLength?: number;
23
+ helpText?: string;
24
+ options?: string[];
25
+ min?: number;
26
+ max?: number;
27
+ from?: string;
28
+ multi?: boolean;
29
+ fields?: FieldDefinition[];
30
+ blocks?: any[];
31
+ }
32
+
33
+ export interface FieldRendererProps {
34
+ field: FieldDefinition;
35
+ value: any;
36
+ onChange: (value: any) => void;
37
+ }
38
+
39
+ const FIELD_MAP: Record<string, React.ComponentType<any>> = {
40
+ text: TextField,
41
+ richText: RichTextField,
42
+ slug: SlugField,
43
+ select: SelectField,
44
+ media: MediaField,
45
+ relationship: RelationshipField,
46
+ date: DateField,
47
+ toggle: ToggleField,
48
+ array: ArrayField,
49
+ blocks: BlockBuilderField,
50
+ group: GroupField,
51
+ nav: NavBuilderField,
52
+ number: NumberField,
53
+ };
54
+
55
+ export function FieldRenderer({ field, value, onChange }: FieldRendererProps) {
56
+ const Component = FIELD_MAP[field.type];
57
+
58
+ if (!Component) {
59
+ return (
60
+ <div className="rounded-md border border-[var(--destructive)] bg-red-50 p-3 text-sm text-[var(--destructive)]">
61
+ Unknown field type: <code>{field.type}</code>
62
+ </div>
63
+ );
64
+ }
65
+
66
+ return (
67
+ <Component
68
+ label={field.label}
69
+ value={value}
70
+ onChange={onChange}
71
+ required={field.required}
72
+ maxLength={field.maxLength}
73
+ helpText={field.helpText}
74
+ options={field.options}
75
+ min={field.min}
76
+ max={field.max}
77
+ from={field.from}
78
+ multi={field.multi}
79
+ fields={field.fields}
80
+ blocks={field.blocks}
81
+ name={field.name}
82
+ />
83
+ );
84
+ }
@@ -0,0 +1,41 @@
1
+ 'use client';
2
+
3
+ import { useState, type ReactNode } from 'react';
4
+
5
+ export interface GroupFieldProps {
6
+ label: string;
7
+ children?: ReactNode;
8
+ defaultOpen?: boolean;
9
+ }
10
+
11
+ export function GroupField({ label, children, defaultOpen = true }: GroupFieldProps) {
12
+ const [open, setOpen] = useState(defaultOpen);
13
+
14
+ return (
15
+ <div className="rounded-md border border-[var(--border)]">
16
+ <button
17
+ type="button"
18
+ onClick={() => setOpen((o) => !o)}
19
+ className="flex w-full items-center justify-between px-4 py-3 text-sm font-medium hover:bg-[var(--accent)]"
20
+ >
21
+ <span>{label}</span>
22
+ <svg
23
+ className={`h-4 w-4 text-[var(--muted-foreground)] transition-transform ${open ? 'rotate-180' : ''}`}
24
+ fill="none"
25
+ viewBox="0 0 24 24"
26
+ stroke="currentColor"
27
+ strokeWidth={2}
28
+ >
29
+ <path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7" />
30
+ </svg>
31
+ </button>
32
+ {open && (
33
+ <div className="border-t border-[var(--border)] p-4">
34
+ {children ?? (
35
+ <p className="text-sm text-[var(--muted-foreground)]">Group fields area</p>
36
+ )}
37
+ </div>
38
+ )}
39
+ </div>
40
+ );
41
+ }
@@ -0,0 +1,48 @@
1
+ 'use client';
2
+
3
+ export interface MediaFieldProps {
4
+ label: string;
5
+ value?: { id: string; filename: string; url: string } | null;
6
+ onChange: (value: any) => void;
7
+ required?: boolean;
8
+ helpText?: string;
9
+ }
10
+
11
+ export function MediaField({ label, value, onChange, required, helpText }: MediaFieldProps) {
12
+ return (
13
+ <div>
14
+ <label className="mb-1 block text-sm font-medium">
15
+ {label}
16
+ {required && <span className="ml-0.5 text-[var(--destructive)]">*</span>}
17
+ </label>
18
+
19
+ {value ? (
20
+ <div className="flex items-center gap-3 rounded-md border border-[var(--border)] p-3">
21
+ <div className="h-16 w-16 shrink-0 rounded-md bg-[var(--muted)]" />
22
+ <div className="flex-1">
23
+ <p className="text-sm font-medium">{value.filename}</p>
24
+ <button
25
+ type="button"
26
+ onClick={() => onChange(null)}
27
+ className="mt-1 text-xs text-[var(--destructive)] hover:underline"
28
+ >
29
+ Remove
30
+ </button>
31
+ </div>
32
+ </div>
33
+ ) : (
34
+ <button
35
+ type="button"
36
+ onClick={() => onChange({ id: 'mock', filename: 'placeholder.jpg', url: '' })}
37
+ className="flex w-full items-center justify-center rounded-md border-2 border-dashed border-[var(--border)] p-6 text-sm text-[var(--muted-foreground)] hover:border-[var(--primary)] hover:text-[var(--foreground)]"
38
+ >
39
+ <span>Click to select media</span>
40
+ </button>
41
+ )}
42
+
43
+ {helpText && (
44
+ <p className="mt-1 text-xs text-[var(--muted-foreground)]">{helpText}</p>
45
+ )}
46
+ </div>
47
+ );
48
+ }