convex-cms 0.0.5-alpha.2 → 0.0.5-alpha.4

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 (95) hide show
  1. package/README.md +44 -9
  2. package/admin/src/components/BulkActionBar.tsx +4 -4
  3. package/admin/src/components/ContentEntryEditor.tsx +8 -28
  4. package/admin/src/components/ContentTypeFormModal.tsx +2 -2
  5. package/admin/src/components/TaxonomyEditor.tsx +2 -2
  6. package/admin/src/components/TermTree.tsx +5 -5
  7. package/admin/src/components/VersionCompare.tsx +1 -1
  8. package/admin/src/components/VersionHistory.tsx +2 -2
  9. package/admin/src/components/fields/CategoryField.tsx +1 -1
  10. package/admin/src/components/fields/MediaField.tsx +4 -4
  11. package/admin/src/components/fields/ReferenceField.tsx +4 -4
  12. package/admin/src/components/fields/TagField.tsx +3 -3
  13. package/admin/src/components/filters/TaxonomyFilter.tsx +2 -2
  14. package/admin/src/components/media/MediaAssetEditDialog.tsx +2 -2
  15. package/admin/src/components/media/MediaFolderEditDialog.tsx +1 -1
  16. package/admin/src/components/media/MediaMoveModal.tsx +2 -2
  17. package/admin/src/components/media/MediaTaxonomyPicker.tsx +4 -4
  18. package/admin/src/contexts/SettingsConfigContext.tsx +2 -2
  19. package/admin/src/pages/MediaPage.tsx +1102 -8
  20. package/admin/src/pages/SettingsPage.tsx +171 -108
  21. package/admin/src/routes/entries/$entryId.tsx +2 -2
  22. package/admin/src/routes/entries/new.$contentTypeId.tsx +1 -1
  23. package/admin/src/routes/entries/type/$contentTypeId.tsx +6 -6
  24. package/admin/src/routes/media.tsx +23 -1094
  25. package/admin-dist/nitro.json +1 -1
  26. package/admin-dist/server/index.mjs +138 -138
  27. package/dist/cli/index.js +0 -0
  28. package/dist/client/admin/index.d.ts +75 -2
  29. package/dist/client/admin/index.d.ts.map +1 -1
  30. package/dist/client/admin/index.js +17 -1
  31. package/dist/client/admin/index.js.map +1 -1
  32. package/dist/client/admin/media.d.ts.map +1 -1
  33. package/dist/client/admin/media.js +3 -3
  34. package/dist/client/admin/media.js.map +1 -1
  35. package/dist/client/admin/settings.d.ts +50 -0
  36. package/dist/client/admin/settings.d.ts.map +1 -0
  37. package/dist/client/admin/settings.js +89 -0
  38. package/dist/client/admin/settings.js.map +1 -0
  39. package/dist/client/admin/taxonomies.d.ts.map +1 -1
  40. package/dist/client/admin/trash.d.ts.map +1 -1
  41. package/dist/client/admin/types.d.ts +40 -0
  42. package/dist/client/admin/types.d.ts.map +1 -1
  43. package/dist/client/admin/validators.d.ts +243 -3
  44. package/dist/client/admin/validators.d.ts.map +1 -1
  45. package/dist/client/admin/validators.js +18 -0
  46. package/dist/client/admin/validators.js.map +1 -1
  47. package/dist/client/adminConfig.d.ts +1 -1
  48. package/dist/client/config.d.ts +145 -0
  49. package/dist/client/config.d.ts.map +1 -0
  50. package/dist/client/config.js +132 -0
  51. package/dist/client/config.js.map +1 -0
  52. package/dist/client/index.d.ts +3 -1
  53. package/dist/client/index.d.ts.map +1 -1
  54. package/dist/client/index.js +13 -6
  55. package/dist/client/index.js.map +1 -1
  56. package/dist/client/schema/defineContentType.js +1 -1
  57. package/dist/client/schema/defineContentType.js.map +1 -1
  58. package/dist/client/wrapper.d.ts.map +1 -1
  59. package/dist/client/wrapper.js +0 -4
  60. package/dist/client/wrapper.js.map +1 -1
  61. package/dist/component/_generated/api.d.ts +2 -0
  62. package/dist/component/_generated/api.d.ts.map +1 -1
  63. package/dist/component/_generated/api.js.map +1 -1
  64. package/dist/component/_generated/component.d.ts +42 -0
  65. package/dist/component/_generated/component.d.ts.map +1 -1
  66. package/dist/component/contentEntries.d.ts.map +1 -1
  67. package/dist/component/contentEntries.js +0 -2
  68. package/dist/component/contentEntries.js.map +1 -1
  69. package/dist/component/contentEntryMutations.d.ts.map +1 -1
  70. package/dist/component/contentEntryMutations.js +0 -1
  71. package/dist/component/contentEntryMutations.js.map +1 -1
  72. package/dist/component/contentLock.d.ts.map +1 -1
  73. package/dist/component/contentLock.js +0 -2
  74. package/dist/component/contentLock.js.map +1 -1
  75. package/dist/component/index.d.ts +2 -1
  76. package/dist/component/index.d.ts.map +1 -1
  77. package/dist/component/index.js +3 -1
  78. package/dist/component/index.js.map +1 -1
  79. package/dist/component/schema.d.ts +18 -1
  80. package/dist/component/schema.d.ts.map +1 -1
  81. package/dist/component/schema.js +6 -1
  82. package/dist/component/schema.js.map +1 -1
  83. package/dist/component/settings.d.ts +60 -0
  84. package/dist/component/settings.d.ts.map +1 -0
  85. package/dist/component/settings.js +126 -0
  86. package/dist/component/settings.js.map +1 -0
  87. package/dist/component/validators.d.ts +36 -0
  88. package/dist/component/validators.d.ts.map +1 -1
  89. package/dist/component/validators.js +15 -0
  90. package/dist/component/validators.js.map +1 -1
  91. package/dist/test.d.ts +11 -2
  92. package/dist/test.d.ts.map +1 -1
  93. package/dist/test.js +2 -5
  94. package/dist/test.js.map +1 -1
  95. package/package.json +12 -3
