@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,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">&#10003;</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">&#10003;</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">&#10003;</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>