@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.
- package/README.md +28 -0
- package/admin/components/AdminCard.astro +25 -0
- package/admin/components/AiGenerateButton.tsx +102 -0
- package/admin/components/AssetsGrid.tsx +711 -0
- package/admin/components/BlockEditor.tsx +996 -0
- package/admin/components/CheckboxField.tsx +31 -0
- package/admin/components/DocumentActions.tsx +317 -0
- package/admin/components/DocumentLock.tsx +54 -0
- package/admin/components/DocumentsDataTable.tsx +804 -0
- package/admin/components/FieldControl.astro +397 -0
- package/admin/components/FocalPointSelector.tsx +100 -0
- package/admin/components/ImageBrowseDialog.tsx +176 -0
- package/admin/components/ImagePicker.tsx +149 -0
- package/admin/components/InternalLinkPicker.tsx +80 -0
- package/admin/components/LiveHeading.tsx +17 -0
- package/admin/components/MobileSidebar.tsx +29 -0
- package/admin/components/RelationField.tsx +204 -0
- package/admin/components/RichTextEditor.tsx +685 -0
- package/admin/components/SelectField.tsx +65 -0
- package/admin/components/SidebarUserMenu.tsx +99 -0
- package/admin/components/SlugField.tsx +77 -0
- package/admin/components/TaxonomySelect.tsx +52 -0
- package/admin/components/Toast.astro +40 -0
- package/admin/components/TreeItemsEditor.tsx +790 -0
- package/admin/components/TreeSelect.tsx +166 -0
- package/admin/components/UnsavedGuard.tsx +181 -0
- package/admin/components/tree-utils.ts +86 -0
- package/admin/components/ui/alert-dialog.tsx +92 -0
- package/admin/components/ui/badge.tsx +83 -0
- package/admin/components/ui/button.tsx +53 -0
- package/admin/components/ui/card.tsx +70 -0
- package/admin/components/ui/checkbox.tsx +28 -0
- package/admin/components/ui/collapsible.tsx +26 -0
- package/admin/components/ui/command.tsx +88 -0
- package/admin/components/ui/dialog.tsx +92 -0
- package/admin/components/ui/dropdown-menu.tsx +259 -0
- package/admin/components/ui/input.tsx +20 -0
- package/admin/components/ui/label.tsx +20 -0
- package/admin/components/ui/popover.tsx +42 -0
- package/admin/components/ui/select.tsx +165 -0
- package/admin/components/ui/separator.tsx +21 -0
- package/admin/components/ui/sheet.tsx +104 -0
- package/admin/components/ui/skeleton.tsx +7 -0
- package/admin/components/ui/table.tsx +74 -0
- package/admin/components/ui/textarea.tsx +18 -0
- package/admin/components/ui/tooltip.tsx +52 -0
- package/admin/layouts/AdminLayout.astro +340 -0
- package/admin/lib/utils.ts +19 -0
- package/dist/admin.js +92 -0
- package/dist/ai.js +67 -0
- package/dist/api.js +827 -0
- package/dist/assets.js +163 -0
- package/dist/auth.js +132 -0
- package/dist/blocks.js +110 -0
- package/dist/content.js +29 -0
- package/dist/create-admin.js +23 -0
- package/dist/define.js +36 -0
- package/dist/generator.js +370 -0
- package/dist/image.js +69 -0
- package/dist/index.js +16 -0
- package/dist/integration.js +256 -0
- package/dist/locks.js +37 -0
- package/dist/richtext.js +1 -0
- package/dist/runtime.js +26 -0
- package/dist/schema.js +13 -0
- package/dist/seed.js +84 -0
- package/dist/values.js +102 -0
- package/middleware/auth.ts +100 -0
- package/package.json +102 -0
- package/routes/api/cms/[collection]/[...path].ts +366 -0
- package/routes/api/cms/ai/alt-text.ts +25 -0
- package/routes/api/cms/ai/seo.ts +25 -0
- package/routes/api/cms/ai/translate.ts +31 -0
- package/routes/api/cms/assets/[id].ts +82 -0
- package/routes/api/cms/assets/folders.ts +81 -0
- package/routes/api/cms/assets/index.ts +23 -0
- package/routes/api/cms/assets/upload.ts +112 -0
- package/routes/api/cms/auth/invite.ts +166 -0
- package/routes/api/cms/auth/login.ts +124 -0
- package/routes/api/cms/auth/logout.ts +33 -0
- package/routes/api/cms/auth/setup.ts +77 -0
- package/routes/api/cms/cron/publish.ts +33 -0
- package/routes/api/cms/img/[...path].ts +24 -0
- package/routes/api/cms/locks/[...path].ts +37 -0
- package/routes/api/cms/preview/render.ts +36 -0
- package/routes/api/cms/references/[collection]/[id].ts +60 -0
- package/routes/pages/admin/[...path].astro +1104 -0
- package/routes/pages/admin/assets/[id].astro +183 -0
- package/routes/pages/admin/assets/index.astro +58 -0
- package/routes/pages/admin/invite.astro +116 -0
- package/routes/pages/admin/login.astro +57 -0
- package/routes/pages/admin/setup.astro +91 -0
- 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
|
+
}
|