@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,149 @@
|
|
|
1
|
+
import { useState, useRef, useCallback, useEffect } from "react";
|
|
2
|
+
import { ImagePlus, Loader2, Upload, X } from "lucide-react";
|
|
3
|
+
import { cn, thumbnail } from "../lib/utils";
|
|
4
|
+
import { Button } from "./ui/button";
|
|
5
|
+
import ImageBrowseDialog from "./ImageBrowseDialog";
|
|
6
|
+
|
|
7
|
+
type Props = {
|
|
8
|
+
name: string;
|
|
9
|
+
value?: string;
|
|
10
|
+
placeholder?: string;
|
|
11
|
+
onChange?: (value: string) => void;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
export default function ImagePicker({ name, value: initialValue, onChange: onChangeProp }: Props) {
|
|
15
|
+
const [value, setValue] = useState(initialValue ?? "");
|
|
16
|
+
const [assetId, setAssetId] = useState<string | null>(null);
|
|
17
|
+
const [localPreview, setLocalPreview] = useState<string | null>(null);
|
|
18
|
+
const [uploading, setUploading] = useState(false);
|
|
19
|
+
const [open, setOpen] = useState(false);
|
|
20
|
+
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
21
|
+
const hiddenRef = useRef<HTMLInputElement>(null);
|
|
22
|
+
|
|
23
|
+
// Resolve asset ID from URL on mount
|
|
24
|
+
useEffect(() => {
|
|
25
|
+
if (!value) return;
|
|
26
|
+
fetch(`/api/cms/assets?url=${encodeURIComponent(value)}`)
|
|
27
|
+
.then((res) => (res.ok ? res.json() : null))
|
|
28
|
+
.then((asset) => {
|
|
29
|
+
if (asset?._id) setAssetId(asset._id);
|
|
30
|
+
})
|
|
31
|
+
.catch(() => {});
|
|
32
|
+
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
|
33
|
+
|
|
34
|
+
// Notify the form of value changes so UnsavedGuard detects them
|
|
35
|
+
const prevValueRef = useRef(value);
|
|
36
|
+
useEffect(() => {
|
|
37
|
+
if (prevValueRef.current !== value) {
|
|
38
|
+
prevValueRef.current = value;
|
|
39
|
+
hiddenRef.current?.dispatchEvent(new Event("change", { bubbles: true }));
|
|
40
|
+
}
|
|
41
|
+
}, [value]);
|
|
42
|
+
|
|
43
|
+
const handleUpload = useCallback(
|
|
44
|
+
async (file: File) => {
|
|
45
|
+
setUploading(true);
|
|
46
|
+
try {
|
|
47
|
+
setLocalPreview(URL.createObjectURL(file));
|
|
48
|
+
|
|
49
|
+
const formData = new FormData();
|
|
50
|
+
formData.append("file", file);
|
|
51
|
+
const res = await fetch("/api/cms/assets/upload", { method: "POST", body: formData });
|
|
52
|
+
if (!res.ok) throw new Error("Upload failed");
|
|
53
|
+
const asset = await res.json();
|
|
54
|
+
setValue(asset.url);
|
|
55
|
+
setAssetId(asset._id);
|
|
56
|
+
onChangeProp?.(asset.url);
|
|
57
|
+
} catch (e) {
|
|
58
|
+
console.error("Upload failed:", e);
|
|
59
|
+
setLocalPreview(null);
|
|
60
|
+
} finally {
|
|
61
|
+
setUploading(false);
|
|
62
|
+
}
|
|
63
|
+
},
|
|
64
|
+
[onChangeProp],
|
|
65
|
+
);
|
|
66
|
+
|
|
67
|
+
const handleFileChange = useCallback(
|
|
68
|
+
(e: React.ChangeEvent<HTMLInputElement>) => {
|
|
69
|
+
const file = e.target.files?.[0];
|
|
70
|
+
if (file) handleUpload(file);
|
|
71
|
+
},
|
|
72
|
+
[handleUpload],
|
|
73
|
+
);
|
|
74
|
+
|
|
75
|
+
const imgSrc = localPreview ?? thumbnail(value);
|
|
76
|
+
const isImage =
|
|
77
|
+
value && (value.match(/\.(jpg|jpeg|png|gif|webp|avif|svg)$/i) || value.startsWith("http") || localPreview);
|
|
78
|
+
|
|
79
|
+
return (
|
|
80
|
+
<div className="space-y-3">
|
|
81
|
+
<input ref={hiddenRef} type="hidden" name={name} value={value} />
|
|
82
|
+
|
|
83
|
+
{value && (
|
|
84
|
+
<div className="group relative inline-block">
|
|
85
|
+
{isImage ? (
|
|
86
|
+
<a
|
|
87
|
+
href={assetId ? `/admin/assets/${assetId}` : undefined}
|
|
88
|
+
target="_blank"
|
|
89
|
+
className={cn(
|
|
90
|
+
"block size-40 overflow-hidden rounded-lg border",
|
|
91
|
+
assetId && "hover:border-foreground/50 cursor-pointer",
|
|
92
|
+
)}
|
|
93
|
+
>
|
|
94
|
+
<img src={imgSrc} alt="" className="size-full object-cover" />
|
|
95
|
+
</a>
|
|
96
|
+
) : (
|
|
97
|
+
<div className="bg-muted/30 flex size-40 items-center justify-center rounded-lg border">
|
|
98
|
+
<span className="text-muted-foreground truncate px-4 text-sm">{value}</span>
|
|
99
|
+
</div>
|
|
100
|
+
)}
|
|
101
|
+
<button
|
|
102
|
+
type="button"
|
|
103
|
+
title="Remove image"
|
|
104
|
+
onClick={() => {
|
|
105
|
+
if (localPreview) URL.revokeObjectURL(localPreview);
|
|
106
|
+
setLocalPreview(null);
|
|
107
|
+
setValue("");
|
|
108
|
+
setAssetId(null);
|
|
109
|
+
onChangeProp?.("");
|
|
110
|
+
}}
|
|
111
|
+
className="border-foreground/25 hover:border-foreground bg-background/80 absolute top-2 right-2 flex size-5 items-center justify-center rounded border opacity-0 backdrop-blur-sm transition-[opacity,border-color] group-hover:opacity-100"
|
|
112
|
+
>
|
|
113
|
+
<X className="size-3" />
|
|
114
|
+
</button>
|
|
115
|
+
</div>
|
|
116
|
+
)}
|
|
117
|
+
|
|
118
|
+
<div className="flex gap-2">
|
|
119
|
+
<input ref={fileInputRef} type="file" accept="image/*" className="hidden" onChange={handleFileChange} />
|
|
120
|
+
<Button
|
|
121
|
+
type="button"
|
|
122
|
+
variant="outline"
|
|
123
|
+
className="text-foreground/70"
|
|
124
|
+
onClick={() => fileInputRef.current?.click()}
|
|
125
|
+
disabled={uploading}
|
|
126
|
+
>
|
|
127
|
+
{uploading ? <Loader2 className="size-3 animate-spin" /> : <Upload className="size-4 stroke-1" />}
|
|
128
|
+
Upload
|
|
129
|
+
</Button>
|
|
130
|
+
<Button type="button" variant="outline" className="text-foreground/70" onClick={() => setOpen(true)}>
|
|
131
|
+
<ImagePlus className="size-4 stroke-1" />
|
|
132
|
+
Browse
|
|
133
|
+
</Button>
|
|
134
|
+
</div>
|
|
135
|
+
|
|
136
|
+
<ImageBrowseDialog
|
|
137
|
+
open={open}
|
|
138
|
+
onOpenChange={setOpen}
|
|
139
|
+
onSelect={(asset) => {
|
|
140
|
+
if (localPreview) URL.revokeObjectURL(localPreview);
|
|
141
|
+
setLocalPreview(null);
|
|
142
|
+
setValue(asset.url);
|
|
143
|
+
setAssetId(asset._id);
|
|
144
|
+
onChangeProp?.(asset.url);
|
|
145
|
+
}}
|
|
146
|
+
/>
|
|
147
|
+
</div>
|
|
148
|
+
);
|
|
149
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import * as React from "react";
|
|
4
|
+
import { Check, ChevronsUpDown } from "lucide-react";
|
|
5
|
+
import { Button } from "./ui/button";
|
|
6
|
+
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "./ui/command";
|
|
7
|
+
import { Popover, PopoverContent, PopoverTrigger } from "./ui/popover";
|
|
8
|
+
import { cn } from "../lib/utils";
|
|
9
|
+
|
|
10
|
+
export type LinkOptionGroup = {
|
|
11
|
+
collection: string;
|
|
12
|
+
label: string;
|
|
13
|
+
items: Array<{ id: string; label: string; href: string }>;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export default function InternalLinkPicker({
|
|
17
|
+
editHref,
|
|
18
|
+
linkOptions,
|
|
19
|
+
onSelect,
|
|
20
|
+
}: {
|
|
21
|
+
editHref: string;
|
|
22
|
+
linkOptions: LinkOptionGroup[];
|
|
23
|
+
onSelect: (item: { id: string; label: string; href: string }) => void;
|
|
24
|
+
}) {
|
|
25
|
+
const [open, setOpen] = React.useState(false);
|
|
26
|
+
|
|
27
|
+
const selectedLabel = React.useMemo(() => {
|
|
28
|
+
if (!editHref) return "";
|
|
29
|
+
for (const group of linkOptions) {
|
|
30
|
+
const found = group.items.find((item) => item.href === editHref);
|
|
31
|
+
if (found) return found.label;
|
|
32
|
+
}
|
|
33
|
+
return editHref;
|
|
34
|
+
}, [editHref, linkOptions]);
|
|
35
|
+
|
|
36
|
+
return (
|
|
37
|
+
<div className="min-w-0 flex-3">
|
|
38
|
+
<Popover open={open} onOpenChange={setOpen}>
|
|
39
|
+
<PopoverTrigger asChild>
|
|
40
|
+
<Button
|
|
41
|
+
variant="outline"
|
|
42
|
+
role="combobox"
|
|
43
|
+
aria-expanded={open}
|
|
44
|
+
className="h-7 w-full justify-between font-normal"
|
|
45
|
+
>
|
|
46
|
+
<span className={cn("truncate", !selectedLabel && "text-muted-foreground")}>
|
|
47
|
+
{selectedLabel || "Search documents..."}
|
|
48
|
+
</span>
|
|
49
|
+
<ChevronsUpDown className="ml-2 size-3.5 shrink-0 opacity-50" />
|
|
50
|
+
</Button>
|
|
51
|
+
</PopoverTrigger>
|
|
52
|
+
<PopoverContent className="w-(--radix-popover-trigger-width) p-0" align="start">
|
|
53
|
+
<Command>
|
|
54
|
+
<CommandInput placeholder="Search documents..." />
|
|
55
|
+
<CommandList>
|
|
56
|
+
<CommandEmpty>No documents found.</CommandEmpty>
|
|
57
|
+
{linkOptions.map((group) => (
|
|
58
|
+
<CommandGroup key={group.collection} heading={group.label}>
|
|
59
|
+
{group.items.map((item) => (
|
|
60
|
+
<CommandItem
|
|
61
|
+
key={item.id}
|
|
62
|
+
value={`${item.label} ${item.href}`}
|
|
63
|
+
onSelect={() => {
|
|
64
|
+
onSelect(item);
|
|
65
|
+
setOpen(false);
|
|
66
|
+
}}
|
|
67
|
+
>
|
|
68
|
+
<Check className={cn("size-4", editHref === item.href ? "opacity-100" : "opacity-0")} />
|
|
69
|
+
{item.label}
|
|
70
|
+
</CommandItem>
|
|
71
|
+
))}
|
|
72
|
+
</CommandGroup>
|
|
73
|
+
))}
|
|
74
|
+
</CommandList>
|
|
75
|
+
</Command>
|
|
76
|
+
</PopoverContent>
|
|
77
|
+
</Popover>
|
|
78
|
+
</div>
|
|
79
|
+
);
|
|
80
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useEffect, useState } from "react";
|
|
4
|
+
|
|
5
|
+
export default function LiveHeading({ initial, inputName = "title" }: { initial: string; inputName?: string }) {
|
|
6
|
+
const [text, setText] = useState(initial);
|
|
7
|
+
|
|
8
|
+
useEffect(() => {
|
|
9
|
+
const input = document.querySelector<HTMLInputElement>(`[name="${inputName}"]`);
|
|
10
|
+
if (!input) return;
|
|
11
|
+
const handler = () => setText(input.value || initial);
|
|
12
|
+
input.addEventListener("input", handler);
|
|
13
|
+
return () => input.removeEventListener("input", handler);
|
|
14
|
+
}, [initial, inputName]);
|
|
15
|
+
|
|
16
|
+
return <>{text}</>;
|
|
17
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { Menu } from "lucide-react";
|
|
4
|
+
import { useState } from "react";
|
|
5
|
+
|
|
6
|
+
import { Button } from "./ui/button";
|
|
7
|
+
import { Sheet, SheetContent, SheetTitle } from "./ui/sheet";
|
|
8
|
+
|
|
9
|
+
export default function MobileSidebar({ children }: { children: React.ReactNode }) {
|
|
10
|
+
const [open, setOpen] = useState(false);
|
|
11
|
+
|
|
12
|
+
return (
|
|
13
|
+
<>
|
|
14
|
+
<Button variant="ghost" size="icon-sm" onClick={() => setOpen(true)}>
|
|
15
|
+
<Menu className="size-5" />
|
|
16
|
+
<span className="sr-only">Open menu</span>
|
|
17
|
+
</Button>
|
|
18
|
+
|
|
19
|
+
<Sheet open={open} onOpenChange={setOpen}>
|
|
20
|
+
<SheetContent side="left" className="w-68 p-0" showCloseButton={false}>
|
|
21
|
+
<SheetTitle className="sr-only">Navigation</SheetTitle>
|
|
22
|
+
<div className="flex h-full flex-col" onClick={() => setOpen(false)}>
|
|
23
|
+
{children}
|
|
24
|
+
</div>
|
|
25
|
+
</SheetContent>
|
|
26
|
+
</Sheet>
|
|
27
|
+
</>
|
|
28
|
+
);
|
|
29
|
+
}
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
4
|
+
import { Check, ChevronsUpDown, Plus, X } from "lucide-react";
|
|
5
|
+
|
|
6
|
+
import { Button } from "./ui/button";
|
|
7
|
+
import { Command, CommandEmpty, CommandInput, CommandItem, CommandList } from "./ui/command";
|
|
8
|
+
import { Popover, PopoverContent, PopoverTrigger } from "./ui/popover";
|
|
9
|
+
import { Sheet, SheetContent, SheetHeader, SheetTitle } from "./ui/sheet";
|
|
10
|
+
import { cn } from "../lib/utils";
|
|
11
|
+
|
|
12
|
+
type Option = { value: string; label: string };
|
|
13
|
+
|
|
14
|
+
type Props = {
|
|
15
|
+
name: string;
|
|
16
|
+
value?: string;
|
|
17
|
+
hasMany?: boolean;
|
|
18
|
+
options: Option[];
|
|
19
|
+
collectionSlug: string;
|
|
20
|
+
collectionLabel: string;
|
|
21
|
+
labelField?: string;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export default function RelationField({
|
|
25
|
+
name,
|
|
26
|
+
value: initialValue,
|
|
27
|
+
hasMany = false,
|
|
28
|
+
options: initialOptions,
|
|
29
|
+
collectionSlug,
|
|
30
|
+
collectionLabel,
|
|
31
|
+
labelField = "title",
|
|
32
|
+
}: Props) {
|
|
33
|
+
const [options, setOptions] = useState(initialOptions);
|
|
34
|
+
const [selected, setSelected] = useState<string[]>(() => {
|
|
35
|
+
if (!initialValue) return [];
|
|
36
|
+
if (hasMany) {
|
|
37
|
+
try {
|
|
38
|
+
const parsed = JSON.parse(initialValue);
|
|
39
|
+
return Array.isArray(parsed) ? parsed.map(String) : [];
|
|
40
|
+
} catch {
|
|
41
|
+
return [];
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return initialValue ? [initialValue] : [];
|
|
45
|
+
});
|
|
46
|
+
const [open, setOpen] = useState(false);
|
|
47
|
+
const [sheetOpen, setSheetOpen] = useState(false);
|
|
48
|
+
const hiddenRef = useRef<HTMLInputElement>(null);
|
|
49
|
+
const iframeRef = useRef<HTMLIFrameElement>(null);
|
|
50
|
+
|
|
51
|
+
const hiddenValue = hasMany ? JSON.stringify(selected) : (selected[0] ?? "");
|
|
52
|
+
|
|
53
|
+
// Notify form of changes so UnsavedGuard can detect them
|
|
54
|
+
useEffect(() => {
|
|
55
|
+
hiddenRef.current?.dispatchEvent(new Event("change", { bubbles: true }));
|
|
56
|
+
}, [hiddenValue]);
|
|
57
|
+
|
|
58
|
+
const getLabel = useCallback((id: string) => options.find((o) => o.value === id)?.label ?? id, [options]);
|
|
59
|
+
|
|
60
|
+
const displayLabel = useMemo(() => {
|
|
61
|
+
if (selected.length === 0) return "";
|
|
62
|
+
if (hasMany) return `${selected.length} selected`;
|
|
63
|
+
return getLabel(selected[0]);
|
|
64
|
+
}, [selected, hasMany, getLabel]);
|
|
65
|
+
|
|
66
|
+
const selectItem = (id: string) => {
|
|
67
|
+
if (hasMany) {
|
|
68
|
+
setSelected((prev) => (prev.includes(id) ? prev.filter((v) => v !== id) : [...prev, id]));
|
|
69
|
+
} else {
|
|
70
|
+
setSelected((prev) => (prev[0] === id ? [] : [id]));
|
|
71
|
+
setOpen(false);
|
|
72
|
+
}
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
const remove = (id: string) => {
|
|
76
|
+
setSelected((prev) => prev.filter((v) => v !== id));
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
// Listen for postMessage from embedded iframe after successful save
|
|
80
|
+
useEffect(() => {
|
|
81
|
+
if (!sheetOpen) return;
|
|
82
|
+
|
|
83
|
+
const handleMessage = (e: MessageEvent) => {
|
|
84
|
+
if (e.data?.type !== "cms:created") return;
|
|
85
|
+
const docId = String(e.data.id);
|
|
86
|
+
fetch(`/api/cms/${collectionSlug}/${docId}?status=any`)
|
|
87
|
+
.then((res) => (res.ok ? res.json() : null))
|
|
88
|
+
.then((doc) => {
|
|
89
|
+
if (doc) {
|
|
90
|
+
const label = String(doc[labelField] ?? doc.slug ?? docId);
|
|
91
|
+
setOptions((prev) => {
|
|
92
|
+
const exists = prev.some((o) => o.value === docId);
|
|
93
|
+
return exists
|
|
94
|
+
? prev.map((o) => (o.value === docId ? { ...o, label } : o))
|
|
95
|
+
: [{ value: docId, label }, ...prev];
|
|
96
|
+
});
|
|
97
|
+
if (hasMany) {
|
|
98
|
+
setSelected((prev) => (prev.includes(docId) ? prev : [...prev, docId]));
|
|
99
|
+
} else {
|
|
100
|
+
setSelected([docId]);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
setSheetOpen(false);
|
|
104
|
+
});
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
window.addEventListener("message", handleMessage);
|
|
108
|
+
return () => window.removeEventListener("message", handleMessage);
|
|
109
|
+
}, [sheetOpen, collectionSlug, hasMany, labelField]);
|
|
110
|
+
|
|
111
|
+
return (
|
|
112
|
+
<div className="space-y-2">
|
|
113
|
+
<input ref={hiddenRef} type="hidden" name={name} value={hiddenValue} />
|
|
114
|
+
|
|
115
|
+
{/* Selected items (hasMany chips) */}
|
|
116
|
+
{hasMany && selected.length > 0 && (
|
|
117
|
+
<div className="flex flex-wrap gap-1.5">
|
|
118
|
+
{selected.map((id) => (
|
|
119
|
+
<span
|
|
120
|
+
key={id}
|
|
121
|
+
className="bg-secondary text-secondary-foreground inline-flex items-center gap-1 rounded-md border px-2 py-0.5 text-sm"
|
|
122
|
+
>
|
|
123
|
+
{getLabel(id)}
|
|
124
|
+
<button
|
|
125
|
+
type="button"
|
|
126
|
+
title="Remove"
|
|
127
|
+
onClick={() => remove(id)}
|
|
128
|
+
className="text-muted-foreground hover:text-foreground -mr-0.5 rounded p-0.5"
|
|
129
|
+
>
|
|
130
|
+
<X className="size-3" />
|
|
131
|
+
</button>
|
|
132
|
+
</span>
|
|
133
|
+
))}
|
|
134
|
+
</div>
|
|
135
|
+
)}
|
|
136
|
+
|
|
137
|
+
{/* Combobox */}
|
|
138
|
+
<Popover open={open} onOpenChange={setOpen}>
|
|
139
|
+
<PopoverTrigger asChild>
|
|
140
|
+
<Button
|
|
141
|
+
variant="outline"
|
|
142
|
+
role="combobox"
|
|
143
|
+
aria-expanded={open}
|
|
144
|
+
size="lg"
|
|
145
|
+
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"
|
|
146
|
+
>
|
|
147
|
+
<span className={cn("truncate", !displayLabel && "text-muted-foreground")}>
|
|
148
|
+
{displayLabel || `Search ${collectionLabel.toLowerCase()}...`}
|
|
149
|
+
</span>
|
|
150
|
+
<ChevronsUpDown className="ml-2 size-4 shrink-0 opacity-50" />
|
|
151
|
+
</Button>
|
|
152
|
+
</PopoverTrigger>
|
|
153
|
+
<PopoverContent className="w-(--radix-popover-trigger-width) p-0" align="start">
|
|
154
|
+
<Command>
|
|
155
|
+
<CommandInput placeholder={`Search ${collectionLabel.toLowerCase()}...`} />
|
|
156
|
+
<CommandList>
|
|
157
|
+
<CommandEmpty>No results found.</CommandEmpty>
|
|
158
|
+
{options.map((o) => (
|
|
159
|
+
<CommandItem key={o.value} value={o.label} onSelect={() => selectItem(o.value)}>
|
|
160
|
+
<Check className={cn("ml-1 size-4", selected.includes(o.value) ? "opacity-100" : "opacity-0")} />
|
|
161
|
+
{o.label}
|
|
162
|
+
</CommandItem>
|
|
163
|
+
))}
|
|
164
|
+
</CommandList>
|
|
165
|
+
</Command>
|
|
166
|
+
</PopoverContent>
|
|
167
|
+
</Popover>
|
|
168
|
+
|
|
169
|
+
{/* Create new button */}
|
|
170
|
+
<Button
|
|
171
|
+
type="button"
|
|
172
|
+
variant="outline"
|
|
173
|
+
size="sm"
|
|
174
|
+
className="text-foreground/70"
|
|
175
|
+
onClick={() => setSheetOpen(true)}
|
|
176
|
+
>
|
|
177
|
+
<Plus className="size-3.5" />
|
|
178
|
+
Create {collectionLabel.toLowerCase()}
|
|
179
|
+
</Button>
|
|
180
|
+
|
|
181
|
+
{/* Create sheet — full-width iframe with the actual add-new page */}
|
|
182
|
+
<Sheet open={sheetOpen} onOpenChange={setSheetOpen}>
|
|
183
|
+
<SheetContent
|
|
184
|
+
side="right"
|
|
185
|
+
className="data-[side=right]:w-[90vw] data-[side=right]:sm:max-w-[90vw] data-[side=right]:lg:w-[50vw] data-[side=right]:lg:max-w-[50vw]"
|
|
186
|
+
>
|
|
187
|
+
<SheetHeader className="sr-only">
|
|
188
|
+
<SheetTitle>Create {collectionLabel.toLowerCase()}</SheetTitle>
|
|
189
|
+
</SheetHeader>
|
|
190
|
+
<div className="flex-1 overflow-hidden">
|
|
191
|
+
{sheetOpen && (
|
|
192
|
+
<iframe
|
|
193
|
+
ref={iframeRef}
|
|
194
|
+
src={`/admin/${collectionSlug}/new?_embed=1`}
|
|
195
|
+
title={`Create ${collectionLabel}`}
|
|
196
|
+
className="size-full"
|
|
197
|
+
/>
|
|
198
|
+
)}
|
|
199
|
+
</div>
|
|
200
|
+
</SheetContent>
|
|
201
|
+
</Sheet>
|
|
202
|
+
</div>
|
|
203
|
+
);
|
|
204
|
+
}
|