@@ -1,34 +1,1128 @@
1
1
  /**
2
2
  * Shared Media Page Component
3
3
  *
4
- * Manages media assets and folders.
4
+ * Manages media assets and folders with upload, organization, and trash functionality.
5
5
  * Used by both CLI routes and embed pages.
6
6
  */
7
7
 
8
+ import { useState, useMemo, useCallback, useEffect } from "react";
9
+ import { useQuery, useMutation } from "convex/react";
10
+ import { UploadDropzone, type UploadedFile } from "~/components/UploadDropzone";
8
11
  import { CmsPageHeader } from "~/components/cmsds/CmsPageHeader";
12
+ import { CmsToolbar } from "~/components/cmsds/CmsToolbar";
9
13
  import { CmsEmptyState } from "~/components/cmsds/CmsEmptyState";
10
- import { Image } from "lucide-react";
14
+ import { CmsButton } from "~/components/cmsds/CmsButton";
15
+ import { TaxonomyFilter } from "~/components/filters/TaxonomyFilter";
16
+ import {
17
+ Dialog,
18
+ DialogContent,
19
+ DialogHeader,
20
+ DialogTitle,
21
+ DialogFooter,
22
+ } from "~/components/ui/dialog";
23
+ import { Input } from "~/components/ui/input";
24
+ import { Label } from "~/components/ui/label";
25
+ import {
26
+ Select,
27
+ SelectContent,
28
+ SelectItem,
29
+ SelectTrigger,
30
+ SelectValue,
31
+ } from "~/components/ui/select";
32
+ import { Checkbox } from "~/components/ui/checkbox";
33
+ import { cn } from "~/lib/cn";
34
+ import {
35
+ Image,
36
+ Video,
37
+ Music,
38
+ FileText,
39
+ File,
40
+ Folder,
41
+ Home,
42
+ ChevronLeft,
43
+ FolderPlus,
44
+ Upload,
45
+ Search,
46
+ X,
47
+ Trash2,
48
+ RotateCcw,
49
+ } from "lucide-react";
50
+ import {
51
+ MediaPreviewModal,
52
+ type MediaAsset,
53
+ } from "~/components/media/MediaPreviewModal";
54
+ import {
55
+ MediaAssetEditDialog,
56
+ type MediaAssetForEdit,
57
+ } from "~/components/media/MediaAssetEditDialog";
58
+ import {
59
+ MediaFolderEditDialog,
60
+ type MediaFolderForEdit,
61
+ } from "~/components/media/MediaFolderEditDialog";
62
+ import { MediaAssetActions } from "~/components/media/MediaAssetActions";
63
+ import { MediaFolderActions } from "~/components/media/MediaFolderActions";
64
+ import { MediaBulkActionBar } from "~/components/media/MediaBulkActionBar";
65
+ import { MediaTrashBulkActionBar } from "~/components/media/MediaTrashBulkActionBar";
66
+ import { MediaMoveModal } from "~/components/media/MediaMoveModal";
67
+ import { CmsConfirmDialog } from "~/components/cmsds/CmsDialog";
68
+ import { Tabs, TabsList, TabsTrigger } from "~/components/ui/tabs";
69
+ import { Badge } from "~/components/ui/badge";
11
70
  import type { AdminNavigation } from "~/lib/navigation";
12
71
  import type { CmsAdminApi } from "~/embed/contexts/ApiContext";
13
72
 
73
+ type MediaView = "library" | "trash";
74
+ type MediaType = "image" | "video" | "audio" | "document" | "other";
75
+
14
76
  export interface MediaPageProps {
15
77
  api: CmsAdminApi;
16
78
  navigation: AdminNavigation;
79
+ settings?: {
80
+ features: {
81
+ mediaManagement: boolean;
82
+ };
83
+ } | null;
84
+ }
85
+
86
+ function formatFileSize(bytes: number): string {
87
+ if (bytes === 0) return "0 B";
88
+ const k = 1024;
89
+ const sizes = ["B", "KB", "MB", "GB"];
90
+ const i = Math.floor(Math.log(bytes) / Math.log(k));
91
+ return `${parseFloat((bytes / Math.pow(k, i)).toFixed(1))} ${sizes[i]}`;
92
+ }
93
+
94
+ function formatDate(timestamp: number): string {
95
+ return new Date(timestamp).toLocaleDateString("en-US", {
96
+ month: "short",
97
+ day: "numeric",
98
+ year: "numeric",
99
+ });
100
+ }
101
+
102
+ function getMediaTypeIcon(type: string, className = "size-6") {
103
+ const iconProps = { className };
104
+ switch (type) {
105
+ case "image":
106
+ return <Image {...iconProps} />;
107
+ case "video":
108
+ return <Video {...iconProps} />;
109
+ case "audio":
110
+ return <Music {...iconProps} />;
111
+ case "document":
112
+ return <FileText {...iconProps} />;
113
+ default:
114
+ return <File {...iconProps} />;
115
+ }
17
116
  }
18
117
 
