@actuate-media/cms-admin 0.1.4 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -21
- package/dist/AdminRoot.d.ts.map +1 -1
- package/dist/AdminRoot.js +17 -11
- package/dist/AdminRoot.js.map +1 -1
- package/dist/actuate-admin.css +2 -0
- package/dist/components/TipTapEditor.js +78 -78
- package/dist/lib/useApiData.d.ts +8 -1
- package/dist/lib/useApiData.d.ts.map +1 -1
- package/dist/lib/useApiData.js +39 -7
- package/dist/lib/useApiData.js.map +1 -1
- package/dist/views/Dashboard.d.ts +2 -1
- package/dist/views/Dashboard.d.ts.map +1 -1
- package/dist/views/Dashboard.js +52 -7
- package/dist/views/Dashboard.js.map +1 -1
- package/package.json +10 -5
- package/src/AdminRoot.tsx +312 -0
- package/src/__tests__/lib/search.test.ts +138 -0
- package/src/__tests__/lib/utils.test.ts +19 -0
- package/src/__tests__/router/match-route.test.ts +47 -0
- package/src/__tests__/router/strip-base.test.ts +30 -0
- package/src/components/Breadcrumbs.tsx +92 -0
- package/src/components/CommandPalette.tsx +384 -0
- package/src/components/ErrorBoundary.tsx +52 -0
- package/src/components/FocalPointPicker.tsx +54 -0
- package/src/components/FolderTree.tsx +427 -0
- package/src/components/LivePreview.tsx +136 -0
- package/src/components/LocaleProvider.tsx +51 -0
- package/src/components/LocaleSwitcher.tsx +51 -0
- package/src/components/MediaPickerModal.tsx +183 -0
- package/src/components/PresenceIndicator.tsx +71 -0
- package/src/components/SEOPanel.tsx +767 -0
- package/src/components/ThemeProvider.tsx +98 -0
- package/src/components/TipTapEditor.tsx +469 -0
- package/src/components/VersionHistory.tsx +167 -0
- package/src/components/ui/Avatar.tsx +42 -0
- package/src/components/ui/Badge.tsx +25 -0
- package/src/components/ui/Button.tsx +52 -0
- package/src/components/ui/CommandPalette.tsx +119 -0
- package/src/components/ui/ConfirmDialog.tsx +52 -0
- package/src/components/ui/DataTable.tsx +194 -0
- package/src/components/ui/EmptyState.tsx +29 -0
- package/src/components/ui/Modal.tsx +48 -0
- package/src/components/ui/Pagination.tsx +79 -0
- package/src/components/ui/SearchInput.tsx +44 -0
- package/src/components/ui/Skeleton.tsx +48 -0
- package/src/components/ui/Toast.tsx +66 -0
- package/src/components/ui/index.ts +24 -0
- package/src/fields/ArrayField.tsx +92 -0
- package/src/fields/BlockBuilderField.tsx +421 -0
- package/src/fields/DateField.tsx +41 -0
- package/src/fields/FieldRenderer.tsx +84 -0
- package/src/fields/GroupField.tsx +41 -0
- package/src/fields/MediaField.tsx +48 -0
- package/src/fields/NavBuilderField.tsx +78 -0
- package/src/fields/NumberField.tsx +45 -0
- package/src/fields/RelationshipField.tsx +245 -0
- package/src/fields/RichTextField.tsx +26 -0
- package/src/fields/SelectField.tsx +117 -0
- package/src/fields/SlugField.tsx +65 -0
- package/src/fields/TextField.tsx +48 -0
- package/src/fields/ToggleField.tsx +36 -0
- package/src/fields/block-types.ts +95 -0
- package/src/fields/index.ts +17 -0
- package/src/hooks/useContentLock.ts +52 -0
- package/src/hooks/useDebounce.ts +14 -0
- package/src/hooks/useKeyboardShortcuts.ts +32 -0
- package/src/index.ts +55 -0
- package/src/layout/Header.tsx +135 -0
- package/src/layout/Layout.tsx +77 -0
- package/src/layout/Sidebar.tsx +216 -0
- package/src/lib/api.ts +67 -0
- package/src/lib/search.ts +59 -0
- package/src/lib/useApiData.ts +95 -0
- package/src/lib/utils.ts +6 -0
- package/src/router/index.ts +81 -0
- package/src/styles/build-input.css +11 -0
- package/src/styles/tailwind.css +11 -6
- package/src/styles/theme.css +182 -181
- package/src/views/CollectionList.tsx +270 -0
- package/src/views/Dashboard.tsx +300 -0
- package/src/views/DocumentEdit.tsx +377 -0
- package/src/views/FormEditor.tsx +533 -0
- package/src/views/FormSubmissions.tsx +316 -0
- package/src/views/Forms.tsx +106 -0
- package/src/views/Login.tsx +322 -0
- package/src/views/MediaBrowser.tsx +774 -0
- package/src/views/PageEditor.tsx +192 -0
- package/src/views/Pages.tsx +354 -0
- package/src/views/PostEditor.tsx +251 -0
- package/src/views/Posts.tsx +243 -0
- package/src/views/Redirects.tsx +293 -0
- package/src/views/SEO.tsx +458 -0
- package/src/views/Settings.tsx +811 -0
- package/src/views/SetupWizard.tsx +207 -0
- 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 “Add Block” 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
|
+
}
|