@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,31 @@
1
+ import { useState, useRef } from "react";
2
+ import { Checkbox } from "./ui/checkbox";
3
+
4
+ type Props = {
5
+ name: string;
6
+ checked?: boolean;
7
+ disabled?: boolean;
8
+ };
9
+
10
+ export default function CheckboxField({ name, checked: initial = false, disabled }: Props) {
11
+ const [checked, setChecked] = useState(initial);
12
+ const hiddenRef = useRef<HTMLInputElement>(null);
13
+
14
+ return (
15
+ <label className="group inline-flex cursor-pointer items-center gap-2.5 text-sm">
16
+ <input type="hidden" name={name} value={checked ? "true" : "false"} ref={hiddenRef} />
17
+ <Checkbox
18
+ className="group-hover:border-primary/60 disabled:group-hover:border-input"
19
+ checked={checked}
20
+ onCheckedChange={(v) => {
21
+ setChecked(Boolean(v));
22
+ setTimeout(() => {
23
+ hiddenRef.current?.dispatchEvent(new Event("change", { bubbles: true }));
24
+ }, 0);
25
+ }}
26
+ disabled={disabled}
27
+ />
28
+ <span className="text-foreground select-none">{checked ? "Enabled" : "Disabled"}</span>
29
+ </label>
30
+ );
31
+ }
@@ -0,0 +1,317 @@
1
+ "use client";
2
+
3
+ import { useState } from "react";
4
+ import { CalendarClock, CheckIcon, EllipsisVertical } from "lucide-react";
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
+ import {
16
+ Dialog,
17
+ DialogClose,
18
+ DialogContent,
19
+ DialogDescription,
20
+ DialogFooter,
21
+ DialogHeader,
22
+ DialogTitle,
23
+ } from "./ui/dialog";
24
+ import {
25
+ DropdownMenu,
26
+ DropdownMenuContent,
27
+ DropdownMenuItem,
28
+ DropdownMenuSeparator,
29
+ DropdownMenuSub,
30
+ DropdownMenuSubContent,
31
+ DropdownMenuSubTrigger,
32
+ DropdownMenuTrigger,
33
+ } from "./ui/dropdown-menu";
34
+
35
+ type Version = {
36
+ version: number;
37
+ createdAt: string;
38
+ };
39
+
40
+ type Props = {
41
+ formId: string;
42
+ collectionSlug?: string;
43
+ documentId?: string;
44
+ showUnpublish?: boolean;
45
+ showDiscardDraft?: boolean;
46
+ showDelete?: boolean;
47
+ showDuplicate?: boolean;
48
+ showSchedule?: boolean;
49
+ showCancelSchedule?: boolean;
50
+ currentPublishAt?: string;
51
+ currentUnpublishAt?: string;
52
+ versions?: Version[];
53
+ restoreEndpoint?: string;
54
+ redirectTo?: string;
55
+ };
56
+
57
+ export default function DocumentActions({
58
+ formId,
59
+ collectionSlug,
60
+ documentId,
61
+ showUnpublish,
62
+ showDiscardDraft,
63
+ showDelete,
64
+ showDuplicate,
65
+ showSchedule,
66
+ showCancelSchedule,
67
+ currentPublishAt,
68
+ currentUnpublishAt,
69
+ versions = [],
70
+ restoreEndpoint,
71
+ redirectTo,
72
+ }: Props) {
73
+ const [scheduleOpen, setScheduleOpen] = useState(false);
74
+ const [deleteOpen, setDeleteOpen] = useState(false);
75
+ const [refWarning, setRefWarning] = useState<string | null>(null);
76
+ const [publishAt, setPublishAt] = useState(currentPublishAt ? toLocalDatetime(currentPublishAt) : "");
77
+ const [unpublishAt, setUnpublishAt] = useState(currentUnpublishAt ? toLocalDatetime(currentUnpublishAt) : "");
78
+
79
+ const canDuplicate = !!(showDuplicate && collectionSlug && documentId);
80
+ const hasActions =
81
+ canDuplicate || showUnpublish || showDiscardDraft || showDelete || showSchedule || showCancelSchedule || versions.length > 0;
82
+ if (!hasActions) return null;
83
+
84
+ const duplicate = async () => {
85
+ if (!collectionSlug || !documentId) return;
86
+ try {
87
+ const res = await fetch(`/api/cms/${collectionSlug}/${documentId}/duplicate`, {
88
+ method: "POST",
89
+ headers: { Accept: "application/json" },
90
+ });
91
+ if (!res.ok) throw new Error("Duplicate failed");
92
+ const created = await res.json();
93
+ window.location.assign(`/admin/${collectionSlug}/${created._id}?_toast=success&_msg=Document+duplicated`);
94
+ } catch {
95
+ window.location.assign(`${window.location.pathname}?_toast=error&_msg=Failed+to+duplicate`);
96
+ }
97
+ };
98
+
99
+ const submitAction = (action: string) => {
100
+ const form = document.getElementById(formId) as HTMLFormElement | null;
101
+ if (!form) return;
102
+ const input = form.querySelector<HTMLInputElement>('input[name="_action"]');
103
+ if (input) input.value = action;
104
+ form.submit();
105
+ };
106
+
107
+ const submitSchedule = () => {
108
+ if (!publishAt) return;
109
+
110
+ const form = document.getElementById(formId) as HTMLFormElement | null;
111
+ if (!form) return;
112
+
113
+ const intentInput = form.querySelector<HTMLInputElement>('input[name="_intent"]');
114
+ if (!intentInput) {
115
+ const hidden = document.createElement("input");
116
+ hidden.type = "hidden";
117
+ hidden.name = "_intent";
118
+ hidden.value = "schedule";
119
+ form.appendChild(hidden);
120
+ } else {
121
+ intentInput.value = "schedule";
122
+ }
123
+
124
+ // Inject _publishAt
125
+ let publishAtInput = form.querySelector<HTMLInputElement>('input[name="_publishAt"]');
126
+ if (!publishAtInput) {
127
+ publishAtInput = document.createElement("input");
128
+ publishAtInput.type = "hidden";
129
+ publishAtInput.name = "_publishAt";
130
+ form.appendChild(publishAtInput);
131
+ }
132
+ publishAtInput.value = new Date(publishAt).toISOString();
133
+
134
+ // Inject _unpublishAt
135
+ let unpublishAtInput = form.querySelector<HTMLInputElement>('input[name="_unpublishAt"]');
136
+ if (!unpublishAtInput) {
137
+ unpublishAtInput = document.createElement("input");
138
+ unpublishAtInput.type = "hidden";
139
+ unpublishAtInput.name = "_unpublishAt";
140
+ form.appendChild(unpublishAtInput);
141
+ }
142
+ unpublishAtInput.value = unpublishAt ? new Date(unpublishAt).toISOString() : "";
143
+
144
+ form.submit();
145
+ };
146
+
147
+ const restoreVersion = (version: number) => {
148
+ if (!restoreEndpoint) return;
149
+ const form = document.createElement("form");
150
+ form.method = "post";
151
+ form.action = restoreEndpoint;
152
+ form.innerHTML = `
153
+ <input type="hidden" name="_action" value="restore" />
154
+ <input type="hidden" name="version" value="${version}" />
155
+ <input type="hidden" name="redirectTo" value="${redirectTo ?? window.location.pathname + window.location.search}" />
156
+ `;
157
+ document.body.appendChild(form);
158
+ form.submit();
159
+ };
160
+
161
+ const sortedVersions = versions.slice().sort((a, b) => Number(b.version) - Number(a.version));
162
+ const latestVersion = sortedVersions.length > 0 ? Number(sortedVersions[0].version) : undefined;
163
+
164
+ return (
165
+ <>
166
+ <DropdownMenu>
167
+ <DropdownMenuTrigger asChild>
168
+ <Button variant="ghost" size="icon-sm" aria-label="More actions">
169
+ <EllipsisVertical className="size-4" />
170
+ </Button>
171
+ </DropdownMenuTrigger>
172
+ <DropdownMenuContent align="end" className="w-48">
173
+ {canDuplicate && <DropdownMenuItem onClick={duplicate}>Duplicate</DropdownMenuItem>}
174
+ {showSchedule && <DropdownMenuItem onClick={() => setScheduleOpen(true)}>Schedule publish</DropdownMenuItem>}
175
+ {showCancelSchedule && (
176
+ <DropdownMenuItem onClick={() => submitAction("unpublish")}>Cancel schedule</DropdownMenuItem>
177
+ )}
178
+ {showDiscardDraft && (
179
+ <DropdownMenuItem onClick={() => submitAction("discard-draft")}>Discard changes</DropdownMenuItem>
180
+ )}
181
+ {showUnpublish && (
182
+ <DropdownMenuItem onClick={() => submitAction("unpublish")}>Move to draft</DropdownMenuItem>
183
+ )}
184
+ {sortedVersions.length > 0 && (
185
+ <DropdownMenuSub>
186
+ <DropdownMenuSubTrigger>Restore version</DropdownMenuSubTrigger>
187
+ <DropdownMenuSubContent className="max-h-64 overflow-y-auto">
188
+ {sortedVersions.map((v) => {
189
+ const vNum = Number(v.version);
190
+ const isCurrent = vNum === latestVersion;
191
+ return (
192
+ <DropdownMenuItem
193
+ key={vNum}
194
+ disabled={isCurrent}
195
+ onClick={() => restoreVersion(vNum)}
196
+ className="flex items-center justify-between gap-3"
197
+ >
198
+ <span>
199
+ v{vNum}
200
+ {isCurrent ? " (current)" : ""}
201
+ </span>
202
+ {isCurrent && <CheckIcon className="text-muted-foreground size-3.5" />}
203
+ </DropdownMenuItem>
204
+ );
205
+ })}
206
+ </DropdownMenuSubContent>
207
+ </DropdownMenuSub>
208
+ )}
209
+ {(showUnpublish || showSchedule || showCancelSchedule || sortedVersions.length > 0) && showDelete && (
210
+ <DropdownMenuSeparator />
211
+ )}
212
+ {showDelete && (
213
+ <DropdownMenuItem
214
+ variant="destructive"
215
+ onClick={async () => {
216
+ setRefWarning(null);
217
+ if (collectionSlug && documentId) {
218
+ try {
219
+ const res = await fetch(`/api/cms/references/${collectionSlug}/${documentId}`);
220
+ if (res.ok) {
221
+ const { refs, total } = await res.json();
222
+ if (total > 0) {
223
+ const parts = refs.map(
224
+ (r: { collection: string; count: number }) => `${r.count} ${r.collection.toLowerCase()}`,
225
+ );
226
+ setRefWarning(
227
+ `This document is referenced by ${parts.join(", ")}. Deleting it will leave broken references.`,
228
+ );
229
+ }
230
+ }
231
+ } catch {}
232
+ }
233
+ setDeleteOpen(true);
234
+ }}
235
+ >
236
+ Delete
237
+ </DropdownMenuItem>
238
+ )}
239
+ </DropdownMenuContent>
240
+ </DropdownMenu>
241
+
242
+ <Dialog open={scheduleOpen} onOpenChange={setScheduleOpen}>
243
+ <DialogContent>
244
+ <DialogHeader>
245
+ <DialogTitle>Schedule publish</DialogTitle>
246
+ <DialogDescription>Set a date and time for this document to be published automatically.</DialogDescription>
247
+ </DialogHeader>
248
+ <div className="grid gap-4 py-2">
249
+ <div className="grid gap-2">
250
+ <label htmlFor="schedule-publish-at" className="text-sm font-medium">
251
+ Publish at
252
+ </label>
253
+ <input
254
+ id="schedule-publish-at"
255
+ type="datetime-local"
256
+ value={publishAt}
257
+ onChange={(e) => setPublishAt(e.target.value)}
258
+ className="border-input bg-background ring-offset-background placeholder:text-muted-foreground focus-visible:ring-ring flex h-9 w-full rounded-md border px-3 py-1 text-sm shadow-sm transition-colors focus-visible:ring-1 focus-visible:outline-none"
259
+ />
260
+ </div>
261
+ <div className="grid gap-2">
262
+ <label htmlFor="schedule-unpublish-at" className="text-sm font-medium">
263
+ Unpublish at <span className="text-muted-foreground">(optional)</span>
264
+ </label>
265
+ <input
266
+ id="schedule-unpublish-at"
267
+ type="datetime-local"
268
+ value={unpublishAt}
269
+ onChange={(e) => setUnpublishAt(e.target.value)}
270
+ className="border-input bg-background ring-offset-background placeholder:text-muted-foreground focus-visible:ring-ring flex h-9 w-full rounded-md border px-3 py-1 text-sm shadow-sm transition-colors focus-visible:ring-1 focus-visible:outline-none"
271
+ />
272
+ </div>
273
+ </div>
274
+ <DialogFooter>
275
+ <DialogClose>
276
+ <Button variant="outline">Cancel</Button>
277
+ </DialogClose>
278
+ <Button onClick={submitSchedule} disabled={!publishAt}>
279
+ <CalendarClock className="mr-2 size-4" />
280
+ Schedule
281
+ </Button>
282
+ </DialogFooter>
283
+ </DialogContent>
284
+ </Dialog>
285
+
286
+ <AlertDialog open={deleteOpen} onOpenChange={setDeleteOpen}>
287
+ <AlertDialogContent>
288
+ <AlertDialogHeader>
289
+ <AlertDialogTitle>Delete document</AlertDialogTitle>
290
+ <AlertDialogDescription>
291
+ {refWarning && (
292
+ <span className="mb-2 block font-medium text-amber-600 dark:text-amber-400">{refWarning}</span>
293
+ )}
294
+ This action cannot be undone. This will permanently delete this document.
295
+ </AlertDialogDescription>
296
+ </AlertDialogHeader>
297
+ <AlertDialogFooter>
298
+ <AlertDialogClose>
299
+ <Button variant="outline">Cancel</Button>
300
+ </AlertDialogClose>
301
+ <Button variant="destructive" onClick={() => submitAction("delete")}>
302
+ Delete
303
+ </Button>
304
+ </AlertDialogFooter>
305
+ </AlertDialogContent>
306
+ </AlertDialog>
307
+ </>
308
+ );
309
+ }
310
+
311
+ function toLocalDatetime(iso: string): string {
312
+ const date = new Date(iso);
313
+ if (isNaN(date.getTime())) return "";
314
+ const offset = date.getTimezoneOffset();
315
+ const local = new Date(date.getTime() - offset * 60000);
316
+ return local.toISOString().slice(0, 16);
317
+ }
@@ -0,0 +1,54 @@
1
+ import { useEffect, useState } from "react";
2
+ import { Lock } from "lucide-react";
3
+
4
+ const HEARTBEAT_MS = 2 * 60 * 1000;
5
+
6
+ export default function DocumentLock({ collection, documentId }: { collection: string; documentId: string }) {
7
+ const [lockedBy, setLockedBy] = useState<string | null>(null);
8
+ const endpoint = `/api/cms/locks/${collection}/${documentId}`;
9
+
10
+ useEffect(() => {
11
+ if (lockedBy) return;
12
+
13
+ const acquire = () =>
14
+ fetch(endpoint, { method: "POST" })
15
+ .then((r) => r.json())
16
+ .then((data) => {
17
+ if (!data.acquired) setLockedBy(data.userEmail);
18
+ })
19
+ .catch(() => {});
20
+
21
+ acquire();
22
+ const interval = setInterval(acquire, HEARTBEAT_MS);
23
+
24
+ const release = () => navigator.sendBeacon(`${endpoint}?_method=DELETE`);
25
+ window.addEventListener("beforeunload", release);
26
+
27
+ return () => {
28
+ clearInterval(interval);
29
+ window.removeEventListener("beforeunload", release);
30
+ fetch(endpoint, { method: "DELETE" }).catch(() => {});
31
+ };
32
+ }, [endpoint, lockedBy]);
33
+
34
+ // Hide edit area and buttons when locked
35
+ useEffect(() => {
36
+ if (!lockedBy) return;
37
+ const editArea = document.getElementById("document-edit-area");
38
+ if (editArea) editArea.style.display = "none";
39
+ document.querySelectorAll<HTMLElement>('[form="document-form"], [form="translation-form"]').forEach((el) => {
40
+ el.style.display = "none";
41
+ });
42
+ }, [lockedBy]);
43
+
44
+ if (!lockedBy) return null;
45
+
46
+ return (
47
+ <div className="bg-destructive/10 text-destructive border-destructive/20 flex items-center gap-2 rounded-md border px-4 py-3 text-sm">
48
+ <Lock className="size-4 shrink-0" />
49
+ <span>
50
+ This document is currently being edited by <strong>{lockedBy}</strong>. You cannot edit it until they are done.
51
+ </span>
52
+ </div>
53
+ );
54
+ }