@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,65 @@
|
|
|
1
|
+
import { useEffect, useRef, useState } from "react";
|
|
2
|
+
import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from "./ui/select";
|
|
3
|
+
|
|
4
|
+
type SelectOption = { label: string; value: string };
|
|
5
|
+
|
|
6
|
+
type Props = {
|
|
7
|
+
name: string;
|
|
8
|
+
value?: string;
|
|
9
|
+
placeholder?: string;
|
|
10
|
+
disabled?: boolean;
|
|
11
|
+
items: SelectOption[];
|
|
12
|
+
onChange?: (value: string) => void;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export default function SelectField({
|
|
16
|
+
name,
|
|
17
|
+
value: initialValue,
|
|
18
|
+
placeholder = "Select an option",
|
|
19
|
+
disabled,
|
|
20
|
+
items,
|
|
21
|
+
onChange: onChangeProp,
|
|
22
|
+
}: Props) {
|
|
23
|
+
const [value, setValue] = useState(initialValue ?? "");
|
|
24
|
+
const hiddenRef = useRef<HTMLInputElement>(null);
|
|
25
|
+
const isInitial = useRef(true);
|
|
26
|
+
|
|
27
|
+
useEffect(() => {
|
|
28
|
+
if (isInitial.current) {
|
|
29
|
+
isInitial.current = false;
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
hiddenRef.current?.dispatchEvent(new Event("change", { bubbles: true }));
|
|
33
|
+
}, [value]);
|
|
34
|
+
|
|
35
|
+
return (
|
|
36
|
+
<>
|
|
37
|
+
<input type="hidden" name={name} value={value} ref={hiddenRef} />
|
|
38
|
+
<Select
|
|
39
|
+
items={items}
|
|
40
|
+
value={value}
|
|
41
|
+
onValueChange={(v) => {
|
|
42
|
+
setValue(v ?? "");
|
|
43
|
+
onChangeProp?.(v ?? "");
|
|
44
|
+
}}
|
|
45
|
+
disabled={disabled}
|
|
46
|
+
>
|
|
47
|
+
<SelectTrigger className="w-full">
|
|
48
|
+
<SelectValue placeholder={placeholder} />
|
|
49
|
+
</SelectTrigger>
|
|
50
|
+
<SelectContent>
|
|
51
|
+
<SelectGroup>
|
|
52
|
+
<SelectItem value="">
|
|
53
|
+
<span className="text-muted-foreground">None</span>
|
|
54
|
+
</SelectItem>
|
|
55
|
+
{items.map((item) => (
|
|
56
|
+
<SelectItem key={item.value} value={item.value}>
|
|
57
|
+
{item.label}
|
|
58
|
+
</SelectItem>
|
|
59
|
+
))}
|
|
60
|
+
</SelectGroup>
|
|
61
|
+
</SelectContent>
|
|
62
|
+
</Select>
|
|
63
|
+
</>
|
|
64
|
+
);
|
|
65
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { ChevronsUpDown, LogOut, Monitor, Moon, Sun } from "lucide-react";
|
|
4
|
+
import { useEffect, useState } from "react";
|
|
5
|
+
|
|
6
|
+
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator } from "./ui/dropdown-menu";
|
|
7
|
+
import { DropdownMenuTrigger } from "./ui/dropdown-menu";
|
|
8
|
+
|
|
9
|
+
type Theme = "light" | "dark" | "system";
|
|
10
|
+
|
|
11
|
+
function applyTheme(theme: Theme) {
|
|
12
|
+
const root = document.documentElement;
|
|
13
|
+
const dark = theme === "dark" || (theme === "system" && matchMedia("(prefers-color-scheme: dark)").matches);
|
|
14
|
+
root.classList.toggle("dark", dark);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export default function SidebarUserMenu({
|
|
18
|
+
userName,
|
|
19
|
+
userEmail,
|
|
20
|
+
logoutAction,
|
|
21
|
+
}: {
|
|
22
|
+
userName: string;
|
|
23
|
+
userEmail: string;
|
|
24
|
+
logoutAction: string;
|
|
25
|
+
}) {
|
|
26
|
+
const [theme, setTheme] = useState<Theme>("system");
|
|
27
|
+
|
|
28
|
+
useEffect(() => {
|
|
29
|
+
const saved = localStorage.getItem("admin-theme") as Theme | null;
|
|
30
|
+
if (saved) {
|
|
31
|
+
// eslint-disable-next-line react-hooks/set-state-in-effect
|
|
32
|
+
setTheme(saved);
|
|
33
|
+
applyTheme(saved);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const mq = window.matchMedia("(prefers-color-scheme: dark)");
|
|
37
|
+
const handler = () => {
|
|
38
|
+
if ((localStorage.getItem("admin-theme") ?? "system") === "system") {
|
|
39
|
+
applyTheme("system");
|
|
40
|
+
}
|
|
41
|
+
};
|
|
42
|
+
mq.addEventListener("change", handler);
|
|
43
|
+
return () => mq.removeEventListener("change", handler);
|
|
44
|
+
}, []);
|
|
45
|
+
|
|
46
|
+
const changeTheme = (t: Theme) => {
|
|
47
|
+
setTheme(t);
|
|
48
|
+
localStorage.setItem("admin-theme", t);
|
|
49
|
+
applyTheme(t);
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
return (
|
|
53
|
+
<DropdownMenu>
|
|
54
|
+
<DropdownMenuTrigger className="hover:bg-foreground/5 dark:hover:bg-accent/60 flex w-full items-center gap-3 rounded-lg px-3 py-2 transition-colors">
|
|
55
|
+
<div className="bg-primary/80 text-primary-foreground flex size-8 shrink-0 items-center justify-center rounded-full font-medium">
|
|
56
|
+
{userName.charAt(0).toUpperCase()}
|
|
57
|
+
</div>
|
|
58
|
+
<div className="min-w-0 flex-1 text-left">
|
|
59
|
+
<div className="truncate text-sm font-medium">{userName}</div>
|
|
60
|
+
<div className="text-muted-foreground truncate text-xs">{userEmail}</div>
|
|
61
|
+
</div>
|
|
62
|
+
<ChevronsUpDown className="text-muted-foreground size-4 shrink-0" />
|
|
63
|
+
</DropdownMenuTrigger>
|
|
64
|
+
|
|
65
|
+
<DropdownMenuContent side="bottom" align="start" className="w-(--radix-dropdown-menu-trigger-width)">
|
|
66
|
+
<DropdownMenuItem onClick={() => changeTheme("light")}>
|
|
67
|
+
<Sun className="text-muted-foreground size-3.5" />
|
|
68
|
+
Light
|
|
69
|
+
{theme === "light" && <span className="ml-auto text-xs">✓</span>}
|
|
70
|
+
</DropdownMenuItem>
|
|
71
|
+
<DropdownMenuItem onClick={() => changeTheme("dark")}>
|
|
72
|
+
<Moon className="text-muted-foreground size-3.5" />
|
|
73
|
+
Dark
|
|
74
|
+
{theme === "dark" && <span className="ml-auto text-xs">✓</span>}
|
|
75
|
+
</DropdownMenuItem>
|
|
76
|
+
<DropdownMenuItem onClick={() => changeTheme("system")}>
|
|
77
|
+
<Monitor className="text-muted-foreground size-3.5" />
|
|
78
|
+
System
|
|
79
|
+
{theme === "system" && <span className="ml-auto text-xs">✓</span>}
|
|
80
|
+
</DropdownMenuItem>
|
|
81
|
+
|
|
82
|
+
<DropdownMenuSeparator />
|
|
83
|
+
|
|
84
|
+
<DropdownMenuItem
|
|
85
|
+
onClick={() => {
|
|
86
|
+
const form = document.createElement("form");
|
|
87
|
+
form.method = "post";
|
|
88
|
+
form.action = logoutAction;
|
|
89
|
+
document.body.appendChild(form);
|
|
90
|
+
form.submit();
|
|
91
|
+
}}
|
|
92
|
+
>
|
|
93
|
+
<LogOut className="text-muted-foreground size-3.5" />
|
|
94
|
+
Log out
|
|
95
|
+
</DropdownMenuItem>
|
|
96
|
+
</DropdownMenuContent>
|
|
97
|
+
</DropdownMenu>
|
|
98
|
+
);
|
|
99
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import * as React from "react";
|
|
4
|
+
import { Input } from "./ui/input";
|
|
5
|
+
import { cn } from "../lib/utils";
|
|
6
|
+
|
|
7
|
+
type Props = {
|
|
8
|
+
name: string;
|
|
9
|
+
value?: string;
|
|
10
|
+
from?: string;
|
|
11
|
+
readOnly?: boolean;
|
|
12
|
+
required?: boolean;
|
|
13
|
+
placeholder?: string;
|
|
14
|
+
className?: string;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
function slugify(value: string) {
|
|
18
|
+
return value
|
|
19
|
+
.normalize("NFKD")
|
|
20
|
+
.replace(/[^\w\s-]/g, "")
|
|
21
|
+
.trim()
|
|
22
|
+
.toLowerCase()
|
|
23
|
+
.replace(/[\s_-]+/g, "-")
|
|
24
|
+
.replace(/^-+|-+$/g, "");
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export default function SlugField({
|
|
28
|
+
name,
|
|
29
|
+
value: initialValue = "",
|
|
30
|
+
from,
|
|
31
|
+
readOnly,
|
|
32
|
+
required,
|
|
33
|
+
placeholder,
|
|
34
|
+
className,
|
|
35
|
+
}: Props) {
|
|
36
|
+
const [value, setValue] = React.useState(initialValue);
|
|
37
|
+
const autoSyncRef = React.useRef(!initialValue);
|
|
38
|
+
const wrapperRef = React.useRef<HTMLDivElement>(null);
|
|
39
|
+
|
|
40
|
+
React.useEffect(() => {
|
|
41
|
+
if (!from || readOnly) return;
|
|
42
|
+
|
|
43
|
+
const form = wrapperRef.current?.closest("form");
|
|
44
|
+
if (!form) return;
|
|
45
|
+
|
|
46
|
+
const sourceInput = form.querySelector<HTMLInputElement>(`[name="${from}"]`);
|
|
47
|
+
if (!sourceInput) return;
|
|
48
|
+
|
|
49
|
+
const handleInput = () => {
|
|
50
|
+
if (autoSyncRef.current) {
|
|
51
|
+
setValue(slugify(sourceInput.value));
|
|
52
|
+
}
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
sourceInput.addEventListener("input", handleInput);
|
|
56
|
+
return () => sourceInput.removeEventListener("input", handleInput);
|
|
57
|
+
}, [from, readOnly]);
|
|
58
|
+
|
|
59
|
+
return (
|
|
60
|
+
<div ref={wrapperRef}>
|
|
61
|
+
<Input
|
|
62
|
+
type="text"
|
|
63
|
+
id={name}
|
|
64
|
+
name={name}
|
|
65
|
+
value={value}
|
|
66
|
+
onChange={(e) => {
|
|
67
|
+
setValue(e.target.value);
|
|
68
|
+
autoSyncRef.current = false;
|
|
69
|
+
}}
|
|
70
|
+
placeholder={placeholder}
|
|
71
|
+
readOnly={readOnly}
|
|
72
|
+
required={required}
|
|
73
|
+
className={cn("shadow-none", className)}
|
|
74
|
+
/>
|
|
75
|
+
</div>
|
|
76
|
+
);
|
|
77
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useEffect, useState } from "react";
|
|
4
|
+
import TreeSelect, { flattenTree, type TreeSelectItem } from "./TreeSelect";
|
|
5
|
+
|
|
6
|
+
type Props = {
|
|
7
|
+
name: string;
|
|
8
|
+
value?: string;
|
|
9
|
+
taxonomySlug: string;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
type Term = {
|
|
13
|
+
id: string;
|
|
14
|
+
name: string;
|
|
15
|
+
slug: string;
|
|
16
|
+
children?: Term[];
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export default function TaxonomySelect({ name, value, taxonomySlug }: Props) {
|
|
20
|
+
const [items, setItems] = useState<TreeSelectItem[]>([]);
|
|
21
|
+
|
|
22
|
+
useEffect(() => {
|
|
23
|
+
fetch(`/api/cms/taxonomies?where=${encodeURIComponent(JSON.stringify({ slug: taxonomySlug }))}&status=any`)
|
|
24
|
+
.then((res) => (res.ok ? res.json() : { docs: [] }))
|
|
25
|
+
.then((result) => {
|
|
26
|
+
const doc = result.docs?.[0] ?? result[0];
|
|
27
|
+
if (!doc?.terms) return;
|
|
28
|
+
const parsed = typeof doc.terms === "string" ? JSON.parse(doc.terms) : doc.terms;
|
|
29
|
+
if (Array.isArray(parsed)) {
|
|
30
|
+
setItems(
|
|
31
|
+
flattenTree(
|
|
32
|
+
parsed as Term[],
|
|
33
|
+
(t) => t.slug,
|
|
34
|
+
(t) => t.name,
|
|
35
|
+
),
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
})
|
|
39
|
+
.catch(() => {});
|
|
40
|
+
}, [taxonomySlug]);
|
|
41
|
+
|
|
42
|
+
return (
|
|
43
|
+
<TreeSelect
|
|
44
|
+
name={name}
|
|
45
|
+
value={value}
|
|
46
|
+
placeholder={`Search ${taxonomySlug}...`}
|
|
47
|
+
searchPlaceholder="Search terms..."
|
|
48
|
+
emptyMessage="No terms found."
|
|
49
|
+
items={items}
|
|
50
|
+
/>
|
|
51
|
+
);
|
|
52
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* Server-side toast notification — no React hydration needed.
|
|
4
|
+
* Rendered directly in HTML with CSS animations for auto-dismiss.
|
|
5
|
+
*/
|
|
6
|
+
import { CircleAlert, CircleCheck } from "lucide-react";
|
|
7
|
+
import { cn } from "../lib/utils";
|
|
8
|
+
|
|
9
|
+
type Props = {
|
|
10
|
+
status: "success" | "error";
|
|
11
|
+
message: string;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
const { status, message } = Astro.props;
|
|
15
|
+
const isError = status === "error";
|
|
16
|
+
---
|
|
17
|
+
|
|
18
|
+
{/* Centering wrapper — keeps left-1/2 -translate-x-1/2 stable while inner div animates */}
|
|
19
|
+
<div class="fixed top-4 left-1/2 z-50 w-full max-w-sm -translate-x-1/2">
|
|
20
|
+
<div
|
|
21
|
+
id="admin-toast"
|
|
22
|
+
role={isError ? "alert" : "status"}
|
|
23
|
+
class={cn(
|
|
24
|
+
"bg-card flex items-center gap-2 rounded-lg border px-5 py-3 text-sm shadow-lg",
|
|
25
|
+
isError ? "border-red-500/30" : "border-green-500/30",
|
|
26
|
+
)}
|
|
27
|
+
style={`animation: toast-in 0.25s ease-out, toast-out 0.3s ease-in ${isError ? "4.7s" : "2.7s"} forwards`}
|
|
28
|
+
>
|
|
29
|
+
{
|
|
30
|
+
isError ? (
|
|
31
|
+
<CircleAlert className="size-4 shrink-0 text-red-600 dark:text-red-400" />
|
|
32
|
+
) : (
|
|
33
|
+
<CircleCheck className="size-4 shrink-0 text-green-600 dark:text-green-400" />
|
|
34
|
+
)
|
|
35
|
+
}
|
|
36
|
+
<span class={cn(isError ? "text-red-700 dark:text-red-400" : "text-green-700 dark:text-green-400")}>
|
|
37
|
+
{message}
|
|
38
|
+
</span>
|
|
39
|
+
</div>
|
|
40
|
+
</div>
|