@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,711 @@
1
+ "use client";
2
+
3
+ import { useState, useCallback, useRef } from "react";
4
+ import {
5
+ ChevronRight,
6
+ Folder,
7
+ FolderPlus,
8
+ GripVertical,
9
+ ImagePlus,
10
+ MoreHorizontal,
11
+ Pencil,
12
+ Trash2,
13
+ Upload,
14
+ } from "lucide-react";
15
+ import {
16
+ DndContext,
17
+ DragOverlay,
18
+ PointerSensor,
19
+ pointerWithin,
20
+ useSensor,
21
+ useSensors,
22
+ useDraggable,
23
+ useDroppable,
24
+ type DragStartEvent,
25
+ type DragEndEvent,
26
+ } from "@dnd-kit/core";
27
+ import { cn, thumbnail } from "../lib/utils";
28
+ import { Badge } from "./ui/badge";
29
+ import { Button, buttonVariants } from "./ui/button";
30
+ import { Card, CardContent } from "./ui/card";
31
+ import {
32
+ Dialog,
33
+ DialogContent,
34
+ DialogHeader,
35
+ DialogTitle,
36
+ DialogFooter,
37
+ DialogDescription,
38
+ DialogClose,
39
+ } from "./ui/dialog";
40
+ import { Input } from "./ui/input";
41
+ import { Label } from "./ui/label";
42
+
43
+ type AssetItem = {
44
+ _id: string;
45
+ filename: string;
46
+ mimeType: string;
47
+ url: string;
48
+ alt: string | null;
49
+ _createdAt: string;
50
+ };
51
+
52
+ type FolderItem = {
53
+ _id: string;
54
+ name: string;
55
+ };
56
+
57
+ type Breadcrumb = {
58
+ label: string;
59
+ href: string;
60
+ id: string;
61
+ };
62
+
63
+ type Props = {
64
+ folders: FolderItem[];
65
+ assets: AssetItem[];
66
+ breadcrumbs: Breadcrumb[];
67
+ currentFolderId: string | null;
68
+ };
69
+
70
+ // -----------------------------------------------
71
+ // Draggable asset card
72
+ // -----------------------------------------------
73
+
74
+ function DraggableAssetCard({
75
+ asset,
76
+ selected,
77
+ onToggleSelect,
78
+ }: {
79
+ asset: AssetItem;
80
+ selected: boolean;
81
+ onToggleSelect: () => void;
82
+ }) {
83
+ const { attributes, listeners, setNodeRef, setActivatorNodeRef, isDragging } = useDraggable({
84
+ id: asset._id,
85
+ data: { type: "asset", asset },
86
+ });
87
+
88
+ return (
89
+ <div ref={setNodeRef} className={cn("group relative", isDragging && "opacity-40")}>
90
+ <a href={`/admin/assets/${asset._id}`} className="block">
91
+ <Card className="hover:border-foreground/50 overflow-hidden pt-0 transition-colors">
92
+ <div className="relative">
93
+ {asset.mimeType.startsWith("image/") ? (
94
+ <div className="bg-muted/30 aspect-square w-full overflow-hidden">
95
+ <img src={thumbnail(asset.url)} alt={asset.alt ?? asset.filename} className="size-full object-cover" />
96
+ </div>
97
+ ) : (
98
+ <div className="bg-muted/30 flex aspect-square items-center justify-center">
99
+ <Badge variant="outline">{asset.mimeType}</Badge>
100
+ </div>
101
+ )}
102
+ </div>
103
+ <CardContent className="px-3">
104
+ <div className="truncate text-sm font-medium">{asset.filename}</div>
105
+ <div className="text-muted-foreground mt-0.5 text-xs">
106
+ {new Date(asset._createdAt).toLocaleDateString()}
107
+ </div>
108
+ </CardContent>
109
+ </Card>
110
+ </a>
111
+ {/* Checkbox */}
112
+ <label
113
+ className={cn(
114
+ "border-foreground/40 hover:border-foreground/80 bg-background/80 absolute top-2.5 right-2.5 z-10 flex size-5 cursor-default items-center justify-center rounded border backdrop-blur-sm transition-[opacity,border-color]",
115
+ selected ? "border-foreground! opacity-100" : "opacity-0 group-hover:opacity-100",
116
+ )}
117
+ onClick={(e) => e.stopPropagation()}
118
+ >
119
+ <input
120
+ type="checkbox"
121
+ className="peer sr-only"
122
+ checked={selected}
123
+ onChange={(e) => {
124
+ e.stopPropagation();
125
+ onToggleSelect();
126
+ }}
127
+ />
128
+ <svg
129
+ className={cn("text-foreground size-3", selected ? "block" : "hidden")}
130
+ viewBox="0 0 24 24"
131
+ fill="none"
132
+ stroke="currentColor"
133
+ strokeWidth={3}
134
+ strokeLinecap="round"
135
+ strokeLinejoin="round"
136
+ >
137
+ <polyline points="20 6 9 17 4 12" />
138
+ </svg>
139
+ </label>
140
+ {/* Drag handle */}
141
+ <div
142
+ ref={setActivatorNodeRef}
143
+ className="bg-background/80 border-foreground/40 hover:border-foreground/80 text-muted-foreground absolute top-2.5 left-2.5 z-10 flex size-5 cursor-grab items-center justify-center rounded border opacity-0 backdrop-blur-sm transition-opacity group-hover:opacity-100 active:cursor-grabbing"
144
+ {...attributes}
145
+ {...listeners}
146
+ >
147
+ <GripVertical className="text-foreground size-3.5" />
148
+ </div>
149
+ </div>
150
+ );
151
+ }
152
+
153
+ // -----------------------------------------------
154
+ // Droppable folder card
155
+ // -----------------------------------------------
156
+
157
+ function DroppableFolder({
158
+ folder,
159
+ onOpenMenu,
160
+ menuActive,
161
+ }: {
162
+ folder: FolderItem;
163
+ onOpenMenu: (folderId: string, folderName: string, rect: DOMRect) => void;
164
+ menuActive?: boolean;
165
+ }) {
166
+ const { isOver, setNodeRef } = useDroppable({
167
+ id: `folder-${folder._id}`,
168
+ data: { type: "folder", folderId: folder._id },
169
+ });
170
+
171
+ return (
172
+ <div ref={setNodeRef} className="group relative">
173
+ <a href={`/admin/assets?folder=${folder._id}`}>
174
+ <Card
175
+ className={cn(
176
+ "transition-shadow",
177
+ isOver ? "ring-foreground/40 bg-primary/8 ring-1" : "hover:ring-foreground/20 hover:ring-1",
178
+ )}
179
+ >
180
+ <CardContent className="flex items-center gap-3 py-3">
181
+ <Folder className="text-muted-foreground size-5" />
182
+ <span className="truncate text-sm font-medium">{folder.name}</span>
183
+ </CardContent>
184
+ </Card>
185
+ </a>
186
+ <div
187
+ className={cn(
188
+ "absolute top-1/2 right-3 -translate-y-1/2 transition-opacity",
189
+ menuActive ? "opacity-100" : "opacity-0 group-hover:opacity-100",
190
+ )}
191
+ >
192
+ <button
193
+ type="button"
194
+ title="Folder options"
195
+ className="text-muted-foreground hover:text-foreground rounded-md p-1"
196
+ onClick={(e) => {
197
+ e.preventDefault();
198
+ e.stopPropagation();
199
+ const rect = e.currentTarget.getBoundingClientRect();
200
+ onOpenMenu(folder._id, folder.name, rect);
201
+ }}
202
+ >
203
+ <MoreHorizontal className="size-4" />
204
+ </button>
205
+ </div>
206
+ </div>
207
+ );
208
+ }
209
+
210
+ // -----------------------------------------------
211
+ // Droppable breadcrumb
212
+ // -----------------------------------------------
213
+
214
+ function DroppableBreadcrumb({ href, folderId, label }: { href: string; folderId: string; label: string }) {
215
+ const { isOver, setNodeRef } = useDroppable({
216
+ id: `breadcrumb-${folderId}`,
217
+ data: { type: "folder", folderId: folderId || null },
218
+ });
219
+
220
+ return (
221
+ <a
222
+ ref={setNodeRef}
223
+ href={href}
224
+ className={cn(
225
+ "bg-muted/50 border-border hover:border-foreground/20 hover:text-foreground rounded-md border px-2.5 py-1 transition-colors",
226
+ isOver && "border-primary! bg-primary/12! text-foreground",
227
+ )}
228
+ >
229
+ {label}
230
+ </a>
231
+ );
232
+ }
233
+
234
+ // -----------------------------------------------
235
+ // Drag overlay (ghost while dragging)
236
+ // -----------------------------------------------
237
+
238
+ function AssetDragOverlay({ asset }: { asset: AssetItem }) {
239
+ return (
240
+ <div className="w-48" style={{ transform: "rotate(2deg) scale(0.95)" }}>
241
+ <Card className="overflow-hidden pt-0 shadow-lg">
242
+ <div className="relative">
243
+ {asset.mimeType.startsWith("image/") ? (
244
+ <div className="bg-muted/30 aspect-square w-full overflow-hidden">
245
+ <img src={asset.url} alt={asset.alt ?? asset.filename} className="size-full object-cover" />
246
+ </div>
247
+ ) : (
248
+ <div className="bg-muted/30 flex aspect-square items-center justify-center">
249
+ <Badge variant="outline">{asset.mimeType}</Badge>
250
+ </div>
251
+ )}
252
+ </div>
253
+ <CardContent className="px-3">
254
+ <div className="truncate text-sm font-medium">{asset.filename}</div>
255
+ </CardContent>
256
+ </Card>
257
+ </div>
258
+ );
259
+ }
260
+
261
+ // -----------------------------------------------
262
+ // Main component
263
+ // -----------------------------------------------
264
+
265
+ export default function AssetsGrid({ folders, assets, breadcrumbs, currentFolderId }: Props) {
266
+ const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
267
+ const [activeAsset, setActiveAsset] = useState<AssetItem | null>(null);
268
+
269
+ // Folder dialog state
270
+ const [createOpen, setCreateOpen] = useState(false);
271
+ const [renameOpen, setRenameOpen] = useState(false);
272
+ const [deleteOpen, setDeleteOpen] = useState(false);
273
+ const [bulkDeleteOpen, setBulkDeleteOpen] = useState(false);
274
+ const [createName, setCreateName] = useState("");
275
+ const [renameName, setRenameName] = useState("");
276
+ const [activeFolderId, setActiveFolderId] = useState<string | null>(null);
277
+ const [activeFolderName, setActiveFolderName] = useState("");
278
+ const [error, setError] = useState<string | null>(null);
279
+ const [loading, setLoading] = useState(false);
280
+
281
+ // Context menu
282
+ const [menuOpen, setMenuOpen] = useState(false);
283
+ const [menuPos, setMenuPos] = useState({ top: 0, left: 0 });
284
+
285
+ const uploadFormRef = useRef<HTMLFormElement>(null);
286
+
287
+ const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 5 } }));
288
+
289
+ // --- Selection ---
290
+
291
+ const toggleSelect = useCallback((id: string) => {
292
+ setSelectedIds((prev) => {
293
+ const next = new Set(prev);
294
+ if (next.has(id)) next.delete(id);
295
+ else next.add(id);
296
+ return next;
297
+ });
298
+ }, []);
299
+
300
+ const clearSelection = useCallback(() => setSelectedIds(new Set()), []);
301
+
302
+ // --- Drag & drop ---
303
+
304
+ const handleDragStart = useCallback(
305
+ (event: DragStartEvent) => {
306
+ const asset = assets.find((a) => a._id === event.active.id);
307
+ if (asset) setActiveAsset(asset);
308
+ document.body.style.cursor = "grabbing";
309
+ },
310
+ [assets],
311
+ );
312
+
313
+ const handleDragEnd = useCallback((event: DragEndEvent) => {
314
+ document.body.style.cursor = "";
315
+ setActiveAsset(null);
316
+ const { active, over } = event;
317
+ if (!over) return;
318
+
319
+ const assetId = String(active.id);
320
+ const dropData = over.data.current as { type: string; folderId: string | null } | undefined;
321
+ if (!dropData || dropData.type !== "folder") return;
322
+
323
+ const targetFolderId = dropData.folderId;
324
+
325
+ fetch(`/api/cms/assets/${assetId}`, {
326
+ method: "PATCH",
327
+ headers: { "Content-Type": "application/json" },
328
+ body: JSON.stringify({ folder: targetFolderId || null }),
329
+ }).then((res) => {
330
+ if (res.ok) location.reload();
331
+ });
332
+ }, []);
333
+
334
+ const handleDragCancel = useCallback(() => {
335
+ document.body.style.cursor = "";
336
+ setActiveAsset(null);
337
+ }, []);
338
+
339
+ // --- Folder actions ---
340
+
341
+ function openMenu(folderId: string, folderName: string, rect: DOMRect) {
342
+ setActiveFolderId(folderId);
343
+ setActiveFolderName(folderName);
344
+ setMenuPos({ top: rect.bottom + 4, left: rect.right - 160 });
345
+ setMenuOpen(true);
346
+ }
347
+
348
+ function resetDialogState() {
349
+ setLoading(false);
350
+ setError(null);
351
+ }
352
+
353
+ async function handleCreateFolder() {
354
+ if (!createName.trim()) return;
355
+ setLoading(true);
356
+ setError(null);
357
+ const res = await fetch("/api/cms/assets/folders", {
358
+ method: "POST",
359
+ headers: { "Content-Type": "application/json" },
360
+ body: JSON.stringify({ action: "create", name: createName.trim(), parent: currentFolderId }),
361
+ });
362
+ if (res.ok) location.reload();
363
+ else {
364
+ setLoading(false);
365
+ setError("Failed to create folder.");
366
+ }
367
+ }
368
+
369
+ async function handleRenameFolder() {
370
+ if (!renameName.trim() || renameName.trim() === activeFolderName) return;
371
+ setLoading(true);
372
+ setError(null);
373
+ const res = await fetch("/api/cms/assets/folders", {
374
+ method: "POST",
375
+ headers: { "Content-Type": "application/json" },
376
+ body: JSON.stringify({ action: "rename", id: activeFolderId, name: renameName.trim() }),
377
+ });
378
+ if (res.ok) location.reload();
379
+ else {
380
+ setLoading(false);
381
+ setError("Failed to rename folder.");
382
+ }
383
+ }
384
+
385
+ async function handleDeleteFolder() {
386
+ setLoading(true);
387
+ setError(null);
388
+ const res = await fetch("/api/cms/assets/folders", {
389
+ method: "POST",
390
+ headers: { "Content-Type": "application/json" },
391
+ body: JSON.stringify({ action: "delete", id: activeFolderId }),
392
+ });
393
+ if (res.ok) location.reload();
394
+ else {
395
+ setLoading(false);
396
+ setError("Failed to delete folder.");
397
+ }
398
+ }
399
+
400
+ async function handleBulkDelete() {
401
+ setLoading(true);
402
+ setError(null);
403
+ const ids = Array.from(selectedIds);
404
+ const results = await Promise.all(ids.map((id) => fetch(`/api/cms/assets/${id}`, { method: "DELETE" })));
405
+ const failed = results.filter((r) => !r.ok).length;
406
+ if (failed > 0 && failed === ids.length) {
407
+ setLoading(false);
408
+ setError("Failed to delete assets.");
409
+ return;
410
+ }
411
+ location.reload();
412
+ }
413
+
414
+ const hasBreadcrumbs = breadcrumbs.length > 0;
415
+ const isEmpty = folders.length === 0 && assets.length === 0;
416
+
417
+ return (
418
+ <DndContext
419
+ sensors={sensors}
420
+ collisionDetection={pointerWithin}
421
+ onDragStart={handleDragStart}
422
+ onDragEnd={handleDragEnd}
423
+ onDragCancel={handleDragCancel}
424
+ >
425
+ <section className="space-y-6">
426
+ {/* Header */}
427
+ <div className="flex items-start justify-between gap-4">
428
+ <div>
429
+ <h1 className="text-2xl font-semibold tracking-tight">Assets</h1>
430
+ {hasBreadcrumbs && (
431
+ <nav className="text-muted-foreground mt-2 flex items-center gap-1 text-sm">
432
+ <DroppableBreadcrumb href="/admin/assets" folderId="" label="All assets" />
433
+ {breadcrumbs.map((crumb) => (
434
+ <span key={crumb.id} className="contents">
435
+ <ChevronRight className="size-3.5" />
436
+ <DroppableBreadcrumb href={crumb.href} folderId={crumb.id} label={crumb.label} />
437
+ </span>
438
+ ))}
439
+ </nav>
440
+ )}
441
+ </div>
442
+ <div className="flex items-center gap-2">
443
+ {selectedIds.size > 0 ? (
444
+ <>
445
+ <span className="text-muted-foreground text-sm">{selectedIds.size} selected</span>
446
+ <Button
447
+ variant="destructive"
448
+ size="sm"
449
+ onClick={() => {
450
+ resetDialogState();
451
+ setBulkDeleteOpen(true);
452
+ }}
453
+ >
454
+ <Trash2 className="size-3.5" />
455
+ Delete
456
+ </Button>
457
+ <Button variant="outline" size="sm" onClick={clearSelection}>
458
+ Cancel
459
+ </Button>
460
+ </>
461
+ ) : (
462
+ <>
463
+ <Button
464
+ variant="outline"
465
+ size="sm"
466
+ onClick={() => {
467
+ setCreateName("");
468
+ resetDialogState();
469
+ setCreateOpen(true);
470
+ }}
471
+ >
472
+ <FolderPlus className="size-4" />
473
+ New folder
474
+ </Button>
475
+ <label className={buttonVariants({ size: "sm" }) + " cursor-pointer"}>
476
+ <Upload className="size-4" />
477
+ Upload
478
+ <form
479
+ ref={uploadFormRef}
480
+ method="post"
481
+ action="/api/cms/assets/upload"
482
+ encType="multipart/form-data"
483
+ className="hidden"
484
+ >
485
+ <input type="hidden" name="redirectTo" value="/admin/assets" />
486
+ {currentFolderId && <input type="hidden" name="folder" value={currentFolderId} />}
487
+ <input
488
+ type="file"
489
+ name="file"
490
+ accept="image/*,video/*,audio/*,.pdf,.doc,.docx"
491
+ required
492
+ onChange={(e) => {
493
+ if (e.target.value) uploadFormRef.current?.submit();
494
+ }}
495
+ />
496
+ </form>
497
+ </label>
498
+ </>
499
+ )}
500
+ </div>
501
+ </div>
502
+
503
+ {/* Folders */}
504
+ {folders.length > 0 && (
505
+ <div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5">
506
+ {folders.map((folder) => (
507
+ <DroppableFolder
508
+ key={folder._id}
509
+ folder={folder}
510
+ onOpenMenu={openMenu}
511
+ menuActive={menuOpen && activeFolderId === folder._id}
512
+ />
513
+ ))}
514
+ </div>
515
+ )}
516
+
517
+ {/* Assets or empty state */}
518
+ {isEmpty ? (
519
+ <Card>
520
+ <CardContent className="py-12">
521
+ <div className="text-center">
522
+ <ImagePlus className="text-muted-foreground/30 mx-auto size-12" />
523
+ <p className="text-muted-foreground mt-3 text-sm">
524
+ {currentFolderId ? "This folder is empty." : "No assets uploaded yet."}
525
+ </p>
526
+ </div>
527
+ </CardContent>
528
+ </Card>
529
+ ) : (
530
+ assets.length > 0 && (
531
+ <div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5">
532
+ {assets.map((asset) => (
533
+ <DraggableAssetCard
534
+ key={asset._id}
535
+ asset={asset}
536
+ selected={selectedIds.has(asset._id)}
537
+ onToggleSelect={() => toggleSelect(asset._id)}
538
+ />
539
+ ))}
540
+ </div>
541
+ )
542
+ )}
543
+ </section>
544
+
545
+ {/* Drag overlay */}
546
+ <DragOverlay dropAnimation={null}>{activeAsset && <AssetDragOverlay asset={activeAsset} />}</DragOverlay>
547
+
548
+ {/* Context menu */}
549
+ {menuOpen && (
550
+ <div
551
+ className="bg-popover text-popover-foreground border-border fixed z-50 min-w-40 rounded-md border p-1 shadow-md"
552
+ style={{ top: menuPos.top, left: menuPos.left }}
553
+ onClick={(e) => e.stopPropagation()}
554
+ >
555
+ <button
556
+ type="button"
557
+ className="hover:bg-accent flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-sm"
558
+ onClick={() => {
559
+ setMenuOpen(false);
560
+ setRenameName(activeFolderName);
561
+ resetDialogState();
562
+ setRenameOpen(true);
563
+ }}
564
+ >
565
+ <Pencil className="size-3.5" />
566
+ Rename
567
+ </button>
568
+ <button
569
+ type="button"
570
+ className="text-destructive hover:bg-destructive/10 flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-sm"
571
+ onClick={() => {
572
+ setMenuOpen(false);
573
+ resetDialogState();
574
+ setDeleteOpen(true);
575
+ }}
576
+ >
577
+ <Trash2 className="size-3.5" />
578
+ Delete
579
+ </button>
580
+ </div>
581
+ )}
582
+
583
+ {/* Create folder dialog */}
584
+ <Dialog open={createOpen} onOpenChange={setCreateOpen}>
585
+ <DialogContent>
586
+ <DialogHeader>
587
+ <DialogTitle>New folder</DialogTitle>
588
+ </DialogHeader>
589
+ <form
590
+ onSubmit={(e) => {
591
+ e.preventDefault();
592
+ handleCreateFolder();
593
+ }}
594
+ >
595
+ <div className="grid gap-2 py-2">
596
+ <Label htmlFor="folder-name">Name</Label>
597
+ <Input
598
+ id="folder-name"
599
+ value={createName}
600
+ onChange={(e) => setCreateName(e.target.value)}
601
+ placeholder="Folder name"
602
+ autoFocus
603
+ />
604
+ </div>
605
+ {error && <p className="text-destructive text-sm">{error}</p>}
606
+ <DialogFooter className="mt-4">
607
+ <DialogClose>
608
+ <Button variant="outline" type="button">
609
+ Cancel
610
+ </Button>
611
+ </DialogClose>
612
+ <Button type="submit" disabled={!createName.trim() || loading}>
613
+ Create
614
+ </Button>
615
+ </DialogFooter>
616
+ </form>
617
+ </DialogContent>
618
+ </Dialog>
619
+
620
+ {/* Rename folder dialog */}
621
+ <Dialog open={renameOpen} onOpenChange={setRenameOpen}>
622
+ <DialogContent>
623
+ <DialogHeader>
624
+ <DialogTitle>Rename folder</DialogTitle>
625
+ </DialogHeader>
626
+ <form
627
+ onSubmit={(e) => {
628
+ e.preventDefault();
629
+ handleRenameFolder();
630
+ }}
631
+ >
632
+ <div className="grid gap-2 py-2">
633
+ <Label htmlFor="rename-name">Name</Label>
634
+ <Input
635
+ id="rename-name"
636
+ value={renameName}
637
+ onChange={(e) => setRenameName(e.target.value)}
638
+ placeholder="Folder name"
639
+ autoFocus
640
+ />
641
+ </div>
642
+ {error && <p className="text-destructive text-sm">{error}</p>}
643
+ <DialogFooter className="mt-4">
644
+ <DialogClose>
645
+ <Button variant="outline" type="button">
646
+ Cancel
647
+ </Button>
648
+ </DialogClose>
649
+ <Button type="submit" disabled={!renameName.trim() || renameName.trim() === activeFolderName || loading}>
650
+ Rename
651
+ </Button>
652
+ </DialogFooter>
653
+ </form>
654
+ </DialogContent>
655
+ </Dialog>
656
+
657
+ {/* Delete folder dialog */}
658
+ <Dialog open={deleteOpen} onOpenChange={setDeleteOpen}>
659
+ <DialogContent>
660
+ <DialogHeader>
661
+ <DialogTitle>Delete folder</DialogTitle>
662
+ <DialogDescription>
663
+ Are you sure you want to delete &ldquo;{activeFolderName}&rdquo;? Assets in this folder will be moved to
664
+ root.
665
+ </DialogDescription>
666
+ </DialogHeader>
667
+ {error && <p className="text-destructive text-sm">{error}</p>}
668
+ <DialogFooter className="mt-2">
669
+ <DialogClose>
670
+ <Button variant="outline" type="button">
671
+ Cancel
672
+ </Button>
673
+ </DialogClose>
674
+ <Button variant="destructive" onClick={handleDeleteFolder} disabled={loading}>
675
+ <Trash2 className="size-3.5" />
676
+ Delete
677
+ </Button>
678
+ </DialogFooter>
679
+ </DialogContent>
680
+ </Dialog>
681
+
682
+ {/* Bulk delete dialog */}
683
+ <Dialog open={bulkDeleteOpen} onOpenChange={setBulkDeleteOpen}>
684
+ <DialogContent>
685
+ <DialogHeader>
686
+ <DialogTitle>Delete assets</DialogTitle>
687
+ <DialogDescription>
688
+ Are you sure you want to delete {selectedIds.size} asset{selectedIds.size > 1 ? "s" : ""}? This action
689
+ cannot be undone.
690
+ </DialogDescription>
691
+ </DialogHeader>
692
+ {error && <p className="text-destructive text-sm">{error}</p>}
693
+ <DialogFooter className="mt-2">
694
+ <DialogClose>
695
+ <Button variant="outline" type="button">
696
+ Cancel
697
+ </Button>
698
+ </DialogClose>
699
+ <Button variant="destructive" onClick={handleBulkDelete} disabled={loading}>
700
+ <Trash2 className="size-3.5" />
701
+ Delete
702
+ </Button>
703
+ </DialogFooter>
704
+ </DialogContent>
705
+ </Dialog>
706
+
707
+ {/* Close menu on outside click */}
708
+ {menuOpen && <div className="fixed inset-0 z-40" onClick={() => setMenuOpen(false)} />}
709
+ </DndContext>
710
+ );
711
+ }