@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,166 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useEffect, useRef, useState } from "react";
|
|
4
|
+
import { Check, ChevronsUpDown } from "lucide-react";
|
|
5
|
+
import { Button } from "./ui/button";
|
|
6
|
+
import { Command, CommandEmpty, CommandInput, CommandItem, CommandList } from "./ui/command";
|
|
7
|
+
import { Popover, PopoverContent, PopoverTrigger } from "./ui/popover";
|
|
8
|
+
import { cn } from "../lib/utils";
|
|
9
|
+
|
|
10
|
+
export type TreeSelectItem = {
|
|
11
|
+
value: string;
|
|
12
|
+
label: string;
|
|
13
|
+
depth: number;
|
|
14
|
+
path: string[];
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
type Props = {
|
|
18
|
+
name: string;
|
|
19
|
+
value?: string;
|
|
20
|
+
placeholder?: string;
|
|
21
|
+
searchPlaceholder?: string;
|
|
22
|
+
emptyMessage?: string;
|
|
23
|
+
items: TreeSelectItem[];
|
|
24
|
+
onChange?: (value: string) => void;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export function flattenTree<T extends { children?: T[] }>(
|
|
28
|
+
nodes: T[],
|
|
29
|
+
getValue: (node: T) => string,
|
|
30
|
+
getLabel: (node: T) => string,
|
|
31
|
+
depth = 0,
|
|
32
|
+
path: string[] = [],
|
|
33
|
+
): TreeSelectItem[] {
|
|
34
|
+
const result: TreeSelectItem[] = [];
|
|
35
|
+
for (const node of nodes) {
|
|
36
|
+
const currentPath = [...path, getLabel(node)];
|
|
37
|
+
result.push({ value: getValue(node), label: getLabel(node), depth, path: currentPath });
|
|
38
|
+
if (node.children?.length) {
|
|
39
|
+
result.push(...flattenTree(node.children, getValue, getLabel, depth + 1, currentPath));
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return result;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function flattenByParent<T>(
|
|
46
|
+
items: T[],
|
|
47
|
+
getValue: (item: T) => string,
|
|
48
|
+
getLabel: (item: T) => string,
|
|
49
|
+
getParent: (item: T) => string | null,
|
|
50
|
+
): TreeSelectItem[] {
|
|
51
|
+
const childrenMap = new Map<string | null, T[]>();
|
|
52
|
+
for (const item of items) {
|
|
53
|
+
const parent = getParent(item);
|
|
54
|
+
if (!childrenMap.has(parent)) childrenMap.set(parent, []);
|
|
55
|
+
childrenMap.get(parent)!.push(item);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const result: TreeSelectItem[] = [];
|
|
59
|
+
const walk = (parentId: string | null, depth: number, path: string[]) => {
|
|
60
|
+
for (const item of childrenMap.get(parentId) ?? []) {
|
|
61
|
+
const currentPath = [...path, getLabel(item)];
|
|
62
|
+
result.push({ value: getValue(item), label: getLabel(item), depth, path: currentPath });
|
|
63
|
+
walk(getValue(item), depth + 1, currentPath);
|
|
64
|
+
}
|
|
65
|
+
};
|
|
66
|
+
walk(null, 0, []);
|
|
67
|
+
return result;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export default function TreeSelect({
|
|
71
|
+
name,
|
|
72
|
+
value: initialValue,
|
|
73
|
+
placeholder = "Select...",
|
|
74
|
+
searchPlaceholder = "Search...",
|
|
75
|
+
emptyMessage = "No results found.",
|
|
76
|
+
items,
|
|
77
|
+
onChange: onChangeProp,
|
|
78
|
+
}: Props) {
|
|
79
|
+
const [value, setValue] = useState(initialValue ?? "");
|
|
80
|
+
const [open, setOpen] = useState(false);
|
|
81
|
+
const hiddenRef = useRef<HTMLInputElement>(null);
|
|
82
|
+
const isInitial = useRef(true);
|
|
83
|
+
|
|
84
|
+
useEffect(() => {
|
|
85
|
+
if (isInitial.current) {
|
|
86
|
+
isInitial.current = false;
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
hiddenRef.current?.dispatchEvent(new Event("change", { bubbles: true }));
|
|
90
|
+
}, [value]);
|
|
91
|
+
|
|
92
|
+
const selected = items.find((i) => i.value === value);
|
|
93
|
+
|
|
94
|
+
return (
|
|
95
|
+
<div className="space-y-2">
|
|
96
|
+
<input ref={hiddenRef} type="hidden" name={name} value={value} />
|
|
97
|
+
<Popover open={open} onOpenChange={setOpen}>
|
|
98
|
+
<PopoverTrigger asChild>
|
|
99
|
+
<Button
|
|
100
|
+
variant="outline"
|
|
101
|
+
role="combobox"
|
|
102
|
+
aria-expanded={open}
|
|
103
|
+
size="lg"
|
|
104
|
+
className="border-input bg-muted/30 hover:bg-muted dark:bg-input/30 dark:hover:bg-input/50 w-full justify-between text-base font-normal"
|
|
105
|
+
>
|
|
106
|
+
<span className={cn("truncate", !selected && "text-muted-foreground")}>
|
|
107
|
+
{selected ? (
|
|
108
|
+
<span className="flex items-center gap-1">
|
|
109
|
+
{selected.path.length > 1 && (
|
|
110
|
+
<span className="text-muted-foreground">
|
|
111
|
+
{selected.path.slice(0, -1).join(" / ")}
|
|
112
|
+
{" / "}
|
|
113
|
+
</span>
|
|
114
|
+
)}
|
|
115
|
+
{selected.label}
|
|
116
|
+
</span>
|
|
117
|
+
) : (
|
|
118
|
+
placeholder
|
|
119
|
+
)}
|
|
120
|
+
</span>
|
|
121
|
+
<ChevronsUpDown className="ml-2 size-4 shrink-0 opacity-50" />
|
|
122
|
+
</Button>
|
|
123
|
+
</PopoverTrigger>
|
|
124
|
+
<PopoverContent className="w-(--radix-popover-trigger-width) p-0" align="start">
|
|
125
|
+
<Command>
|
|
126
|
+
<CommandInput placeholder={searchPlaceholder} />
|
|
127
|
+
<CommandList>
|
|
128
|
+
<CommandEmpty>{emptyMessage}</CommandEmpty>
|
|
129
|
+
<CommandItem
|
|
130
|
+
value="__none__"
|
|
131
|
+
onSelect={() => {
|
|
132
|
+
setValue("");
|
|
133
|
+
onChangeProp?.("");
|
|
134
|
+
setOpen(false);
|
|
135
|
+
}}
|
|
136
|
+
>
|
|
137
|
+
<div className="flex items-center">
|
|
138
|
+
<Check className={cn("mr-2 ml-1 size-4 shrink-0", !value ? "opacity-100" : "opacity-0")} />
|
|
139
|
+
<span className="text-muted-foreground">None</span>
|
|
140
|
+
</div>
|
|
141
|
+
</CommandItem>
|
|
142
|
+
{items.map((item) => (
|
|
143
|
+
<CommandItem
|
|
144
|
+
key={item.value}
|
|
145
|
+
value={item.path.join(" / ")}
|
|
146
|
+
onSelect={() => {
|
|
147
|
+
setValue(item.value === value ? "" : item.value);
|
|
148
|
+
onChangeProp?.(item.value === value ? "" : item.value);
|
|
149
|
+
setOpen(false);
|
|
150
|
+
}}
|
|
151
|
+
>
|
|
152
|
+
<div className="flex items-center" style={{ paddingLeft: `${item.depth * 1.25}rem` }}>
|
|
153
|
+
<Check
|
|
154
|
+
className={cn("mr-2 ml-1 size-4 shrink-0", value === item.value ? "opacity-100" : "opacity-0")}
|
|
155
|
+
/>
|
|
156
|
+
{item.label}
|
|
157
|
+
</div>
|
|
158
|
+
</CommandItem>
|
|
159
|
+
))}
|
|
160
|
+
</CommandList>
|
|
161
|
+
</Command>
|
|
162
|
+
</PopoverContent>
|
|
163
|
+
</Popover>
|
|
164
|
+
</div>
|
|
165
|
+
);
|
|
166
|
+
}
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useCallback, useEffect, useRef, useState } from "react";
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
AlertDialog,
|
|
7
|
+
AlertDialogClose,
|
|
8
|
+
AlertDialogContent,
|
|
9
|
+
AlertDialogDescription,
|
|
10
|
+
AlertDialogFooter,
|
|
11
|
+
AlertDialogHeader,
|
|
12
|
+
AlertDialogTitle,
|
|
13
|
+
} from "./ui/alert-dialog";
|
|
14
|
+
import { Button } from "./ui/button";
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Tracks whether the form has unsaved changes.
|
|
18
|
+
* Shows a confirmation dialog when the user tries to navigate away.
|
|
19
|
+
* Also prevents accidental tab/window close via beforeunload.
|
|
20
|
+
*/
|
|
21
|
+
export default function UnsavedGuard({
|
|
22
|
+
formId,
|
|
23
|
+
isNew = false,
|
|
24
|
+
isDraft = false,
|
|
25
|
+
}: {
|
|
26
|
+
formId: string;
|
|
27
|
+
isNew?: boolean;
|
|
28
|
+
/** Document is a draft — publish button stays enabled even when form is clean */
|
|
29
|
+
isDraft?: boolean;
|
|
30
|
+
}) {
|
|
31
|
+
const [pendingHref, setPendingHref] = useState<string | null>(null);
|
|
32
|
+
const dirtyRef = useRef(false);
|
|
33
|
+
const submittingRef = useRef(false);
|
|
34
|
+
|
|
35
|
+
// Track form changes by comparing current values to initial snapshot.
|
|
36
|
+
// Also toggles disabled state on save/publish submit buttons.
|
|
37
|
+
useEffect(() => {
|
|
38
|
+
const form = document.getElementById(formId) as HTMLFormElement | null;
|
|
39
|
+
if (!form) return;
|
|
40
|
+
|
|
41
|
+
const doc = form.ownerDocument;
|
|
42
|
+
const saveBtn = doc.querySelector<HTMLButtonElement>(`button[type="submit"][form="${formId}"][value="save"]`);
|
|
43
|
+
const publishBtn = doc.querySelector<HTMLButtonElement>(`button[type="submit"][form="${formId}"][value="publish"]`);
|
|
44
|
+
|
|
45
|
+
const initialData = new FormData(form);
|
|
46
|
+
const initialSnapshot = serializeFormData(initialData);
|
|
47
|
+
|
|
48
|
+
const updateButtons = (dirty: boolean) => {
|
|
49
|
+
if (isNew) return;
|
|
50
|
+
if (saveBtn) saveBtn.disabled = !dirty;
|
|
51
|
+
// Publish enabled when form has changes OR document is an unpublished draft
|
|
52
|
+
if (publishBtn) publishBtn.disabled = !dirty && !isDraft;
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
// Set initial button state
|
|
56
|
+
updateButtons(false);
|
|
57
|
+
|
|
58
|
+
const checkDirty = () => {
|
|
59
|
+
const currentSnapshot = serializeFormData(new FormData(form));
|
|
60
|
+
dirtyRef.current = currentSnapshot !== initialSnapshot;
|
|
61
|
+
updateButtons(dirtyRef.current);
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
// Mark as clean when form is submitted
|
|
65
|
+
const handleSubmit = () => {
|
|
66
|
+
submittingRef.current = true;
|
|
67
|
+
dirtyRef.current = false;
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
// Live preview: broadcast field changes to any open preview tab
|
|
71
|
+
const previewChannel = new BroadcastChannel("cms-preview");
|
|
72
|
+
const broadcastField = (e: Event) => {
|
|
73
|
+
const target = e.target as HTMLInputElement;
|
|
74
|
+
if (!target.name || target.name.startsWith("_") || target.name === "redirectTo") return;
|
|
75
|
+
// Skip hidden inputs — complex fields (rich text, blocks) handle their own broadcasting
|
|
76
|
+
if (target.type === "hidden") return;
|
|
77
|
+
previewChannel.postMessage({ field: target.name, value: target.value });
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
form.addEventListener("input", checkDirty);
|
|
81
|
+
form.addEventListener("input", broadcastField);
|
|
82
|
+
form.addEventListener("change", checkDirty);
|
|
83
|
+
form.addEventListener("change", broadcastField);
|
|
84
|
+
form.addEventListener("submit", handleSubmit);
|
|
85
|
+
|
|
86
|
+
return () => {
|
|
87
|
+
form.removeEventListener("input", checkDirty);
|
|
88
|
+
form.removeEventListener("input", broadcastField);
|
|
89
|
+
form.removeEventListener("change", checkDirty);
|
|
90
|
+
form.removeEventListener("change", broadcastField);
|
|
91
|
+
form.removeEventListener("submit", handleSubmit);
|
|
92
|
+
previewChannel.close();
|
|
93
|
+
};
|
|
94
|
+
}, [formId, isNew, isDraft]);
|
|
95
|
+
|
|
96
|
+
// Cmd+S / Ctrl+S to save
|
|
97
|
+
useEffect(() => {
|
|
98
|
+
const handleKeydown = (e: KeyboardEvent) => {
|
|
99
|
+
if ((e.metaKey || e.ctrlKey) && e.key === "s") {
|
|
100
|
+
e.preventDefault();
|
|
101
|
+
const saveBtn =
|
|
102
|
+
document.querySelector<HTMLButtonElement>(`button[type="submit"][form="${formId}"][value="save"]`) ??
|
|
103
|
+
document.querySelector<HTMLButtonElement>(`button[type="submit"][form="${formId}"]`);
|
|
104
|
+
if (saveBtn && !saveBtn.disabled) {
|
|
105
|
+
saveBtn.click();
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
document.addEventListener("keydown", handleKeydown);
|
|
111
|
+
return () => document.removeEventListener("keydown", handleKeydown);
|
|
112
|
+
}, [formId]);
|
|
113
|
+
|
|
114
|
+
// Intercept link clicks within the admin layout
|
|
115
|
+
useEffect(() => {
|
|
116
|
+
const handleClick = (e: MouseEvent) => {
|
|
117
|
+
if (submittingRef.current || !dirtyRef.current) return;
|
|
118
|
+
|
|
119
|
+
const anchor = (e.target as HTMLElement).closest("a[href]") as HTMLAnchorElement | null;
|
|
120
|
+
if (!anchor) return;
|
|
121
|
+
|
|
122
|
+
const href = anchor.getAttribute("href");
|
|
123
|
+
if (!href || href.startsWith("#") || href.startsWith("javascript:") || anchor.target === "_blank") return;
|
|
124
|
+
|
|
125
|
+
e.preventDefault();
|
|
126
|
+
setPendingHref(href);
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
document.addEventListener("click", handleClick, true);
|
|
130
|
+
return () => document.removeEventListener("click", handleClick, true);
|
|
131
|
+
}, []);
|
|
132
|
+
|
|
133
|
+
// Prevent accidental tab/window close
|
|
134
|
+
useEffect(() => {
|
|
135
|
+
const handleBeforeUnload = (e: BeforeUnloadEvent) => {
|
|
136
|
+
if (dirtyRef.current && !submittingRef.current) {
|
|
137
|
+
e.preventDefault();
|
|
138
|
+
}
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
window.addEventListener("beforeunload", handleBeforeUnload);
|
|
142
|
+
return () => window.removeEventListener("beforeunload", handleBeforeUnload);
|
|
143
|
+
}, []);
|
|
144
|
+
|
|
145
|
+
const handleDiscard = useCallback(() => {
|
|
146
|
+
if (pendingHref) {
|
|
147
|
+
dirtyRef.current = false;
|
|
148
|
+
window.location.assign(pendingHref);
|
|
149
|
+
}
|
|
150
|
+
}, [pendingHref]);
|
|
151
|
+
|
|
152
|
+
return (
|
|
153
|
+
<AlertDialog open={pendingHref !== null} onOpenChange={(open) => !open && setPendingHref(null)}>
|
|
154
|
+
<AlertDialogContent>
|
|
155
|
+
<AlertDialogHeader>
|
|
156
|
+
<AlertDialogTitle>Unsaved changes</AlertDialogTitle>
|
|
157
|
+
<AlertDialogDescription>
|
|
158
|
+
You have unsaved changes that will be lost if you leave this page.
|
|
159
|
+
</AlertDialogDescription>
|
|
160
|
+
</AlertDialogHeader>
|
|
161
|
+
<AlertDialogFooter>
|
|
162
|
+
<AlertDialogClose render={<Button variant="outline" />}>Stay on page</AlertDialogClose>
|
|
163
|
+
<Button variant="destructive" onClick={handleDiscard}>
|
|
164
|
+
Discard changes
|
|
165
|
+
</Button>
|
|
166
|
+
</AlertDialogFooter>
|
|
167
|
+
</AlertDialogContent>
|
|
168
|
+
</AlertDialog>
|
|
169
|
+
);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** Serialize FormData to a stable string for comparison, skipping system fields */
|
|
173
|
+
function serializeFormData(data: FormData): string {
|
|
174
|
+
const entries: [string, string][] = [];
|
|
175
|
+
data.forEach((value, key) => {
|
|
176
|
+
if (key.startsWith("_") || key === "redirectTo") return;
|
|
177
|
+
entries.push([key, String(value)]);
|
|
178
|
+
});
|
|
179
|
+
entries.sort((a, b) => a[0].localeCompare(b[0]));
|
|
180
|
+
return entries.map(([k, v]) => `${k}=${v}`).join("&");
|
|
181
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
export type TreeItem = {
|
|
2
|
+
id: string;
|
|
3
|
+
children: TreeItem[];
|
|
4
|
+
[key: string]: unknown;
|
|
5
|
+
};
|
|
6
|
+
|
|
7
|
+
export function generateId() {
|
|
8
|
+
return "ti_" + Math.random().toString(36).slice(2, 9);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function slugify(value: string) {
|
|
12
|
+
return value
|
|
13
|
+
.normalize("NFKD")
|
|
14
|
+
.replace(/[^\w\s-]/g, "")
|
|
15
|
+
.trim()
|
|
16
|
+
.toLowerCase()
|
|
17
|
+
.replace(/[\s_-]+/g, "-")
|
|
18
|
+
.replace(/^-+|-+$/g, "");
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function parseItems(value?: string): TreeItem[] {
|
|
22
|
+
if (!value) return [];
|
|
23
|
+
try {
|
|
24
|
+
const parsed = JSON.parse(value);
|
|
25
|
+
if (Array.isArray(parsed)) return parsed;
|
|
26
|
+
} catch {}
|
|
27
|
+
return [];
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function cloneItems(items: TreeItem[]): TreeItem[] {
|
|
31
|
+
return JSON.parse(JSON.stringify(items));
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function createBlankItem(): TreeItem {
|
|
35
|
+
return { id: generateId(), children: [] };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function getItemLabel(item: TreeItem, variant: "menu" | "taxonomy"): string {
|
|
39
|
+
return String(variant === "menu" ? item.label : item.name) || "—";
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function getItemSublabel(item: TreeItem, variant: "menu" | "taxonomy"): string {
|
|
43
|
+
return String(variant === "menu" ? item.href : item.slug) || "";
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function findItemById(items: TreeItem[], id: string): TreeItem | null {
|
|
47
|
+
for (const item of items) {
|
|
48
|
+
if (item.id === id) return item;
|
|
49
|
+
const found = findItemById(item.children, id);
|
|
50
|
+
if (found) return found;
|
|
51
|
+
}
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function findParentList(items: TreeItem[], id: string): TreeItem[] | null {
|
|
56
|
+
for (const item of items) {
|
|
57
|
+
if (item.id === id) return items;
|
|
58
|
+
const found = findParentList(item.children, id);
|
|
59
|
+
if (found) return found;
|
|
60
|
+
}
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function findItemDepth(items: TreeItem[], id: string, depth = 0): number {
|
|
65
|
+
for (const item of items) {
|
|
66
|
+
if (item.id === id) return depth;
|
|
67
|
+
const found = findItemDepth(item.children, id, depth + 1);
|
|
68
|
+
if (found >= 0) return found;
|
|
69
|
+
}
|
|
70
|
+
return -1;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function canIndent(items: TreeItem[], id: string): boolean {
|
|
74
|
+
const check = (list: TreeItem[]): boolean => {
|
|
75
|
+
for (let i = 0; i < list.length; i++) {
|
|
76
|
+
if (list[i].id === id) return i > 0;
|
|
77
|
+
if (check(list[i].children)) return true;
|
|
78
|
+
}
|
|
79
|
+
return false;
|
|
80
|
+
};
|
|
81
|
+
return check(items);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function canOutdent(items: TreeItem[], id: string): boolean {
|
|
85
|
+
return findItemDepth(items, id) > 0;
|
|
86
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import * as React from "react";
|
|
4
|
+
import { AlertDialog as AlertDialogPrimitive } from "@base-ui/react/alert-dialog";
|
|
5
|
+
|
|
6
|
+
import { cn } from "../../lib/utils";
|
|
7
|
+
|
|
8
|
+
function AlertDialog({ ...props }: AlertDialogPrimitive.Root.Props) {
|
|
9
|
+
return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} />;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function AlertDialogTrigger({ ...props }: AlertDialogPrimitive.Trigger.Props) {
|
|
13
|
+
return <AlertDialogPrimitive.Trigger data-slot="alert-dialog-trigger" {...props} />;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function AlertDialogPortal({ ...props }: AlertDialogPrimitive.Portal.Props) {
|
|
17
|
+
return <AlertDialogPrimitive.Portal data-slot="alert-dialog-portal" {...props} />;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function AlertDialogOverlay({ className, ...props }: AlertDialogPrimitive.Backdrop.Props) {
|
|
21
|
+
return (
|
|
22
|
+
<AlertDialogPrimitive.Backdrop
|
|
23
|
+
data-slot="alert-dialog-overlay"
|
|
24
|
+
className={cn(
|
|
25
|
+
"fixed inset-0 z-50 bg-black/50 transition-opacity duration-150 data-ending-style:opacity-0 data-starting-style:opacity-0",
|
|
26
|
+
className,
|
|
27
|
+
)}
|
|
28
|
+
{...props}
|
|
29
|
+
/>
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function AlertDialogContent({ className, children, ...props }: AlertDialogPrimitive.Popup.Props) {
|
|
34
|
+
return (
|
|
35
|
+
<AlertDialogPortal>
|
|
36
|
+
<AlertDialogOverlay />
|
|
37
|
+
<AlertDialogPrimitive.Popup
|
|
38
|
+
data-slot="alert-dialog-content"
|
|
39
|
+
className={cn(
|
|
40
|
+
"bg-background fixed top-1/2 left-1/2 z-50 grid w-full max-w-104 -translate-x-1/2 -translate-y-1/2 gap-4 rounded-lg border p-6 shadow-lg transition duration-150 data-ending-style:scale-95 data-ending-style:opacity-0 data-starting-style:scale-95 data-starting-style:opacity-0",
|
|
41
|
+
className,
|
|
42
|
+
)}
|
|
43
|
+
{...props}
|
|
44
|
+
>
|
|
45
|
+
{children}
|
|
46
|
+
</AlertDialogPrimitive.Popup>
|
|
47
|
+
</AlertDialogPortal>
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function AlertDialogHeader({ className, ...props }: React.ComponentProps<"div">) {
|
|
52
|
+
return <div data-slot="alert-dialog-header" className={cn("flex flex-col gap-1", className)} {...props} />;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function AlertDialogFooter({ className, ...props }: React.ComponentProps<"div">) {
|
|
56
|
+
return <div data-slot="alert-dialog-footer" className={cn("flex justify-end gap-2", className)} {...props} />;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function AlertDialogTitle({ className, ...props }: AlertDialogPrimitive.Title.Props) {
|
|
60
|
+
return (
|
|
61
|
+
<AlertDialogPrimitive.Title
|
|
62
|
+
data-slot="alert-dialog-title"
|
|
63
|
+
className={cn("text-foreground text-base font-semibold", className)}
|
|
64
|
+
{...props}
|
|
65
|
+
/>
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function AlertDialogDescription({ className, ...props }: AlertDialogPrimitive.Description.Props) {
|
|
70
|
+
return (
|
|
71
|
+
<AlertDialogPrimitive.Description
|
|
72
|
+
data-slot="alert-dialog-description"
|
|
73
|
+
className={cn("text-muted-foreground text-sm", className)}
|
|
74
|
+
{...props}
|
|
75
|
+
/>
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function AlertDialogClose({ ...props }: AlertDialogPrimitive.Close.Props) {
|
|
80
|
+
return <AlertDialogPrimitive.Close data-slot="alert-dialog-close" {...props} />;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export {
|
|
84
|
+
AlertDialog,
|
|
85
|
+
AlertDialogTrigger,
|
|
86
|
+
AlertDialogContent,
|
|
87
|
+
AlertDialogHeader,
|
|
88
|
+
AlertDialogFooter,
|
|
89
|
+
AlertDialogTitle,
|
|
90
|
+
AlertDialogDescription,
|
|
91
|
+
AlertDialogClose,
|
|
92
|
+
};
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { mergeProps } from "@base-ui/react/merge-props";
|
|
2
|
+
import { useRender } from "@base-ui/react/use-render";
|
|
3
|
+
import { cva, type VariantProps } from "class-variance-authority";
|
|
4
|
+
|
|
5
|
+
import { cn } from "../../lib/utils";
|
|
6
|
+
|
|
7
|
+
const badgeVariants = cva(
|
|
8
|
+
"group/badge inline-flex h-5 w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-4xl border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-all focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3!",
|
|
9
|
+
{
|
|
10
|
+
variants: {
|
|
11
|
+
variant: {
|
|
12
|
+
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
|
|
13
|
+
secondary: "bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80",
|
|
14
|
+
destructive:
|
|
15
|
+
"bg-destructive/10 text-destructive focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:focus-visible:ring-destructive/40 [a]:hover:bg-destructive/20",
|
|
16
|
+
outline: "border-foreground/20 text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground",
|
|
17
|
+
ghost: "hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50",
|
|
18
|
+
link: "text-primary underline-offset-4 hover:underline",
|
|
19
|
+
},
|
|
20
|
+
},
|
|
21
|
+
defaultVariants: {
|
|
22
|
+
variant: "default",
|
|
23
|
+
},
|
|
24
|
+
},
|
|
25
|
+
);
|
|
26
|
+
|
|
27
|
+
type StatusColor = "green" | "yellow" | "blue" | "purple";
|
|
28
|
+
|
|
29
|
+
const statusDotColor: Record<StatusColor, string> = {
|
|
30
|
+
green: "bg-emerald-500",
|
|
31
|
+
yellow: "bg-amber-500",
|
|
32
|
+
blue: "bg-blue-500",
|
|
33
|
+
purple: "bg-violet-500",
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
const statusBadgeBg: Record<StatusColor, string> = {
|
|
37
|
+
green: "border-emerald-500/30 bg-emerald-500/10 text-emerald-700 dark:text-emerald-400",
|
|
38
|
+
yellow: "border-amber-500/30 bg-amber-500/10 text-amber-700 dark:text-amber-400",
|
|
39
|
+
blue: "border-blue-500/30 bg-blue-500/10 text-blue-700 dark:text-blue-400",
|
|
40
|
+
purple: "border-violet-500/30 bg-violet-500/10 text-violet-700 dark:text-violet-400",
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
function StatusBadge({ status, styled, className }: { status: string; styled?: boolean; className?: string }) {
|
|
44
|
+
if (!status) return null;
|
|
45
|
+
const color: StatusColor =
|
|
46
|
+
status === "published" ? "green" : status === "changed" ? "yellow" : status === "scheduled" ? "purple" : "blue";
|
|
47
|
+
return (
|
|
48
|
+
<span
|
|
49
|
+
className={cn(
|
|
50
|
+
"inline-flex items-center gap-1.5 rounded-4xl py-0.5 text-xs font-medium capitalize",
|
|
51
|
+
styled ? cn("border px-2.5 py-0.5", statusBadgeBg[color]) : "text-foreground border border-transparent",
|
|
52
|
+
className,
|
|
53
|
+
)}
|
|
54
|
+
>
|
|
55
|
+
<span className={cn("size-1.5 rounded-full", statusDotColor[color])} />
|
|
56
|
+
{status}
|
|
57
|
+
</span>
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function Badge({
|
|
62
|
+
className,
|
|
63
|
+
variant = "default",
|
|
64
|
+
render,
|
|
65
|
+
...props
|
|
66
|
+
}: useRender.ComponentProps<"span"> & VariantProps<typeof badgeVariants>) {
|
|
67
|
+
return useRender({
|
|
68
|
+
defaultTagName: "span",
|
|
69
|
+
props: mergeProps<"span">(
|
|
70
|
+
{
|
|
71
|
+
className: cn(badgeVariants({ variant }), className),
|
|
72
|
+
},
|
|
73
|
+
props,
|
|
74
|
+
),
|
|
75
|
+
render,
|
|
76
|
+
state: {
|
|
77
|
+
slot: "badge",
|
|
78
|
+
variant,
|
|
79
|
+
},
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export { Badge, StatusBadge, badgeVariants };
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { Button as ButtonPrimitive } from "@base-ui/react/button";
|
|
2
|
+
import { cva, type VariantProps } from "class-variance-authority";
|
|
3
|
+
|
|
4
|
+
import { cn } from "../../lib/utils";
|
|
5
|
+
|
|
6
|
+
const buttonVariants = cva(
|
|
7
|
+
"group/button inline-flex shrink-0 items-center justify-center rounded-lg border bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
|
8
|
+
{
|
|
9
|
+
variants: {
|
|
10
|
+
variant: {
|
|
11
|
+
default:
|
|
12
|
+
"bg-indigo-100 hover:bg-indigo-200/70 text-indigo-900 border-indigo-300 dark:bg-indigo-500/30 dark:hover:bg-indigo-500/40 dark:border-indigo-600 dark:text-indigo-100",
|
|
13
|
+
outline:
|
|
14
|
+
"border-foreground/30 bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
|
|
15
|
+
secondary:
|
|
16
|
+
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
|
|
17
|
+
publish:
|
|
18
|
+
"bg-emerald-200/80 hover:bg-emerald-200/60 text-emerald-800 border-emerald-400 dark:bg-emerald-500/30 dark:hover:bg-emerald-500/40 dark:border-emerald-700 dark:text-emerald-200",
|
|
19
|
+
ghost:
|
|
20
|
+
"border-transparent hover:bg-foreground/8 hover:text-foreground aria-expanded:bg-foreground/8 aria-expanded:text-foreground dark:hover:bg-foreground/15 dark:aria-expanded:bg-foreground/15",
|
|
21
|
+
destructive:
|
|
22
|
+
"border-transparent bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40",
|
|
23
|
+
link: "border-transparent text-primary underline-offset-4 hover:underline",
|
|
24
|
+
},
|
|
25
|
+
size: {
|
|
26
|
+
default: "h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
|
|
27
|
+
xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
|
|
28
|
+
sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
|
|
29
|
+
lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-3 has-data-[icon=inline-start]:pl-3",
|
|
30
|
+
icon: "size-8",
|
|
31
|
+
"icon-xs":
|
|
32
|
+
"size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3",
|
|
33
|
+
"icon-sm": "size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg",
|
|
34
|
+
"icon-lg": "size-9",
|
|
35
|
+
},
|
|
36
|
+
},
|
|
37
|
+
defaultVariants: {
|
|
38
|
+
variant: "default",
|
|
39
|
+
size: "default",
|
|
40
|
+
},
|
|
41
|
+
},
|
|
42
|
+
);
|
|
43
|
+
|
|
44
|
+
function Button({
|
|
45
|
+
className,
|
|
46
|
+
variant = "default",
|
|
47
|
+
size = "default",
|
|
48
|
+
...props
|
|
49
|
+
}: ButtonPrimitive.Props & VariantProps<typeof buttonVariants>) {
|
|
50
|
+
return <ButtonPrimitive data-slot="button" className={cn(buttonVariants({ variant, size, className }))} {...props} />;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export { Button, buttonVariants };
|