@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,397 @@
1
+ ---
2
+ import type { FieldConfig } from "@kidecms/core";
3
+ import { serializeFieldValue, humanize } from "@kidecms/core";
4
+ import { customFields } from "virtual:kide/custom-fields";
5
+ import ImagePicker from "./ImagePicker";
6
+ import TreeItemsEditor from "./TreeItemsEditor";
7
+ import RichTextEditor from "./RichTextEditor";
8
+ import SelectField from "./SelectField";
9
+ import RelationField from "./RelationField";
10
+ import BlockEditor from "./BlockEditor";
11
+ import SlugField from "./SlugField";
12
+ import CheckboxField from "./CheckboxField";
13
+ import TaxonomySelect from "./TaxonomySelect";
14
+ import { Input } from "./ui/input";
15
+ import { Label } from "./ui/label";
16
+ import { Textarea } from "./ui/textarea";
17
+ import { cn } from "../lib/utils";
18
+
19
+ type RelationOption = {
20
+ value: string;
21
+ label: string;
22
+ };
23
+
24
+ type RelationMeta = {
25
+ collectionSlug: string;
26
+ collectionLabel: string;
27
+ hasMany: boolean;
28
+ labelField?: string;
29
+ };
30
+
31
+ type LinkOptionGroup = {
32
+ collection: string;
33
+ label: string;
34
+ items: Array<{ id: string; label: string; href: string }>;
35
+ };
36
+
37
+ const {
38
+ name,
39
+ field,
40
+ value,
41
+ relationOptions = [],
42
+ relationMeta,
43
+ readOnly = false,
44
+ menuLinkOptions = [],
45
+ blockRelationOptions = {},
46
+ } = Astro.props as {
47
+ name: string;
48
+ field: FieldConfig;
49
+ value?: unknown;
50
+ relationOptions?: RelationOption[];
51
+ relationMeta?: RelationMeta;
52
+ readOnly?: boolean;
53
+ menuLinkOptions?: LinkOptionGroup[];
54
+ blockRelationOptions?: Record<string, RelationOption[]>;
55
+ };
56
+
57
+ const label = field.label ?? humanize(name);
58
+ const placeholder = field.admin?.placeholder ?? "";
59
+ const serializedValue = serializeFieldValue(field, value);
60
+ const rows =
61
+ field.admin?.rows ?? (field.type === "richText" ? 10 : field.type === "json" || field.type === "blocks" ? 12 : 5);
62
+ const inputMode = field.type === "number" ? "decimal" : undefined;
63
+ const controlClass = cn("w-full shadow-none", readOnly && "cursor-not-allowed bg-muted text-muted-foreground");
64
+ const isTreeEditor =
65
+ field.type === "json" && (field.admin?.component === "menu-items" || field.admin?.component === "taxonomy-terms");
66
+
67
+ // Custom field component: if admin.component is set and not a built-in variant, look for user-provided component
68
+ const builtInComponents = new Set(["radio", "taxonomy-select", "menu-items", "taxonomy-terms"]);
69
+ const CustomComponent =
70
+ field.admin?.component && !builtInComponents.has(field.admin.component)
71
+ ? customFields[field.admin.component] ?? null
72
+ : null;
73
+
74
+ // Build serializable block types metadata for BlockEditor
75
+ const blockTypesMeta =
76
+ field.type === "blocks" && field.types
77
+ ? Object.fromEntries(
78
+ Object.entries(field.types).map(([typeName, typeFields]) => [
79
+ typeName,
80
+ Object.fromEntries(
81
+ Object.entries(typeFields).map(([fieldName, subField]) => [
82
+ fieldName,
83
+ {
84
+ type: subField.type,
85
+ label: subField.label,
86
+ required: subField.required,
87
+ options: "options" in subField ? subField.options : undefined,
88
+ from: "from" in subField ? subField.from : undefined,
89
+ admin: subField.admin,
90
+ defaultValue: subField.defaultValue,
91
+ of: "of" in subField && subField.of ? { type: subField.of.type } : undefined,
92
+ collection: "collection" in subField ? subField.collection : undefined,
93
+ hasMany: "hasMany" in subField ? subField.hasMany : undefined,
94
+ },
95
+ ]),
96
+ ),
97
+ ]),
98
+ )
99
+ : {};
100
+ ---
101
+
102
+ <div
103
+ class="grid gap-2"
104
+ data-field-name={name}
105
+ {...field.condition
106
+ ? {
107
+ "data-condition-field": field.condition.field,
108
+ "data-condition-value": JSON.stringify(field.condition.value),
109
+ }
110
+ : {}}
111
+ >
112
+ {
113
+ !isTreeEditor && (
114
+ <Label htmlFor={name}>
115
+ {label}
116
+ {field.required ? " *" : ""}
117
+ </Label>
118
+ )
119
+ }
120
+ {field.description && <p class="text-muted-foreground -mt-1 text-xs leading-5">{field.description}</p>}
121
+ {field.admin?.help && <p class="text-muted-foreground -mt-1 -mb-0.5 text-xs leading-5">{field.admin.help}</p>}
122
+
123
+ {
124
+ CustomComponent && (
125
+ <CustomComponent
126
+ client:load
127
+ name={name}
128
+ field={field}
129
+ value={serializedValue}
130
+ readOnly={readOnly}
131
+ />
132
+ )
133
+ }
134
+
135
+ {!CustomComponent && (
136
+ <Fragment>
137
+
138
+ {
139
+ field.type === "image" && !readOnly && (
140
+ <ImagePicker client:load name={name} value={serializedValue} placeholder={placeholder} />
141
+ )
142
+ }
143
+
144
+ {
145
+ field.type === "image" && readOnly && (
146
+ <Input
147
+ className={controlClass}
148
+ type="text"
149
+ id={name}
150
+ name={name}
151
+ defaultValue={serializedValue}
152
+ placeholder={placeholder}
153
+ readOnly={readOnly}
154
+ />
155
+ )
156
+ }
157
+
158
+ {
159
+ field.type === "text" && field.admin?.component === "taxonomy-select" && !readOnly && (
160
+ <TaxonomySelect client:load name={name} value={serializedValue} taxonomySlug={field.admin?.placeholder ?? ""} />
161
+ )
162
+ }
163
+
164
+ {
165
+ (field.type === "text" || field.type === "email" || field.type === "date") &&
166
+ field.admin?.component !== "taxonomy-select" &&
167
+ (field.type === "text" && field.admin?.rows ? (
168
+ <Textarea
169
+ className={controlClass}
170
+ id={name}
171
+ name={name}
172
+ rows={field.admin.rows}
173
+ defaultValue={serializedValue}
174
+ placeholder={placeholder}
175
+ readOnly={readOnly}
176
+ required={field.required}
177
+ />
178
+ ) : (
179
+ <Input
180
+ className={controlClass}
181
+ type={field.type === "email" ? "email" : field.type === "date" ? "date" : "text"}
182
+ id={name}
183
+ name={name}
184
+ defaultValue={serializedValue}
185
+ placeholder={placeholder}
186
+ readOnly={readOnly}
187
+ required={field.required}
188
+ />
189
+ ))
190
+ }
191
+
192
+ {
193
+ field.type === "slug" && (
194
+ <SlugField
195
+ client:load
196
+ name={name}
197
+ value={serializedValue}
198
+ from={field.from}
199
+ readOnly={readOnly}
200
+ required={field.required}
201
+ placeholder={placeholder || "auto-generated-slug"}
202
+ className={controlClass}
203
+ />
204
+ )
205
+ }
206
+
207
+ {
208
+ field.type === "number" && (
209
+ <Input
210
+ className={controlClass}
211
+ type="number"
212
+ id={name}
213
+ name={name}
214
+ defaultValue={serializedValue}
215
+ inputMode={inputMode}
216
+ readOnly={readOnly}
217
+ required={field.required}
218
+ />
219
+ )
220
+ }
221
+
222
+ {field.type === "boolean" && <CheckboxField client:load name={name} checked={Boolean(value)} disabled={readOnly} />}
223
+
224
+ {
225
+ field.type === "select" && field.admin?.component !== "radio" && (
226
+ <SelectField
227
+ client:load
228
+ name={name}
229
+ value={String(value ?? "")}
230
+ placeholder="Select an option"
231
+ disabled={readOnly}
232
+ items={field.options.map((option) => ({ value: option, label: option }))}
233
+ />
234
+ )
235
+ }
236
+
237
+ {
238
+ field.type === "select" && field.admin?.component === "radio" && (
239
+ <div class="flex flex-wrap gap-x-5 gap-y-2 has-disabled:opacity-50">
240
+ {field.options.map((option) => (
241
+ <label class="hover:text-foreground flex items-center gap-2 text-sm transition-colors has-disabled:cursor-not-allowed has-disabled:hover:text-current">
242
+ <input
243
+ class="border-input hover:border-primary/60 checked:border-primary checked:hover:border-primary focus-visible:ring-ring/50 disabled:hover:border-input size-4 shrink-0 appearance-none rounded-full border transition-all checked:border-[5px] focus-visible:ring-3 focus-visible:outline-none disabled:cursor-not-allowed"
244
+ type="radio"
245
+ name={name}
246
+ value={option}
247
+ checked={String(value ?? "") === option}
248
+ disabled={readOnly}
249
+ />
250
+ <span class="select-none">{option}</span>
251
+ </label>
252
+ ))}
253
+ </div>
254
+ )
255
+ }
256
+
257
+ {
258
+ field.type === "relation" && !readOnly && relationMeta && (
259
+ <RelationField
260
+ client:load
261
+ name={name}
262
+ value={Array.isArray(value) ? JSON.stringify(value) : String(value ?? "")}
263
+ hasMany={relationMeta.hasMany}
264
+ options={relationOptions}
265
+ collectionSlug={relationMeta.collectionSlug}
266
+ collectionLabel={relationMeta.collectionLabel}
267
+ labelField={relationMeta.labelField}
268
+ />
269
+ )
270
+ }
271
+
272
+ {
273
+ field.type === "relation" && (readOnly || !relationMeta) && (
274
+ <SelectField
275
+ client:load
276
+ name={name}
277
+ value={String(value ?? "")}
278
+ placeholder="Select a document"
279
+ disabled={readOnly}
280
+ items={relationOptions}
281
+ />
282
+ )
283
+ }
284
+
285
+ {
286
+ field.type === "richText" && !readOnly && (
287
+ <RichTextEditor client:load name={name} initialValue={value ? JSON.stringify(value) : ""} rows={rows} />
288
+ )
289
+ }
290
+
291
+ {
292
+ field.type === "richText" && readOnly && (
293
+ <Textarea
294
+ className={controlClass}
295
+ id={name}
296
+ name={name}
297
+ rows={rows}
298
+ readOnly={readOnly}
299
+ defaultValue={serializedValue}
300
+ />
301
+ )
302
+ }
303
+
304
+ {
305
+ field.type === "json" && field.admin?.component === "menu-items" && !readOnly && (
306
+ <TreeItemsEditor
307
+ client:load
308
+ name={name}
309
+ value={serializedValue}
310
+ variant="menu"
311
+ label={label}
312
+ linkOptions={menuLinkOptions}
313
+ />
314
+ )
315
+ }
316
+
317
+ {
318
+ field.type === "json" && field.admin?.component === "taxonomy-terms" && !readOnly && (
319
+ <TreeItemsEditor client:load name={name} value={serializedValue} variant="taxonomy" label={label} />
320
+ )
321
+ }
322
+
323
+ {
324
+ field.type === "json" &&
325
+ (field.admin?.component === "menu-items" || field.admin?.component === "taxonomy-terms") &&
326
+ readOnly && (
327
+ <Textarea
328
+ className={controlClass}
329
+ id={name}
330
+ name={name}
331
+ rows={rows}
332
+ readOnly={readOnly}
333
+ defaultValue={serializedValue}
334
+ />
335
+ )
336
+ }
337
+
338
+ {
339
+ field.type === "blocks" && !readOnly && (
340
+ <BlockEditor
341
+ client:load
342
+ name={name}
343
+ value={serializedValue}
344
+ types={blockTypesMeta}
345
+ blockRelationOptions={blockRelationOptions}
346
+ />
347
+ )
348
+ }
349
+
350
+ {
351
+ field.type === "blocks" && readOnly && (
352
+ <Textarea
353
+ className={controlClass}
354
+ id={name}
355
+ name={name}
356
+ rows={rows}
357
+ readOnly={readOnly}
358
+ defaultValue={serializedValue}
359
+ />
360
+ )
361
+ }
362
+
363
+ {
364
+ field.type === "array" && "of" in field && field.of?.type === "text" && (
365
+ <Input
366
+ className={controlClass}
367
+ id={name}
368
+ name={name}
369
+ placeholder={placeholder || "item1, item2, item3"}
370
+ readOnly={readOnly}
371
+ required={field.required}
372
+ defaultValue={serializedValue}
373
+ />
374
+ )
375
+ }
376
+
377
+ {
378
+ ((field.type === "array" && !("of" in field && field.of?.type === "text")) ||
379
+ (field.type === "json" &&
380
+ field.admin?.component !== "menu-items" &&
381
+ field.admin?.component !== "taxonomy-terms")) && (
382
+ <Textarea
383
+ className={controlClass}
384
+ id={name}
385
+ name={name}
386
+ rows={rows}
387
+ placeholder={placeholder}
388
+ readOnly={readOnly}
389
+ required={field.required}
390
+ defaultValue={serializedValue}
391
+ />
392
+ )
393
+ }
394
+
395
+ </Fragment>
396
+ )}
397
+ </div>
@@ -0,0 +1,100 @@
1
+ "use client";
2
+
3
+ import { useState, useCallback, useEffect, useRef } from "react";
4
+ import { Crosshair, X } from "lucide-react";
5
+ import { Button } from "./ui/button";
6
+ import { Input } from "./ui/input";
7
+
8
+ type Props = {
9
+ src: string;
10
+ alt: string;
11
+ focalX: number | null;
12
+ focalY: number | null;
13
+ };
14
+
15
+ export default function FocalPointSelector({ src, alt, focalX: initialX, focalY: initialY }: Props) {
16
+ const [focalX, setFocalX] = useState<number | null>(initialX);
17
+ const [focalY, setFocalY] = useState<number | null>(initialY);
18
+ const imageRef = useRef<HTMLImageElement>(null);
19
+ const hiddenXRef = useRef<HTMLInputElement>(null);
20
+
21
+ // Notify the form of value changes so UnsavedGuard detects them
22
+ useEffect(() => {
23
+ hiddenXRef.current?.dispatchEvent(new Event("change", { bubbles: true }));
24
+ }, [focalX, focalY]);
25
+
26
+ const handleClick = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
27
+ const img = imageRef.current;
28
+ if (!img) return;
29
+ const rect = img.getBoundingClientRect();
30
+ const x = Math.round(((e.clientX - rect.left) / rect.width) * 100);
31
+ const y = Math.round(((e.clientY - rect.top) / rect.height) * 100);
32
+ setFocalX(Math.max(0, Math.min(100, x)));
33
+ setFocalY(Math.max(0, Math.min(100, y)));
34
+ }, []);
35
+
36
+ const handleClear = useCallback(() => {
37
+ setFocalX(null);
38
+ setFocalY(null);
39
+ }, []);
40
+
41
+ const hasFocal = focalX !== null && focalY !== null;
42
+
43
+ return (
44
+ <div className="space-y-2">
45
+ <div className="flex h-6 items-center justify-between gap-2">
46
+ <div className="text-muted-foreground flex items-center gap-1.5 text-xs">
47
+ <Crosshair className="size-3" />
48
+ {hasFocal ? <span>Focal point</span> : <span>Click image to set focal point</span>}
49
+ </div>
50
+ {hasFocal && (
51
+ <div className="flex items-center gap-1.5">
52
+ <div className="flex items-center gap-1">
53
+ <span className="text-muted-foreground text-xs">X</span>
54
+ <Input
55
+ type="number"
56
+ min={0}
57
+ max={100}
58
+ value={focalX ?? ""}
59
+ onChange={(e) =>
60
+ setFocalX(e.target.value === "" ? null : Math.max(0, Math.min(100, Number(e.target.value))))
61
+ }
62
+ className="h-6 w-14 px-1.5 text-center text-xs"
63
+ />
64
+ </div>
65
+ <div className="flex items-center gap-1">
66
+ <span className="text-muted-foreground text-xs">Y</span>
67
+ <Input
68
+ type="number"
69
+ min={0}
70
+ max={100}
71
+ value={focalY ?? ""}
72
+ onChange={(e) =>
73
+ setFocalY(e.target.value === "" ? null : Math.max(0, Math.min(100, Number(e.target.value))))
74
+ }
75
+ className="h-6 w-14 px-1.5 text-center text-xs"
76
+ />
77
+ </div>
78
+ <Button type="button" variant="ghost" size="icon-xs" title="Clear focal point" onClick={handleClear}>
79
+ <X className="size-3" />
80
+ </Button>
81
+ </div>
82
+ )}
83
+ </div>
84
+ <div className="bg-muted/30 relative cursor-crosshair overflow-hidden rounded-md" onClick={handleClick}>
85
+ <img ref={imageRef} src={src} alt={alt} className="w-full object-contain" draggable={false} />
86
+ {hasFocal && (
87
+ <div
88
+ className="pointer-events-none absolute size-8 -translate-x-1/2 -translate-y-1/2"
89
+ style={{ left: `${focalX}%`, top: `${focalY}%` }}
90
+ >
91
+ <div className="absolute inset-0 rounded-full border-2 border-white shadow-[0_0_0_1.5px_rgba(0,0,0,0.4),inset_0_0_0_1.5px_rgba(0,0,0,0.4)]" />
92
+ <div className="absolute top-1/2 left-1/2 size-2 -translate-x-1/2 -translate-y-1/2 rounded-full bg-white shadow-[0_0_0_1.5px_rgba(0,0,0,0.4)]" />
93
+ </div>
94
+ )}
95
+ </div>
96
+ <input ref={hiddenXRef} type="hidden" name="focalX" value={focalX ?? ""} />
97
+ <input type="hidden" name="focalY" value={focalY ?? ""} />
98
+ </div>
99
+ );
100
+ }
@@ -0,0 +1,176 @@
1
+ import { useState, useCallback, useEffect } from "react";
2
+ import { ChevronRight, Folder, Loader2, X } from "lucide-react";
3
+ import { Dialog, DialogClose, DialogContent, DialogHeader, DialogTitle } from "./ui/dialog";
4
+ import { thumbnail } from "../lib/utils";
5
+
6
+ type AssetRecord = {
7
+ _id: string;
8
+ filename: string;
9
+ mimeType: string;
10
+ url: string;
11
+ _createdAt: string;
12
+ };
13
+
14
+ type FolderRecord = {
15
+ _id: string;
16
+ name: string;
17
+ };
18
+
19
+ type BrowseState = {
20
+ folderId: string | null;
21
+ folders: FolderRecord[];
22
+ assets: AssetRecord[];
23
+ breadcrumbs: Array<{ id: string | null; name: string }>;
24
+ loading: boolean;
25
+ };
26
+
27
+ type Props = {
28
+ open: boolean;
29
+ onOpenChange: (open: boolean) => void;
30
+ onSelect: (asset: AssetRecord) => void;
31
+ };
32
+
33
+ export default function ImageBrowseDialog({ open, onOpenChange, onSelect }: Props) {
34
+ const [browse, setBrowse] = useState<BrowseState>({
35
+ folderId: null,
36
+ folders: [],
37
+ assets: [],
38
+ breadcrumbs: [{ id: null, name: "All assets" }],
39
+ loading: false,
40
+ });
41
+
42
+ const loadFolder = useCallback(async (folderId: string | null, breadcrumbs: BrowseState["breadcrumbs"]) => {
43
+ setBrowse((prev) => ({ ...prev, loading: true, folderId, breadcrumbs }));
44
+ try {
45
+ const folderParam = folderId ? `&folder=${folderId}` : "&folder=";
46
+ const [assetsRes, foldersRes] = await Promise.all([
47
+ fetch(`/api/cms/assets?limit=50${folderParam}`),
48
+ fetch(`/api/cms/assets/folders?parent=${folderId ?? ""}`),
49
+ ]);
50
+ const assetsData = await assetsRes.json();
51
+ const foldersData = await foldersRes.json();
52
+ setBrowse((prev) => ({
53
+ ...prev,
54
+ folders: foldersData ?? [],
55
+ assets: (assetsData.items ?? []).filter((a: AssetRecord) => a.mimeType.startsWith("image/")),
56
+ loading: false,
57
+ }));
58
+ } catch {
59
+ setBrowse((prev) => ({ ...prev, folders: [], assets: [], loading: false }));
60
+ }
61
+ }, []);
62
+
63
+ const navigateToFolder = useCallback(
64
+ (folder: FolderRecord) => {
65
+ loadFolder(folder._id, [...browse.breadcrumbs, { id: folder._id, name: folder.name }]);
66
+ },
67
+ [browse.breadcrumbs, loadFolder],
68
+ );
69
+
70
+ const navigateToBreadcrumb = useCallback(
71
+ (index: number) => {
72
+ const crumb = browse.breadcrumbs[index];
73
+ loadFolder(crumb.id, browse.breadcrumbs.slice(0, index + 1));
74
+ },
75
+ [browse.breadcrumbs, loadFolder],
76
+ );
77
+
78
+ useEffect(() => {
79
+ if (open) {
80
+ // eslint-disable-next-line react-hooks/set-state-in-effect
81
+ loadFolder(null, [{ id: null, name: "All assets" }]);
82
+ }
83
+ }, [open, loadFolder]);
84
+
85
+ return (
86
+ <Dialog open={open} onOpenChange={onOpenChange}>
87
+ <DialogContent className="max-w-3xl">
88
+ <DialogHeader>
89
+ <div className="flex items-center justify-between">
90
+ <DialogTitle>Media Library</DialogTitle>
91
+ <DialogClose>
92
+ <button
93
+ type="button"
94
+ title="Close"
95
+ className="text-muted-foreground hover:text-foreground rounded-md p-1 transition-colors"
96
+ >
97
+ <X className="size-5" />
98
+ </button>
99
+ </DialogClose>
100
+ </div>
101
+ </DialogHeader>
102
+
103
+ {browse.breadcrumbs.length > 1 && (
104
+ <nav className="text-muted-foreground flex items-center gap-1 text-sm">
105
+ {browse.breadcrumbs.map((crumb, i) => (
106
+ <span key={crumb.id ?? "root"} className="contents">
107
+ {i > 0 && <ChevronRight className="size-3.5 shrink-0" />}
108
+ {i < browse.breadcrumbs.length - 1 ? (
109
+ <button
110
+ type="button"
111
+ onClick={() => navigateToBreadcrumb(i)}
112
+ className="hover:text-foreground truncate transition-colors"
113
+ >
114
+ {crumb.name}
115
+ </button>
116
+ ) : (
117
+ <span className="text-foreground truncate font-medium">{crumb.name}</span>
118
+ )}
119
+ </span>
120
+ ))}
121
+ </nav>
122
+ )}
123
+
124
+ <div className="max-h-[60vh] overflow-y-auto">
125
+ {browse.loading ? (
126
+ <div className="flex h-48 items-center justify-center">
127
+ <Loader2 className="text-muted-foreground size-6 animate-spin" />
128
+ </div>
129
+ ) : (
130
+ <div className="space-y-4">
131
+ {browse.folders.length > 0 && (
132
+ <div className="grid grid-cols-3 gap-2 sm:grid-cols-4">
133
+ {browse.folders.map((folder) => (
134
+ <button
135
+ key={folder._id}
136
+ type="button"
137
+ onClick={() => navigateToFolder(folder)}
138
+ className="hover:bg-accent flex items-center gap-2 rounded-lg border px-3 py-2.5 text-left text-sm transition-colors"
139
+ >
140
+ <Folder className="text-muted-foreground size-4 shrink-0" />
141
+ <span className="truncate">{folder.name}</span>
142
+ </button>
143
+ ))}
144
+ </div>
145
+ )}
146
+
147
+ {browse.assets.length > 0 ? (
148
+ <div className="grid grid-cols-3 gap-3 sm:grid-cols-4">
149
+ {browse.assets.map((asset) => (
150
+ <button
151
+ key={asset._id}
152
+ type="button"
153
+ onClick={() => {
154
+ onSelect(asset);
155
+ onOpenChange(false);
156
+ }}
157
+ className="hover:border-foreground relative aspect-square overflow-hidden rounded-lg border transition-colors"
158
+ >
159
+ <img src={thumbnail(asset.url)} alt={asset.filename} className="size-full object-cover" />
160
+ </button>
161
+ ))}
162
+ </div>
163
+ ) : (
164
+ browse.folders.length === 0 && (
165
+ <div className="text-muted-foreground flex h-48 items-center justify-center text-sm">
166
+ {browse.folderId ? "This folder is empty." : "No images uploaded yet."}
167
+ </div>
168
+ )
169
+ )}
170
+ </div>
171
+ )}
172
+ </div>
173
+ </DialogContent>
174
+ </Dialog>
175
+ );
176
+ }