@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,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
|
+
}
|