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