@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,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 “{activeFolderName}”? 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
|
+
}
|