19
- export function MediaPage({ api: _api, navigation: _navigation }: MediaPageProps) {
118
+ function getMediaTypeFromMimeType(mimeType?: string): MediaType {
119
+ if (!mimeType) return "other";
120
+ if (mimeType.startsWith("image/")) return "image";
121
+ if (mimeType.startsWith("video/")) return "video";
122
+ if (mimeType.startsWith("audio/")) return "audio";
123
+ if (
124
+ mimeType === "application/pdf" ||
125
+ mimeType.includes("document") ||
126
+ mimeType.includes("sheet") ||
127
+ mimeType.includes("presentation") ||
128
+ mimeType.startsWith("text/")
129
+ ) {
130
+ return "document";
131
+ }
132
+ return "other";
133
+ }
134
+
135
+ export function MediaPage({ api, navigation, settings }: MediaPageProps) {
136
+ useEffect(() => {
137
+ if (settings && !settings.features.mediaManagement) {
138
+ navigation.navigate("/");
139
+ }
140
+ }, [settings, navigation]);
141
+
142
+ const [currentFolderId, setCurrentFolderId] = useState<string | undefined>(
143
+ undefined
144
+ );
145
+ const [searchQuery, setSearchQuery] = useState("");
146
+ const [typeFilter, setTypeFilter] = useState<MediaType | "">("");
147
+ const [selectedTermIds, setSelectedTermIds] = useState<string[]>([]);
148
+ const [selectedAssets, setSelectedAssets] = useState<Set<string>>(new Set());
149
+ const [isSelectionMode, setIsSelectionMode] = useState(false);
150
+ const [showNewFolderModal, setShowNewFolderModal] = useState(false);
151
+ const [showUploadModal, setShowUploadModal] = useState(false);
152
+ const [newFolderName, setNewFolderName] = useState("");
153
+ const [isCreatingFolder, setIsCreatingFolder] = useState(false);
154
+ const [folderError, setFolderError] = useState("");
155
+ const [previewIndex, setPreviewIndex] = useState<number | null>(null);
156
+ const [editingAsset, setEditingAsset] = useState<MediaAssetForEdit | null>(
157
+ null
158
+ );
159
+ const [editingFolder, setEditingFolder] = useState<MediaFolderForEdit | null>(
160
+ null
161
+ );
162
+ const [deleteTarget, setDeleteTarget] = useState<{
163
+ type: "asset" | "folder";
164
+ id: string;
165
+ name: string;
166
+ } | null>(null);
167
+ const [isDeleting, setIsDeleting] = useState(false);
168
+ const [showMoveModal, setShowMoveModal] = useState(false);
169
+ const [showBulkDeleteConfirm, setShowBulkDeleteConfirm] = useState(false);
170
+ const [isBulkDeleting, setIsBulkDeleting] = useState(false);
171
+ const [activeView, setActiveView] = useState<MediaView>("library");
172
+ const [isRestoring, setIsRestoring] = useState(false);
173
+ const [isPermanentlyDeleting, setIsPermanentlyDeleting] = useState(false);
174
+ const [showPermanentDeleteConfirm, setShowPermanentDeleteConfirm] =
175
+ useState(false);
176
+ const [permanentDeleteTarget, setPermanentDeleteTarget] = useState<
177
+ string | "bulk" | null
178
+ >(null);
179
+
180
+ const isTrashView = activeView === "trash";
181
+
182
+ const assetsResult = useQuery(api.listMediaAssets, {
183
+ folderId: isTrashView ? undefined : currentFolderId,
184
+ type: typeFilter || undefined,
185
+ search: searchQuery || undefined,
186
+ deletedOnly: isTrashView ? true : undefined,
187
+ paginationOpts: { numItems: 100, cursor: null },
188
+ });
189
+
190
+ const trashCount = useQuery(api.getMediaTrashCount, {});
191
+
192
+ const mediaByTermResult0 = useQuery(
193
+ api.getMediaByTerm,
194
+ selectedTermIds[0] ? { termId: selectedTermIds[0] } : "skip"
195
+ );
196
+ const mediaByTermResult1 = useQuery(
197
+ api.getMediaByTerm,
198
+ selectedTermIds[1] ? { termId: selectedTermIds[1] } : "skip"
199
+ );
200
+ const mediaByTermResult2 = useQuery(
201
+ api.getMediaByTerm,
202
+ selectedTermIds[2] ? { termId: selectedTermIds[2] } : "skip"
203
+ );
204
+
205
+ const termFilteredMediaIds = useMemo(() => {
206
+ if (selectedTermIds.length === 0) return null;
207
+ const ids = new Set<string>();
208
+ const results = [
209
+ mediaByTermResult0,
210
+ mediaByTermResult1,
211
+ mediaByTermResult2,
212
+ ];
213
+ for (let i = 0; i < selectedTermIds.length && i < 3; i++) {
214
+ const result = results[i];
215
+ if (result?.page) {
216
+ for (const mediaId of result.page) {
217
+ ids.add(mediaId);
218
+ }
219
+ }
220
+ }
221
+ return ids;
222
+ }, [
223
+ selectedTermIds,
224
+ mediaByTermResult0,
225
+ mediaByTermResult1,
226
+ mediaByTermResult2,
227
+ ]);
228
+
229
+ const folders = useQuery(api.listMediaFolders, {
230
+ parentId: isTrashView ? undefined : currentFolderId,
231
+ deletedOnly: isTrashView || undefined,
232
+ });
233
+
234
+ const currentFolder = useQuery(
235
+ api.getMediaFolder,
236
+ currentFolderId ? { id: currentFolderId } : "skip"
237
+ );
238
+
239
+ const folderTree = useQuery(api.getMediaFolderTree, {});
240
+
241
+ const createFolder = useMutation(api.createMediaFolder);
242
+ const deleteAsset = useMutation(api.deleteMediaAsset);
243
+ const deleteFolder = useMutation(api.deleteMediaFolder);
244
+ const restoreAsset = useMutation(api.restoreMediaAsset);
245
+ const restoreFolder = useMutation(api.restoreMediaFolder);
246
+ const permanentDeleteAsset = useMutation(api.permanentDeleteMediaAsset);
247
+ const bulkPermanentDeleteAssets = useMutation(
248
+ api.bulkPermanentDeleteMediaAssets
249
+ );
250
+
251
+ const breadcrumbPath = useMemo(() => {
252
+ if (!currentFolderId || !folderTree) return [];
253
+
254
+ type FolderItem = (typeof folderTree)[number];
255
+ const path: FolderItem[] = [];
256
+ let folder: FolderItem | undefined = folderTree.find(
257
+ (f) => f._id === currentFolderId
258
+ );
259
+
260
+ while (folder) {
261
+ path.unshift(folder);
262
+ const parentId = folder.parentId;
263
+ folder = parentId ? folderTree.find((f) => f._id === parentId) : undefined;
264
+ }
265
+
266
+ return path;
267
+ }, [currentFolderId, folderTree]);
268
+
269
+ const handleFolderClick = useCallback((folderId: string) => {
270
+ setCurrentFolderId(folderId);
271
+ setSearchQuery("");
272
+ }, []);
273
+
274
+ const handleNavigateUp = useCallback(() => {
275
+ if (currentFolder?.parentId) {
276
+ setCurrentFolderId(currentFolder.parentId as string);
277
+ } else {
278
+ setCurrentFolderId(undefined);
279
+ }
280
+ }, [currentFolder]);
281
+
282
+ const handleNavigateToRoot = useCallback(() => {
283
+ setCurrentFolderId(undefined);
284
+ setSearchQuery("");
285
+ }, []);
286
+
287
+ const handleAssetSelect = useCallback((assetId: string) => {
288
+ setSelectedAssets((prev) => {
289
+ const next = new Set(prev);
290
+ if (next.has(assetId)) {
291
+ next.delete(assetId);
292
+ } else {
293
+ next.add(assetId);
294
+ }
295
+ return next;
296
+ });
297
+ }, []);
298
+
299
+ const handleSelectAll = useCallback(() => {
300
+ if (!assetsResult?.page) return;
301
+ setSelectedAssets(new Set(assetsResult.page.map((a) => a._id as string)));
302
+ }, [assetsResult?.page]);
303
+
304
+ const handleDeselectAll = useCallback(() => {
305
+ setSelectedAssets(new Set());
306
+ }, []);
307
+
308
+ const handleViewChange = useCallback((view: MediaView) => {
309
+ setActiveView(view);
310
+ setSelectedAssets(new Set());
311
+ setIsSelectionMode(false);
312
+ }, []);
313
+
314
+ const handleAssetClick = useCallback(
315
+ (assetId: string) => {
316
+ if (isSelectionMode) {
317
+ handleAssetSelect(assetId);
318
+ } else {
319
+ const index =
320
+ assetsResult?.page?.findIndex((a) => a._id === assetId) ?? -1;
321
+ if (index !== -1) {
322
+ setPreviewIndex(index);
323
+ }
324
+ }
325
+ },
326
+ [isSelectionMode, handleAssetSelect, assetsResult?.page]
327
+ );
328
+
329
+ const handlePreviewNavigate = useCallback((index: number) => {
330
+ setPreviewIndex(index);
331
+ }, []);
332
+
333
+ const handleDelete = useCallback(async () => {
334
+ if (!deleteTarget) return;
335
+
336
+ setIsDeleting(true);
337
+ try {
338
+ if (deleteTarget.type === "asset") {
339
+ await deleteAsset({ id: deleteTarget.id });
340
+ } else {
341
+ await deleteFolder({ id: deleteTarget.id });
342
+ }
343
+ setDeleteTarget(null);
344
+ } catch (err) {
345
+ console.error("Delete failed:", err);
346
+ } finally {
347
+ setIsDeleting(false);
348
+ }
349
+ }, [deleteTarget, deleteAsset, deleteFolder]);
350
+
351
+ const handleBulkDelete = useCallback(async () => {
352
+ if (selectedAssets.size === 0) return;
353
+
354
+ setIsBulkDeleting(true);
355
+ try {
356
+ const deletePromises = Array.from(selectedAssets).map((id) =>
357
+ deleteAsset({ id })
358
+ );
359
+ await Promise.all(deletePromises);
360
+ setSelectedAssets(new Set());
361
+ setIsSelectionMode(false);
362
+ setShowBulkDeleteConfirm(false);
363
+ } catch (err) {
364
+ console.error("Bulk delete failed:", err);
365
+ } finally {
366
+ setIsBulkDeleting(false);
367
+ }
368
+ }, [selectedAssets, deleteAsset]);
369
+
370
+ const handleBulkMoveComplete = useCallback(() => {
371
+ setSelectedAssets(new Set());
372
+ setIsSelectionMode(false);
373
+ }, []);
374
+
375
+ const handleRestore = useCallback(
376
+ async (assetId: string) => {
377
+ setIsRestoring(true);
378
+ try {
379
+ await restoreAsset({ id: assetId });
380
+ } catch (err) {
381
+ console.error("Restore failed:", err);
382
+ } finally {
383
+ setIsRestoring(false);
384
+ }
385
+ },
386
+ [restoreAsset]
387
+ );
388
+
389
+ const handleBulkRestore = useCallback(async () => {
390
+ if (selectedAssets.size === 0) return;
391
+
392
+ setIsRestoring(true);
393
+ try {
394
+ const restorePromises = Array.from(selectedAssets).map((id) =>
395
+ restoreAsset({ id })
396
+ );
397
+ await Promise.all(restorePromises);
398
+ setSelectedAssets(new Set());
399
+ setIsSelectionMode(false);
400
+ } catch (err) {
401
+ console.error("Bulk restore failed:", err);
402
+ } finally {
403
+ setIsRestoring(false);
404
+ }
405
+ }, [selectedAssets, restoreAsset]);
406
+
407
+ const handleRestoreFolder = useCallback(
408
+ async (folderId: string) => {
409
+ setIsRestoring(true);
410
+ try {
411
+ await restoreFolder({ id: folderId });
412
+ } catch (err) {
413
+ console.error("Restore folder failed:", err);
414
+ } finally {
415
+ setIsRestoring(false);
416
+ }
417
+ },
418
+ [restoreFolder]
419
+ );
420
+
421
+ const handlePermanentDelete = useCallback(async () => {
422
+ if (!permanentDeleteTarget) return;
423
+
424
+ setIsPermanentlyDeleting(true);
425
+ try {
426
+ if (permanentDeleteTarget === "bulk") {
427
+ await bulkPermanentDeleteAssets({ ids: Array.from(selectedAssets) });
428
+ setSelectedAssets(new Set());
429
+ setIsSelectionMode(false);
430
+ } else {
431
+ await permanentDeleteAsset({ id: permanentDeleteTarget });
432
+ }
433
+ setShowPermanentDeleteConfirm(false);
434
+ setPermanentDeleteTarget(null);
435
+ } catch (err) {
436
+ console.error("Permanent delete failed:", err);
437
+ } finally {
438
+ setIsPermanentlyDeleting(false);
439
+ }
440
+ }, [
441
+ permanentDeleteTarget,
442
+ permanentDeleteAsset,
443
+ bulkPermanentDeleteAssets,
444
+ selectedAssets,
445
+ ]);
446
+
447
+ const handleCreateFolder = useCallback(async () => {
448
+ if (!newFolderName.trim()) {
449
+ setFolderError("Folder name is required");
450
+ return;
451
+ }
452
+
453
+ setIsCreatingFolder(true);
454
+ setFolderError("");
455
+
456
+ try {
457
+ await createFolder({
458
+ name: newFolderName.trim(),
459
+ parentId: currentFolderId,
460
+ });
461
+ setShowNewFolderModal(false);
462
+ setNewFolderName("");
463
+ } catch (error) {
464
+ setFolderError(
465
+ error instanceof Error ? error.message : "Failed to create folder"
466
+ );
467
+ } finally {
468
+ setIsCreatingFolder(false);
469
+ }
470
+ }, [newFolderName, currentFolderId, createFolder]);
471
+
472
+ const handleUploadComplete = useCallback((_results: UploadedFile[]) => {
473
+ setShowUploadModal(false);
474
+ }, []);
475
+
476
+ const isLoading = assetsResult === undefined || folders === undefined;
477
+
478
+ const displayedAssets = useMemo(() => {
479
+ const assets = assetsResult?.page ?? [];
480
+ if (termFilteredMediaIds === null) {
481
+ return assets;
482
+ }
483
+ return assets.filter((asset) => termFilteredMediaIds.has(asset._id));
484
+ }, [assetsResult?.page, termFilteredMediaIds]);
485
+
20
486
  return (
21
487
  <div className="space-y-6 p-6">
22
488
  <CmsPageHeader
23
489
  title="Media Library"
24
- description="Upload and manage media assets like images, videos, and documents."
490
+ description="Upload, organize, and manage media assets for your content."
25
491
  />
26
492
 
27
- <CmsEmptyState
28
- icon={<Image className="size-6" />}
29
- title="Media library coming soon"
30
- description="The media management interface is under development."
493
+ <Tabs
494
+ value={activeView}
495
+ onValueChange={(v) => handleViewChange(v as MediaView)}
496
+ >
497
+ <TabsList>
498
+ <TabsTrigger value="library">
499
+ <Image className="mr-1.5 size-4" />
500
+ Library
501
+ </TabsTrigger>
502
+ <TabsTrigger value="trash">
503
+ <Trash2 className="mr-1.5 size-4" />
504
+ Trash
505
+ {trashCount && trashCount.total > 0 && (
506
+ <Badge variant="secondary" className="ml-1.5">
507
+ {trashCount.total}
508
+ </Badge>
509
+ )}
510
+ </TabsTrigger>
511
+ </TabsList>
512
+ </Tabs>
513
+
514
+ {!isTrashView && (
515
+ <nav className="flex items-center gap-1" aria-label="Folder navigation">
516
+ <button
517
+ className={cn(
518
+ "flex items-center gap-1.5 rounded-md px-2 py-1 text-sm transition-colors",
519
+ !currentFolderId
520
+ ? "bg-primary/10 font-medium text-primary"
521
+ : "text-muted-foreground hover:bg-muted hover:text-foreground"
522
+ )}
523
+ onClick={handleNavigateToRoot}
524
+ >
525
+ <Home className="size-4" />
526
+ <span>All Files</span>
527
+ </button>
528
+ {breadcrumbPath.map((folder, index) => (
529
+ <span key={folder._id} className="flex items-center">
530
+ <span className="mx-1 text-muted-foreground">/</span>
531
+ <button
532
+ className={cn(
533
+ "rounded-md px-2 py-1 text-sm transition-colors",
534
+ index === breadcrumbPath.length - 1
535
+ ? "bg-primary/10 font-medium text-primary"
536
+ : "text-muted-foreground hover:bg-muted hover:text-foreground"
537
+ )}
538
+ onClick={() => handleFolderClick(folder._id as string)}
539
+ >
540
+ {folder.name}
541
+ </button>
542
+ </span>
543
+ ))}
544
+ </nav>
545
+ )}
546
+
547
+ <CmsToolbar
548
+ left={
549
+ <div className="flex items-center gap-3">
550
+ <div className="relative">
551
+ <Search className="absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
552
+ <Input
553
+ type="search"
554
+ placeholder="Search files..."
555
+ value={searchQuery}
556
+ onChange={(e) => setSearchQuery(e.target.value)}
557
+ className="w-64 pl-9"
558
+ />
559
+ {searchQuery && (
560
+ <button
561
+ className="absolute right-2 top-1/2 -translate-y-1/2 rounded p-0.5 hover:bg-muted"
562
+ onClick={() => setSearchQuery("")}
563
+ aria-label="Clear search"
564
+ >
565
+ <X className="size-4 text-muted-foreground" />
566
+ </button>
567
+ )}
568
+ </div>
569
+
570
+ <Select
571
+ value={typeFilter || "all"}
572
+ onValueChange={(v) =>
573
+ setTypeFilter(v === "all" ? "" : (v as MediaType))
574
+ }
575
+ >
576
+ <SelectTrigger className="w-36">
577
+ <SelectValue placeholder="All Types" />
578
+ </SelectTrigger>
579
+ <SelectContent>
580
+ <SelectItem value="all">All Types</SelectItem>
581
+ <SelectItem value="image">Images</SelectItem>
582
+ <SelectItem value="video">Videos</SelectItem>
583
+ <SelectItem value="audio">Audio</SelectItem>
584
+ <SelectItem value="document">Documents</SelectItem>
585
+ <SelectItem value="other">Other</SelectItem>
586
+ </SelectContent>
587
+ </Select>
588
+
589
+ <TaxonomyFilter
590
+ selectedTermIds={selectedTermIds}
591
+ onChange={setSelectedTermIds}
592
+ placeholder="Tags"
593
+ />
594
+
595
+ {assetsResult?.page && assetsResult.page.length > 0 && (
596
+ <label className="flex cursor-pointer items-center gap-2 text-sm">
597
+ <Checkbox
598
+ checked={isSelectionMode}
599
+ onCheckedChange={(checked) => {
600
+ setIsSelectionMode(checked as boolean);
601
+ if (!checked) {
602
+ setSelectedAssets(new Set());
603
+ }
604
+ }}
605
+ />
606
+ Selection Mode
607
+ </label>
608
+ )}
609
+ </div>
610
+ }
611
+ right={
612
+ <div className="flex items-center gap-2">
613
+ {isSelectionMode && selectedAssets.size > 0 && (
614
+ <span className="text-sm text-muted-foreground">
615
+ {selectedAssets.size} selected
616
+ </span>
617
+ )}
618
+
619
+ {isSelectionMode && (
620
+ <>
621
+ <CmsButton
622
+ variant="secondary"
623
+ size="sm"
624
+ onClick={handleSelectAll}
625
+ >
626
+ Select All
627
+ </CmsButton>
628
+ <CmsButton
629
+ variant="secondary"
630
+ size="sm"
631
+ onClick={handleDeselectAll}
632
+ >
633
+ Clear
634
+ </CmsButton>
635
+ </>
636
+ )}
637
+
638
+ {currentFolderId && !isTrashView && (
639
+ <CmsButton variant="secondary" onClick={handleNavigateUp}>
640
+ <ChevronLeft className="size-4" />
641
+ Up
642
+ </CmsButton>
643
+ )}
644
+
645
+ {!isTrashView && (
646
+ <>
647
+ <CmsButton
648
+ variant="secondary"
649
+ onClick={() => setShowNewFolderModal(true)}
650
+ >
651
+ <FolderPlus className="size-4" />
652
+ New Folder
653
+ </CmsButton>
654
+
655
+ <CmsButton onClick={() => setShowUploadModal(true)}>
656
+ <Upload className="size-4" />
657
+ Upload Files
658
+ </CmsButton>
659
+ </>
660
+ )}
661
+ </div>
662
+ }
31
663
  />
664
+
665
+ {isLoading ? (
666
+ <div className="flex flex-col items-center justify-center py-12">
667
+ <div className="size-8 animate-spin rounded-full border-2 border-muted border-t-primary" />
668
+ <p className="mt-4 text-sm text-muted-foreground">
669
+ Loading media library...
670
+ </p>
671
+ </div>
672
+ ) : (
673
+ <>
674
+ {!isTrashView && folders && folders.length > 0 && !searchQuery && (
675
+ <section>
676
+ <h3 className="mb-3 text-sm font-medium text-muted-foreground">
677
+ Folders
678
+ </h3>
679
+ <div className="grid grid-cols-2 gap-3 sm:grid-cols-4 md:grid-cols-6">
680
+ {folders.map((folder) => (
681
+ <div
682
+ key={folder._id}
683
+ className="group relative flex cursor-pointer flex-col items-center gap-2 rounded-lg border bg-card p-4 text-center transition-colors hover:border-primary/50 hover:bg-muted/50"
684
+ onClick={() => handleFolderClick(folder._id as string)}
685
+ >
686
+ <div className="absolute right-2 top-2">
687
+ <MediaFolderActions
688
+ folder={{ _id: folder._id, name: folder.name }}
689
+ onEdit={() =>
690
+ setEditingFolder({
691
+ _id: folder._id,
692
+ name: folder.name,
693
+ description: folder.description,
694
+ })
695
+ }
696
+ onDelete={() =>
697
+ setDeleteTarget({
698
+ type: "folder",
699
+ id: folder._id,
700
+ name: folder.name,
701
+ })
702
+ }
703
+ />
704
+ </div>
705
+ <Folder className="size-10 text-amber-500" />
706
+ <span className="truncate text-sm font-medium">
707
+ {folder.name}
708
+ </span>
709
+ </div>
710
+ ))}
711
+ </div>
712
+ </section>
713
+ )}
714
+
715
+ {isTrashView && folders && folders.length > 0 && (
716
+ <section>
717
+ <h3 className="mb-3 text-sm font-medium text-muted-foreground">
718
+ Deleted Folders ({folders.length})
719
+ </h3>
720
+ <div className="grid grid-cols-2 gap-3 sm:grid-cols-4 md:grid-cols-6">
721
+ {folders.map((folder) => (
722
+ <div
723
+ key={folder._id}
724
+ className="group relative flex flex-col items-center gap-2 rounded-lg border border-destructive/20 bg-card p-4 text-center opacity-60"
725
+ >
726
+ <Folder className="size-10 text-amber-500/50" />
727
+ <span className="truncate text-sm font-medium">
728
+ {folder.name}
729
+ </span>
730
+ <CmsButton
731
+ variant="secondary"
732
+ size="sm"
733
+ onClick={() => handleRestoreFolder(folder._id)}
734
+ disabled={isRestoring}
735
+ >
736
+ <RotateCcw className="mr-1 size-3" />
737
+ Restore
738
+ </CmsButton>
739
+ </div>
740
+ ))}
741
+ </div>
742
+ </section>
743
+ )}
744
+
745
+ {displayedAssets.length > 0 ? (
746
+ <section>
747
+ {!isTrashView && folders && folders.length > 0 && !searchQuery && (
748
+ <h3 className="mb-3 text-sm font-medium text-muted-foreground">
749
+ Files
750
+ </h3>
751
+ )}
752
+ {isTrashView && (
753
+ <h3 className="mb-3 text-sm font-medium text-muted-foreground">
754
+ Deleted Files ({displayedAssets.length})
755
+ </h3>
756
+ )}
757
+ <div className="grid grid-cols-2 gap-3 sm:grid-cols-4 md:grid-cols-6">
758
+ {displayedAssets.map((asset) => {
759
+ const assetId = asset._id as string;
760
+ const isSelected = selectedAssets.has(assetId);
761
+ const mediaType = getMediaTypeFromMimeType(asset.mimeType);
762
+
763
+ return (
764
+ <div
765
+ key={asset._id}
766
+ className={cn(
767
+ "group relative flex cursor-pointer flex-col overflow-hidden rounded-lg border bg-card transition-all hover:border-primary/50",
768
+ isSelected && "border-primary ring-2 ring-primary/20"
769
+ )}
770
+ onClick={() => handleAssetClick(assetId)}
771
+ >
772
+ {isSelectionMode && (
773
+ <div className="absolute left-2 top-2 z-10">
774
+ <Checkbox
775
+ checked={isSelected}
776
+ onCheckedChange={() => handleAssetSelect(assetId)}
777
+ onClick={(e) => e.stopPropagation()}
778
+ className="bg-white/80"
779
+ />
780
+ </div>
781
+ )}
782
+
783
+ {!isSelectionMode && !isTrashView && (
784
+ <div className="absolute right-2 top-2 z-10">
785
+ <MediaAssetActions
786
+ asset={{
787
+ _id: asset._id,
788
+ name: asset.name,
789
+ url: asset.url,
790
+ }}
791
+ onView={() => {
792
+ const index = displayedAssets.findIndex(
793
+ (a) => a._id === asset._id
794
+ );
795
+ if (index !== -1) setPreviewIndex(index);
796
+ }}
797
+ onEdit={() =>
798
+ setEditingAsset({
799
+ _id: asset._id,
800
+ name: asset.name,
801
+ title: asset.title,
802
+ description: asset.description,
803
+ altText: asset.altText,
804
+ tags: asset.tags,
805
+ })
806
+ }
807
+ onDelete={() =>
808
+ setDeleteTarget({
809
+ type: "asset",
810
+ id: asset._id,
811
+ name: asset.name,
812
+ })
813
+ }
814
+ />
815
+ </div>
816
+ )}
817
+
818
+ {!isSelectionMode && isTrashView && (
819
+ <div className="absolute right-2 top-2 z-10 flex gap-1 opacity-0 group-hover:opacity-100">
820
+ <CmsButton
821
+ variant="secondary"
822
+ size="icon-sm"
823
+ onClick={(e) => {
824
+ e.stopPropagation();
825
+ handleRestore(asset._id);
826
+ }}
827
+ title="Restore"
828
+ >
829
+ <RotateCcw className="size-4" />
830
+ </CmsButton>
831
+ <CmsButton
832
+ variant="danger"
833
+ size="icon-sm"
834
+ onClick={(e) => {
835
+ e.stopPropagation();
836
+ setPermanentDeleteTarget(asset._id);
837
+ setShowPermanentDeleteConfirm(true);
838
+ }}
839
+ title="Delete Forever"
840
+ >
841
+ <Trash2 className="size-4" />
842
+ </CmsButton>
843
+ </div>
844
+ )}
845
+
846
+ <div className="aspect-square overflow-hidden bg-muted">
847
+ {mediaType === "image" && asset.url ? (
848
+ <img
849
+ src={asset.url}
850
+ alt={asset.title || asset.name}
851
+ className="h-full w-full object-cover"
852
+ />
853
+ ) : (
854
+ <div className="flex size-full items-center justify-center text-muted-foreground">
855
+ {getMediaTypeIcon(mediaType, "size-10")}
856
+ </div>
857
+ )}
858
+ </div>
859
+
860
+ <div className="p-2">
861
+ <p
862
+ className="truncate text-sm font-medium"
863
+ title={asset.name}
864
+ >
865
+ {asset.name}
866
+ </p>
867
+ <div className="mt-0.5 flex items-center gap-1 text-xs text-muted-foreground">
868
+ <span className="capitalize">{mediaType}</span>
869
+ <span>•</span>
870
+ <span>{formatFileSize(asset.size ?? 0)}</span>
871
+ </div>
872
+ <p className="mt-0.5 text-xs text-muted-foreground">
873
+ {formatDate(asset._creationTime)}
874
+ </p>
875
+ </div>
876
+ </div>
877
+ );
878
+ })}
879
+ </div>
880
+
881
+ {!assetsResult.isDone && (
882
+ <p className="mt-4 text-center text-sm text-muted-foreground">
883
+ Showing {assetsResult.page.length} files. More files available.
884
+ </p>
885
+ )}
886
+ </section>
887
+ ) : isTrashView ? (
888
+ <CmsEmptyState
889
+ icon={<Trash2 className="size-8" />}
890
+ title="Trash is empty"
891
+ description="Deleted files will appear here. You can restore them or permanently delete them."
892
+ />
893
+ ) : (
894
+ !folders?.length && (
895
+ <CmsEmptyState
896
+ icon={<Image className="size-8" />}
897
+ title="No media assets yet"
898
+ description="Upload images, videos, documents, and other files to use in your content."
899
+ action={{
900
+ label: "Upload Files",
901
+ onClick: () => setShowUploadModal(true),
902
+ }}
903
+ />
904
+ )
905
+ )}
906
+
907
+ {searchQuery && displayedAssets.length === 0 && !isTrashView && (
908
+ <CmsEmptyState
909
+ icon={<Search className="size-8" />}
910
+ title="No results found"
911
+ description={`No files match "${searchQuery}". Try a different search term.`}
912
+ action={{
913
+ label: "Clear Search",
914
+ onClick: () => setSearchQuery(""),
915
+ variant: "secondary",
916
+ }}
917
+ />
918
+ )}
919
+ </>
920
+ )}
921
+
922
+ <Dialog open={showNewFolderModal} onOpenChange={setShowNewFolderModal}>
923
+ <DialogContent>
924
+ <DialogHeader>
925
+ <DialogTitle>Create New Folder</DialogTitle>
926
+ </DialogHeader>
927
+ <div className="space-y-4 py-4">
928
+ <div className="space-y-2">
929
+ <Label htmlFor="folder-name">Folder Name</Label>
930
+ <Input
931
+ id="folder-name"
932
+ value={newFolderName}
933
+ onChange={(e) => {
934
+ setNewFolderName(e.target.value);
935
+ setFolderError("");
936
+ }}
937
+ placeholder="Enter folder name"
938
+ autoFocus
939
+ onKeyDown={(e) => {
940
+ if (e.key === "Enter" && !isCreatingFolder) {
941
+ handleCreateFolder();
942
+ }
943
+ }}
944
+ />
945
+ {folderError && (
946
+ <p className="text-sm text-destructive">{folderError}</p>
947
+ )}
948
+ </div>
949
+ </div>
950
+ <DialogFooter>
951
+ <CmsButton
952
+ variant="secondary"
953
+ onClick={() => setShowNewFolderModal(false)}
954
+ disabled={isCreatingFolder}
955
+ >
956
+ Cancel
957
+ </CmsButton>
958
+ <CmsButton
959
+ onClick={handleCreateFolder}
960
+ disabled={isCreatingFolder || !newFolderName.trim()}
961
+ loading={isCreatingFolder}
962
+ >
963
+ Create Folder
964
+ </CmsButton>
965
+ </DialogFooter>
966
+ </DialogContent>
967
+ </Dialog>
968
+
969
+ <Dialog open={showUploadModal} onOpenChange={setShowUploadModal}>
970
+ <DialogContent className="max-w-lg">
971
+ <DialogHeader>
972
+ <DialogTitle>Upload Files</DialogTitle>
973
+ </DialogHeader>
974
+ <div className="py-4">
975
+ <UploadDropzone
976
+ currentFolderId={currentFolderId}
977
+ generateUploadUrl={api.generateUploadUrl}
978
+ createAsset={api.createMediaAsset}
979
+ onUploadComplete={handleUploadComplete}
980
+ maxFileSize={50 * 1024 * 1024}
981
+ maxConcurrentUploads={3}
982
+ />
983
+ </div>
984
+ <DialogFooter>
985
+ <CmsButton
986
+ variant="secondary"
987
+ onClick={() => setShowUploadModal(false)}
988
+ >
989
+ Close
990
+ </CmsButton>
991
+ </DialogFooter>
992
+ </DialogContent>
993
+ </Dialog>
994
+
995
+ <MediaPreviewModal
996
+ asset={
997
+ previewIndex !== null && displayedAssets[previewIndex]
998
+ ? (displayedAssets[previewIndex] as MediaAsset)
999
+ : null
1000
+ }
1001
+ assets={displayedAssets as MediaAsset[]}
1002
+ currentIndex={previewIndex ?? 0}
1003
+ open={previewIndex !== null}
1004
+ onOpenChange={(open) => {
1005
+ if (!open) setPreviewIndex(null);
1006
+ }}
1007
+ onNavigate={handlePreviewNavigate}
1008
+ onEdit={
1009
+ isTrashView
1010
+ ? undefined
1011
+ : (asset) =>
1012
+ setEditingAsset({
1013
+ _id: asset._id,
1014
+ name: asset.name,
1015
+ title: asset.title,
1016
+ description: asset.description,
1017
+ altText: asset.altText,
1018
+ tags: asset.tags,
1019
+ })
1020
+ }
1021
+ onDelete={
1022
+ isTrashView
1023
+ ? undefined
1024
+ : (asset) =>
1025
+ setDeleteTarget({
1026
+ type: "asset",
1027
+ id: asset._id,
1028
+ name: asset.name,
1029
+ })
1030
+ }
1031
+ />
1032
+
1033
+ <MediaAssetEditDialog
1034
+ asset={editingAsset}
1035
+ open={editingAsset !== null}
1036
+ onOpenChange={(open) => {
1037
+ if (!open) setEditingAsset(null);
1038
+ }}
1039
+ />
1040
+
1041
+ <MediaFolderEditDialog
1042
+ folder={editingFolder}
1043
+ open={editingFolder !== null}
1044
+ onOpenChange={(open) => {
1045
+ if (!open) setEditingFolder(null);
1046
+ }}
1047
+ />
1048
+
1049
+ <CmsConfirmDialog
1050
+ open={deleteTarget !== null}
1051
+ onOpenChange={(open) => {
1052
+ if (!open) setDeleteTarget(null);
1053
+ }}
1054
+ title={`Delete ${deleteTarget?.type === "folder" ? "Folder" : "File"}?`}
1055
+ description={`Are you sure you want to delete "${deleteTarget?.name}"? ${deleteTarget?.type === "folder" ? "This will also delete all files inside the folder." : "This action can be undone from the trash."}`}
1056
+ confirmLabel="Delete"
1057
+ onConfirm={handleDelete}
1058
+ variant="danger"
1059
+ loading={isDeleting}
1060
+ />
1061
+
1062
+ <CmsConfirmDialog
1063
+ open={showBulkDeleteConfirm}
1064
+ onOpenChange={setShowBulkDeleteConfirm}
1065
+ title="Delete Selected Files?"
1066
+ description={`Are you sure you want to delete ${selectedAssets.size} ${selectedAssets.size === 1 ? "file" : "files"}? This action can be undone from the trash.`}
1067
+ confirmLabel="Delete All"
1068
+ onConfirm={handleBulkDelete}
1069
+ variant="danger"
1070
+ loading={isBulkDeleting}
1071
+ />
1072
+
1073
+ <CmsConfirmDialog
1074
+ open={showPermanentDeleteConfirm}
1075
+ onOpenChange={(open) => {
1076
+ setShowPermanentDeleteConfirm(open);
1077
+ if (!open) setPermanentDeleteTarget(null);
1078
+ }}
1079
+ title={
1080
+ permanentDeleteTarget === "bulk"
1081
+ ? `Delete ${selectedAssets.size} ${selectedAssets.size === 1 ? "File" : "Files"} Forever?`
1082
+ : "Delete Forever?"
1083
+ }
1084
+ description={
1085
+ permanentDeleteTarget === "bulk"
1086
+ ? `This will permanently delete ${selectedAssets.size} ${selectedAssets.size === 1 ? "file" : "files"}. This action cannot be undone.`
1087
+ : "This will permanently delete this file. This action cannot be undone."
1088
+ }
1089
+ confirmLabel="Delete Forever"
1090
+ onConfirm={handlePermanentDelete}
1091
+ variant="danger"
1092
+ loading={isPermanentlyDeleting}
1093
+ />
1094
+
1095
+ <MediaMoveModal
1096
+ open={showMoveModal}
1097
+ onOpenChange={setShowMoveModal}
1098
+ assetIds={Array.from(selectedAssets)}
1099
+ currentFolderId={currentFolderId}
1100
+ onMoved={handleBulkMoveComplete}
1101
+ />
1102
+
1103
+ {isSelectionMode && !isTrashView && (
1104
+ <MediaBulkActionBar
1105
+ selectedCount={selectedAssets.size}
1106
+ onClear={handleDeselectAll}
1107
+ onMove={() => setShowMoveModal(true)}
1108
+ onDelete={() => setShowBulkDeleteConfirm(true)}
1109
+ isDeleting={isBulkDeleting}
1110
+ />
1111
+ )}
1112
+
1113
+ {isSelectionMode && isTrashView && (
1114
+ <MediaTrashBulkActionBar
1115
+ selectedCount={selectedAssets.size}
1116
+ onClear={handleDeselectAll}
1117
+ onRestore={handleBulkRestore}
1118
+ onPermanentDelete={() => {
1119
+ setPermanentDeleteTarget("bulk");
1120
+ setShowPermanentDeleteConfirm(true);
1121
+ }}
1122
+ isRestoring={isRestoring}
1123
+ isDeleting={isPermanentlyDeleting}
1124
+ />
1125
+ )}
32
1126
  </div>
33
1127
  );
34
1128
  }