@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,790 @@
1
+ "use client";
2
+
3
+ import * as React from "react";
4
+ import {
5
+ Check,
6
+ ChevronRight,
7
+ ChevronsDownUp,
8
+ ChevronsUpDown,
9
+ GripVertical,
10
+ Indent,
11
+ Outdent,
12
+ Pencil,
13
+ Plus,
14
+ Trash2,
15
+ X,
16
+ } from "lucide-react";
17
+ import {
18
+ DndContext,
19
+ DragOverlay,
20
+ closestCenter,
21
+ PointerSensor,
22
+ useSensor,
23
+ useSensors,
24
+ type DragStartEvent,
25
+ type DragEndEvent,
26
+ } from "@dnd-kit/core";
27
+ import { SortableContext, useSortable, verticalListSortingStrategy, arrayMove } from "@dnd-kit/sortable";
28
+ import { Button } from "./ui/button";
29
+ import { Input } from "./ui/input";
30
+ import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from "./ui/select";
31
+ import { Collapsible, CollapsibleTrigger, CollapsibleContent } from "./ui/collapsible";
32
+ import { cn } from "../lib/utils";
33
+ import InternalLinkPicker, { type LinkOptionGroup } from "./InternalLinkPicker";
34
+ import {
35
+ type TreeItem,
36
+ generateId,
37
+ slugify,
38
+ parseItems,
39
+ cloneItems,
40
+ createBlankItem,
41
+ getItemLabel,
42
+ getItemSublabel,
43
+ findItemById,
44
+ findParentList,
45
+ findItemDepth,
46
+ canIndent,
47
+ canOutdent,
48
+ } from "./tree-utils";
49
+
50
+ // --- Types ---
51
+
52
+ type EditState = {
53
+ id: string;
54
+ label: string;
55
+ href: string;
56
+ target: string;
57
+ linkType: "external" | "internal";
58
+ name: string;
59
+ slug: string;
60
+ autoSlug: boolean;
61
+ };
62
+
63
+ type Props = {
64
+ name: string;
65
+ value?: string;
66
+ variant: "menu" | "taxonomy";
67
+ label?: string;
68
+ linkOptions?: LinkOptionGroup[];
69
+ };
70
+
71
+ function blankEdit(id: string): EditState {
72
+ return { id, label: "", href: "", target: "", linkType: "internal", name: "", slug: "", autoSlug: true };
73
+ }
74
+
75
+ // --- Sortable wrapper ---
76
+
77
+ function SortableTreeItem({
78
+ id,
79
+ disabled,
80
+ children,
81
+ }: {
82
+ id: string;
83
+ disabled?: boolean;
84
+ children: (props: {
85
+ attributes: ReturnType<typeof useSortable>["attributes"];
86
+ listeners: ReturnType<typeof useSortable>["listeners"];
87
+ setNodeRef: (node: HTMLElement | null) => void;
88
+ setActivatorNodeRef: (node: HTMLElement | null) => void;
89
+ style: React.CSSProperties;
90
+ isDragging: boolean;
91
+ }) => React.ReactNode;
92
+ }) {
93
+ const { attributes, listeners, setNodeRef, setActivatorNodeRef, transform, transition, isDragging } = useSortable({
94
+ id,
95
+ disabled,
96
+ });
97
+
98
+ const style: React.CSSProperties = {
99
+ transform: transform ? `translate3d(${Math.round(transform.x)}px, ${Math.round(transform.y)}px, 0)` : undefined,
100
+ transition,
101
+ };
102
+
103
+ return <>{children({ attributes, listeners, setNodeRef, setActivatorNodeRef, style, isDragging })}</>;
104
+ }
105
+
106
+ // --- Main component ---
107
+
108
+ export default function TreeItemsEditor({ name, value, variant, label, linkOptions = [] }: Props) {
109
+ const [items, setItems] = React.useState<TreeItem[]>(() => parseItems(value));
110
+ const [editing, setEditing] = React.useState<EditState | null>(null);
111
+ const [activeId, setActiveId] = React.useState<string | null>(null);
112
+ const newItemIds = React.useRef(new Set<string>());
113
+ const hiddenRef = React.useRef<HTMLInputElement>(null);
114
+
115
+ const updateEditing = (patch: Partial<EditState>) => setEditing((prev) => (prev ? { ...prev, ...patch } : prev));
116
+
117
+ const [expandedIds, setExpandedIds] = React.useState<Set<string>>(() => {
118
+ const ids = new Set<string>();
119
+ const collectIds = (list: TreeItem[]) => {
120
+ for (const item of list) {
121
+ if (item.children.length > 0) ids.add(item.id);
122
+ collectIds(item.children);
123
+ }
124
+ };
125
+ collectIds(parseItems(value));
126
+ return ids;
127
+ });
128
+
129
+ // Serialized value includes any pending inline edit
130
+ const serialized = React.useMemo(() => {
131
+ if (!editing) return JSON.stringify(items);
132
+ const merged = cloneItems(items);
133
+ const apply = (list: TreeItem[]) => {
134
+ for (const item of list) {
135
+ if (item.id === editing.id) {
136
+ if (variant === "menu") {
137
+ item.label = editing.label;
138
+ item.href = editing.href;
139
+ item.target = editing.target || undefined;
140
+ } else {
141
+ item.name = editing.name;
142
+ item.slug = editing.slug || slugify(editing.name);
143
+ }
144
+ return;
145
+ }
146
+ apply(item.children);
147
+ }
148
+ };
149
+ apply(merged);
150
+ return JSON.stringify(merged);
151
+ }, [items, editing, variant]);
152
+
153
+ // Notify form of changes so UnsavedGuard can detect them
154
+ React.useEffect(() => {
155
+ hiddenRef.current?.dispatchEvent(new Event("change", { bubbles: true }));
156
+ }, [serialized]);
157
+
158
+ // --- Tree operations ---
159
+
160
+ const allParentIds = React.useMemo(() => {
161
+ const ids = new Set<string>();
162
+ const collect = (list: TreeItem[]) => {
163
+ for (const item of list) {
164
+ if (item.children.length > 0) {
165
+ ids.add(item.id);
166
+ collect(item.children);
167
+ }
168
+ }
169
+ };
170
+ collect(items);
171
+ return ids;
172
+ }, [items]);
173
+
174
+ const hasExpandableItems = allParentIds.size > 0;
175
+ const allExpanded = hasExpandableItems && [...allParentIds].every((id) => expandedIds.has(id));
176
+
177
+ const toggleExpandAll = () => {
178
+ setExpandedIds(allExpanded ? new Set() : new Set(allParentIds));
179
+ };
180
+
181
+ const toggleExpand = (id: string) => {
182
+ setExpandedIds((prev) => {
183
+ const next = new Set(prev);
184
+ if (next.has(id)) next.delete(id);
185
+ else next.add(id);
186
+ return next;
187
+ });
188
+ };
189
+
190
+ const removeItem = (id: string) => {
191
+ setItems((prev) => {
192
+ const next = cloneItems(prev);
193
+ const remove = (list: TreeItem[]): TreeItem[] =>
194
+ list.filter((item) => {
195
+ if (item.id === id) return false;
196
+ item.children = remove(item.children);
197
+ return true;
198
+ });
199
+ return remove(next);
200
+ });
201
+ if (editing?.id === id) setEditing(null);
202
+ };
203
+
204
+ const saveEdit = () => {
205
+ if (!editing) return;
206
+ newItemIds.current.delete(editing.id);
207
+ setItems((prev) => {
208
+ const next = cloneItems(prev);
209
+ const update = (list: TreeItem[]) => {
210
+ for (const item of list) {
211
+ if (item.id === editing.id) {
212
+ if (variant === "menu") {
213
+ item.label = editing.label;
214
+ item.href = editing.href;
215
+ item.target = editing.target || undefined;
216
+ } else {
217
+ item.name = editing.name;
218
+ item.slug = editing.slug || slugify(editing.name);
219
+ }
220
+ return;
221
+ }
222
+ update(item.children);
223
+ }
224
+ };
225
+ update(next);
226
+ return next;
227
+ });
228
+ setEditing(null);
229
+ };
230
+
231
+ const cancelEdit = () => {
232
+ if (editing && newItemIds.current.has(editing.id)) {
233
+ removeItem(editing.id);
234
+ newItemIds.current.delete(editing.id);
235
+ }
236
+ setEditing(null);
237
+ };
238
+
239
+ const saveOrDiscardEdit = () => {
240
+ if (!editing) return;
241
+ const isEmpty = variant === "menu" ? !editing.label.trim() && !editing.href.trim() : !editing.name.trim();
242
+ if (isEmpty) {
243
+ removeItem(editing.id);
244
+ newItemIds.current.delete(editing.id);
245
+ setEditing(null);
246
+ } else {
247
+ saveEdit();
248
+ }
249
+ };
250
+
251
+ const startEditNewItem = (id: string) => {
252
+ newItemIds.current.add(id);
253
+ setEditing(blankEdit(id));
254
+ };
255
+
256
+ const addRootItem = () => {
257
+ saveOrDiscardEdit();
258
+ const newItem = createBlankItem();
259
+ setItems((prev) => [...prev, newItem]);
260
+ startEditNewItem(newItem.id);
261
+ };
262
+
263
+ const addChildItem = (parentId: string) => {
264
+ saveOrDiscardEdit();
265
+ const newItem = createBlankItem();
266
+ setItems((prev) => {
267
+ const next = cloneItems(prev);
268
+ const addToParent = (list: TreeItem[]): boolean => {
269
+ for (const item of list) {
270
+ if (item.id === parentId) {
271
+ item.children.push(newItem);
272
+ return true;
273
+ }
274
+ if (addToParent(item.children)) return true;
275
+ }
276
+ return false;
277
+ };
278
+ addToParent(next);
279
+ setExpandedIds((prev) => new Set([...prev, parentId]));
280
+ return next;
281
+ });
282
+ startEditNewItem(newItem.id);
283
+ };
284
+
285
+ const startEdit = (item: TreeItem) => {
286
+ if (variant === "menu") {
287
+ const isInternal = linkOptions.some((group) => group.items.some((li) => li.href === String(item.href ?? "")));
288
+ setEditing({
289
+ ...blankEdit(item.id),
290
+ label: String(item.label ?? ""),
291
+ href: String(item.href ?? ""),
292
+ target: String(item.target ?? ""),
293
+ linkType: isInternal ? "internal" : "external",
294
+ });
295
+ } else {
296
+ setEditing({
297
+ ...blankEdit(item.id),
298
+ name: String(item.name ?? ""),
299
+ slug: String(item.slug ?? ""),
300
+ autoSlug: false,
301
+ });
302
+ }
303
+ };
304
+
305
+ const indentItem = (id: string) => {
306
+ setItems((prev) => {
307
+ const next = cloneItems(prev);
308
+ const doIndent = (list: TreeItem[]): boolean => {
309
+ for (let i = 0; i < list.length; i++) {
310
+ if (list[i].id === id && i > 0) {
311
+ const [item] = list.splice(i, 1);
312
+ list[i - 1].children.push(item);
313
+ setExpandedIds((prev) => new Set([...prev, list[i - 1].id]));
314
+ return true;
315
+ }
316
+ if (doIndent(list[i].children)) return true;
317
+ }
318
+ return false;
319
+ };
320
+ doIndent(next);
321
+ return next;
322
+ });
323
+ };
324
+
325
+ const outdentItem = (id: string) => {
326
+ setItems((prev) => {
327
+ const next = cloneItems(prev);
328
+ const doOutdent = (list: TreeItem[], parentList: TreeItem[] | null): boolean => {
329
+ for (let i = 0; i < list.length; i++) {
330
+ if (list[i].id === id && parentList) {
331
+ const [item] = list.splice(i, 1);
332
+ const parentIdx = parentList.findIndex((p) => p.children === list);
333
+ if (parentIdx >= 0) {
334
+ parentList.splice(parentIdx + 1, 0, item);
335
+ return true;
336
+ }
337
+ }
338
+ if (doOutdent(list[i].children, list)) return true;
339
+ }
340
+ return false;
341
+ };
342
+ doOutdent(next, null);
343
+ return next;
344
+ });
345
+ };
346
+
347
+ const editKeyHandler = (e: React.KeyboardEvent) => {
348
+ if (e.key === "Enter") saveEdit();
349
+ if (e.key === "Escape") cancelEdit();
350
+ };
351
+
352
+ // --- Drag and drop ---
353
+
354
+ const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 5 } }));
355
+
356
+ const handleDragStart = React.useCallback((event: DragStartEvent) => {
357
+ setActiveId(String(event.active.id));
358
+ }, []);
359
+
360
+ const handleDragEnd = React.useCallback((event: DragEndEvent) => {
361
+ setActiveId(null);
362
+ const { active, over } = event;
363
+ if (!over || active.id === over.id) return;
364
+
365
+ setItems((prev) => {
366
+ const next = cloneItems(prev);
367
+ const activeSiblings = findParentList(next, String(active.id));
368
+ const overSiblings = findParentList(next, String(over.id));
369
+ if (!activeSiblings || !overSiblings || activeSiblings !== overSiblings) return prev;
370
+
371
+ const oldIndex = activeSiblings.findIndex((item) => item.id === active.id);
372
+ const newIndex = activeSiblings.findIndex((item) => item.id === over.id);
373
+ if (oldIndex === -1 || newIndex === -1) return prev;
374
+
375
+ const reordered = arrayMove(activeSiblings, oldIndex, newIndex);
376
+ activeSiblings.splice(0, activeSiblings.length, ...reordered);
377
+ return next;
378
+ });
379
+ }, []);
380
+
381
+ const handleDragCancel = React.useCallback(() => {
382
+ setActiveId(null);
383
+ }, []);
384
+
385
+ const isAncestorOfActive = React.useCallback(
386
+ (item: TreeItem): boolean => {
387
+ if (!activeId) return false;
388
+ const check = (children: TreeItem[]): boolean => {
389
+ for (const child of children) {
390
+ if (child.id === activeId) return true;
391
+ if (check(child.children)) return true;
392
+ }
393
+ return false;
394
+ };
395
+ return check(item.children);
396
+ },
397
+ [activeId],
398
+ );
399
+
400
+ const activeItem = activeId ? findItemById(items, activeId) : null;
401
+ const activeDepth = activeId ? findItemDepth(items, activeId) : 0;
402
+
403
+ // Collapse siblings of dragged item during drag for uniform height
404
+ const activeSiblingIds = React.useMemo(() => {
405
+ if (!activeId) return null;
406
+ const parentList = findParentList(items, activeId);
407
+ if (!parentList) return null;
408
+ return new Set(parentList.map((item) => item.id));
409
+ }, [activeId, items]);
410
+
411
+ // --- Edit fields ---
412
+
413
+ const renderEditFields = () => {
414
+ if (!editing) return null;
415
+ if (variant === "menu") {
416
+ return (
417
+ <div className="flex min-w-0 flex-1 items-center gap-2">
418
+ <Input
419
+ value={editing.label}
420
+ onChange={(e) => updateEditing({ label: e.target.value })}
421
+ placeholder="Label"
422
+ className="h-7 min-w-0 flex-3 text-sm"
423
+ autoFocus
424
+ onKeyDown={editKeyHandler}
425
+ />
426
+ <Select
427
+ items={[
428
+ { value: "external", label: "External link" },
429
+ { value: "internal", label: "Internal link" },
430
+ ]}
431
+ value={editing.linkType}
432
+ onValueChange={(v) => {
433
+ const newType = (v as "external" | "internal") ?? "external";
434
+ updateEditing({ linkType: newType, ...(newType === "internal" ? { href: "" } : {}) });
435
+ }}
436
+ >
437
+ <SelectTrigger className="h-7! min-w-0 flex-2 text-sm">
438
+ <SelectValue placeholder="Link type" />
439
+ </SelectTrigger>
440
+ <SelectContent>
441
+ <SelectGroup>
442
+ <SelectItem value="external">External link</SelectItem>
443
+ <SelectItem value="internal">Internal link</SelectItem>
444
+ </SelectGroup>
445
+ </SelectContent>
446
+ </Select>
447
+ {editing.linkType === "external" ? (
448
+ <Input
449
+ value={editing.href}
450
+ onChange={(e) => updateEditing({ href: e.target.value })}
451
+ placeholder="https://..."
452
+ className="h-7 min-w-0 flex-3 text-sm"
453
+ onKeyDown={editKeyHandler}
454
+ />
455
+ ) : (
456
+ <InternalLinkPicker
457
+ editHref={editing.href}
458
+ linkOptions={linkOptions}
459
+ onSelect={(item) => {
460
+ updateEditing({ href: item.href, ...(!editing.label ? { label: item.label } : {}) });
461
+ }}
462
+ />
463
+ )}
464
+ </div>
465
+ );
466
+ }
467
+ return (
468
+ <>
469
+ <Input
470
+ value={editing.name}
471
+ onChange={(e) => {
472
+ const name = e.target.value;
473
+ updateEditing({ name, ...(editing.autoSlug ? { slug: slugify(name) } : {}) });
474
+ }}
475
+ placeholder="Name"
476
+ className="h-7 flex-1 text-sm"
477
+ autoFocus
478
+ onKeyDown={editKeyHandler}
479
+ />
480
+ <Input
481
+ value={editing.slug}
482
+ onChange={(e) => updateEditing({ slug: e.target.value, autoSlug: false })}
483
+ placeholder="slug"
484
+ className="h-7 w-36 text-sm"
485
+ onKeyDown={editKeyHandler}
486
+ />
487
+ </>
488
+ );
489
+ };
490
+
491
+ // --- Tree rendering ---
492
+
493
+ const renderItem = (item: TreeItem, depth: number) => {
494
+ const hasChildren = item.children.length > 0;
495
+ const isDragSibling = activeSiblingIds?.has(item.id) ?? false;
496
+ const isExpanded = expandedIds.has(item.id) && !isDragSibling;
497
+ const isEditing = editing?.id === item.id;
498
+ const sortDisabled = isAncestorOfActive(item);
499
+
500
+ return (
501
+ <SortableTreeItem key={item.id} id={item.id} disabled={sortDisabled}>
502
+ {({ attributes, listeners, setNodeRef, setActivatorNodeRef, style, isDragging }) => (
503
+ <Collapsible open={isExpanded}>
504
+ <div ref={setNodeRef} style={style} className={cn(isDragging && "z-10 opacity-30")}>
505
+ <div
506
+ className="hover:bg-accent/40 flex items-center gap-1 border-b py-1.5 pr-2 text-sm transition-colors"
507
+ style={{ paddingLeft: `${depth * 1.5 + 0.25}rem` }}
508
+ >
509
+ <button
510
+ type="button"
511
+ ref={setActivatorNodeRef}
512
+ className="text-muted-foreground/50 hover:text-muted-foreground -ml-0.5 cursor-grab touch-none rounded p-0.5 transition-colors active:cursor-grabbing"
513
+ {...attributes}
514
+ {...listeners}
515
+ >
516
+ <GripVertical className="size-3.5" />
517
+ </button>
518
+
519
+ {hasChildren ? (
520
+ <CollapsibleTrigger
521
+ onClick={() => toggleExpand(item.id)}
522
+ className="text-muted-foreground hover:text-foreground flex size-6 shrink-0 items-center justify-center rounded-md transition-colors"
523
+ >
524
+ <ChevronRight
525
+ className="size-3.5 transition-transform duration-150"
526
+ style={{ transform: isExpanded ? "rotate(90deg)" : undefined }}
527
+ />
528
+ </CollapsibleTrigger>
529
+ ) : (
530
+ <span className="size-6 shrink-0" />
531
+ )}
532
+
533
+ {isEditing ? (
534
+ <div className="flex min-w-0 flex-1 items-center gap-2">
535
+ {renderEditFields()}
536
+ <Button variant="ghost" size="icon-sm" className="size-7" onClick={saveEdit} title="Save">
537
+ <Check className="size-3.5" />
538
+ </Button>
539
+ <Button variant="ghost" size="icon-sm" className="size-7" onClick={cancelEdit} title="Cancel">
540
+ <X className="size-3.5" />
541
+ </Button>
542
+ </div>
543
+ ) : (
544
+ <>
545
+ <div className="flex min-w-0 flex-1 items-center gap-3">
546
+ <span className="truncate font-medium">{getItemLabel(item, variant)}</span>
547
+ {variant === "menu" && (
548
+ <span className="text-muted-foreground truncate text-xs">{getItemSublabel(item, variant)}</span>
549
+ )}
550
+ {variant === "menu" && item.target === "_blank" && (
551
+ <span className="text-muted-foreground text-xs">(new tab)</span>
552
+ )}
553
+ </div>
554
+ <div className="flex shrink-0 items-center gap-0.5">
555
+ <Button
556
+ variant="ghost"
557
+ size="icon-sm"
558
+ className="size-7"
559
+ title="Indent"
560
+ onClick={() => indentItem(item.id)}
561
+ disabled={!canIndent(items, item.id)}
562
+ >
563
+ <Indent className="size-3.5" />
564
+ </Button>
565
+ <Button
566
+ variant="ghost"
567
+ size="icon-sm"
568
+ className="size-7"
569
+ title="Outdent"
570
+ onClick={() => outdentItem(item.id)}
571
+ disabled={!canOutdent(items, item.id)}
572
+ >
573
+ <Outdent className="size-3.5" />
574
+ </Button>
575
+ <Button
576
+ variant="ghost"
577
+ size="icon-sm"
578
+ className="size-7"
579
+ title="Add child"
580
+ onClick={() => addChildItem(item.id)}
581
+ >
582
+ <Plus className="size-3.5" />
583
+ </Button>
584
+ <Button
585
+ variant="ghost"
586
+ size="icon-sm"
587
+ className="size-7"
588
+ title="Edit"
589
+ onClick={() => startEdit(item)}
590
+ >
591
+ <Pencil className="size-3.5" />
592
+ </Button>
593
+ <Button
594
+ variant="ghost"
595
+ size="icon-sm"
596
+ className="text-muted-foreground hover:text-destructive size-7"
597
+ title="Delete"
598
+ onClick={() => removeItem(item.id)}
599
+ >
600
+ <Trash2 className="size-3.5" />
601
+ </Button>
602
+ </div>
603
+ </>
604
+ )}
605
+ </div>
606
+
607
+ {hasChildren && (
608
+ <CollapsibleContent>
609
+ <SortableContext items={item.children.map((c) => c.id)} strategy={verticalListSortingStrategy}>
610
+ {item.children.map((child) => renderItem(child, depth + 1))}
611
+ </SortableContext>
612
+ </CollapsibleContent>
613
+ )}
614
+ </div>
615
+ </Collapsible>
616
+ )}
617
+ </SortableTreeItem>
618
+ );
619
+ };
620
+
621
+ // --- Bulk add (taxonomy only) ---
622
+
623
+ const [bulkInput, setBulkInput] = React.useState("");
624
+ const [bulkParent, setBulkParent] = React.useState("");
625
+
626
+ const flattenForSelect = React.useCallback(
627
+ (list: TreeItem[], depth = 0): Array<{ id: string; label: string }> =>
628
+ list.flatMap((item) => [
629
+ {
630
+ id: item.id,
631
+ label: `${"—".repeat(depth)}${depth > 0 ? " " : ""}${String(item.name || item.label || item.id)}`,
632
+ },
633
+ ...flattenForSelect(item.children, depth + 1),
634
+ ]),
635
+ [],
636
+ );
637
+
638
+ const parentOptions = React.useMemo(
639
+ () => [{ id: "", label: "Root" }, ...flattenForSelect(items)],
640
+ [items, flattenForSelect],
641
+ );
642
+
643
+ const handleBulkAdd = () => {
644
+ const names = bulkInput
645
+ .split(",")
646
+ .map((s) => s.trim())
647
+ .filter(Boolean);
648
+ if (names.length === 0) return;
649
+
650
+ const newItems = names.map((n) => ({
651
+ id: generateId(),
652
+ name: n,
653
+ slug: slugify(n),
654
+ children: [] as TreeItem[],
655
+ }));
656
+
657
+ setItems((prev) => {
658
+ const next = cloneItems(prev);
659
+ if (!bulkParent) {
660
+ next.push(...newItems);
661
+ } else {
662
+ const addToParent = (list: TreeItem[]): boolean => {
663
+ for (const item of list) {
664
+ if (item.id === bulkParent) {
665
+ item.children.push(...newItems);
666
+ return true;
667
+ }
668
+ if (addToParent(item.children)) return true;
669
+ }
670
+ return false;
671
+ };
672
+ if (!addToParent(next)) next.push(...newItems);
673
+ setExpandedIds((prev) => new Set([...prev, bulkParent]));
674
+ }
675
+ return next;
676
+ });
677
+ setBulkInput("");
678
+ };
679
+
680
+ // --- Render ---
681
+
682
+ const emptyLabel = variant === "menu" ? "No menu items." : "No terms.";
683
+ const addLabel = variant === "menu" ? "Add menu item" : "Add term";
684
+
685
+ return (
686
+ <div className="space-y-2">
687
+ <input ref={hiddenRef} type="hidden" name={name} value={serialized} />
688
+
689
+ {label && (
690
+ <div className="flex items-center justify-between">
691
+ <label className="text-sm leading-none font-medium">{label}</label>
692
+ {hasExpandableItems && (
693
+ <button
694
+ type="button"
695
+ onClick={toggleExpandAll}
696
+ className="text-muted-foreground hover:text-foreground flex items-center gap-1 text-xs leading-none transition-colors"
697
+ >
698
+ {allExpanded ? (
699
+ <>
700
+ <ChevronsDownUp className="size-3.5" />
701
+ Collapse all
702
+ </>
703
+ ) : (
704
+ <>
705
+ <ChevronsUpDown className="size-3.5" />
706
+ Expand all
707
+ </>
708
+ )}
709
+ </button>
710
+ )}
711
+ </div>
712
+ )}
713
+
714
+ {variant === "taxonomy" && (
715
+ <div className="flex items-center gap-2">
716
+ <Input
717
+ value={bulkInput}
718
+ onChange={(e) => setBulkInput(e.target.value)}
719
+ onKeyDown={(e) => {
720
+ if (e.key === "Enter") {
721
+ e.preventDefault();
722
+ handleBulkAdd();
723
+ }
724
+ }}
725
+ placeholder="e.g., Electronics, Clothing, Books"
726
+ className="min-w-0 flex-1 text-sm"
727
+ />
728
+ <Select
729
+ items={parentOptions.map((opt) => ({ value: opt.id, label: opt.label }))}
730
+ value={bulkParent}
731
+ onValueChange={(v) => setBulkParent(v ?? "")}
732
+ >
733
+ <SelectTrigger className="w-32 shrink-0 text-sm">
734
+ <SelectValue placeholder="Root" />
735
+ </SelectTrigger>
736
+ <SelectContent>
737
+ <SelectGroup>
738
+ {parentOptions.map((opt) => (
739
+ <SelectItem key={opt.id} value={opt.id}>
740
+ {opt.label}
741
+ </SelectItem>
742
+ ))}
743
+ </SelectGroup>
744
+ </SelectContent>
745
+ </Select>
746
+ <Button type="button" variant="outline" size="lg" onClick={handleBulkAdd} disabled={!bulkInput.trim()}>
747
+ Add
748
+ </Button>
749
+ </div>
750
+ )}
751
+
752
+ <DndContext
753
+ sensors={sensors}
754
+ collisionDetection={closestCenter}
755
+ onDragStart={handleDragStart}
756
+ onDragEnd={handleDragEnd}
757
+ onDragCancel={handleDragCancel}
758
+ >
759
+ <div className="rounded-lg border">
760
+ {items.length > 0 ? (
761
+ <SortableContext items={items.map((item) => item.id)} strategy={verticalListSortingStrategy}>
762
+ {items.map((item) => renderItem(item, 0))}
763
+ </SortableContext>
764
+ ) : (
765
+ <div className="text-muted-foreground py-8 text-center text-sm">{emptyLabel}</div>
766
+ )}
767
+ </div>
768
+ <DragOverlay dropAnimation={null}>
769
+ {activeItem && (
770
+ <div
771
+ className="bg-background flex items-center gap-1 rounded-md border py-1.5 pr-2 text-sm shadow-lg"
772
+ style={{ paddingLeft: `${activeDepth * 1.5 + 0.25}rem` }}
773
+ >
774
+ <GripVertical className="text-muted-foreground/50 size-3.5" />
775
+ <span className="size-6 shrink-0" />
776
+ <span className="truncate font-medium">{getItemLabel(activeItem, variant)}</span>
777
+ {variant === "menu" && (
778
+ <span className="text-muted-foreground truncate text-xs">{getItemSublabel(activeItem, variant)}</span>
779
+ )}
780
+ </div>
781
+ )}
782
+ </DragOverlay>
783
+ </DndContext>
784
+ <Button type="button" variant="outline" size="sm" onClick={addRootItem}>
785
+ <Plus className="size-4" />
786
+ {addLabel}
787
+ </Button>
788
+ </div>
789
+ );
790
+ }