@drawnagency/primitives 0.1.76 → 0.1.78
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/dist/{chunk-Q3T23HVW.js → chunk-2DDWWTRX.js} +5 -5
- package/dist/chunk-2G2S36SY.js +13 -0
- package/dist/{chunk-S55OZ7F2.js → chunk-3LNKOLZP.js} +4 -0
- package/dist/{chunk-SWIHUIFT.js → chunk-4SIWBWKZ.js} +12 -2
- package/dist/{chunk-SWEVWC5K.js → chunk-BAQYZSFF.js} +22 -22
- package/dist/{chunk-IFHRHV7S.js → chunk-EMGLZQCM.js} +1 -1
- package/dist/{chunk-NKVZGPR6.js → chunk-K4JD6FD3.js} +1 -1
- package/dist/{chunk-SFKYUKVN.js → chunk-MK4POMLZ.js} +1 -1
- package/dist/{chunk-7T2WQSWF.js → chunk-NS3UTJ3E.js} +11 -11
- package/dist/chunk-REM2NH7K.js +44 -0
- package/dist/{chunk-T7MP7ANP.js → chunk-UA6OCJJK.js} +2 -2
- package/dist/{chunk-ZRWGV5O6.js → chunk-Z5IYWUW3.js} +3 -3
- package/dist/collab/provider-registry.d.ts +4 -0
- package/dist/collab/provider-registry.d.ts.map +1 -0
- package/dist/collab/provider-registry.js +9 -0
- package/dist/collab/rebase.d.ts +37 -0
- package/dist/collab/rebase.d.ts.map +1 -0
- package/dist/collab/rebase.js +64 -0
- package/dist/collab/recovery.d.ts +89 -0
- package/dist/collab/recovery.d.ts.map +1 -0
- package/dist/collab/types.d.ts +93 -0
- package/dist/collab/types.d.ts.map +1 -0
- package/dist/collab/types.js +19 -0
- package/dist/components/editor/AudienceIndicator.d.ts +3 -1
- package/dist/components/editor/AudienceIndicator.d.ts.map +1 -1
- package/dist/components/editor/ConflictDialog.d.ts +14 -0
- package/dist/components/editor/ConflictDialog.d.ts.map +1 -0
- package/dist/components/editor/IndicatorPill.d.ts +4 -1
- package/dist/components/editor/IndicatorPill.d.ts.map +1 -1
- package/dist/components/editor/LockBadge.d.ts +7 -0
- package/dist/components/editor/LockBadge.d.ts.map +1 -0
- package/dist/components/editor/PresenceAvatars.d.ts +9 -0
- package/dist/components/editor/PresenceAvatars.d.ts.map +1 -0
- package/dist/components/editor/SectionWrapper.d.ts +1 -1
- package/dist/components/editor/SectionWrapper.d.ts.map +1 -1
- package/dist/components/editor/StatusIndicator.d.ts +3 -1
- package/dist/components/editor/StatusIndicator.d.ts.map +1 -1
- package/dist/components/editor/index.js +95 -47
- package/dist/components/primitives/index.js +5 -5
- package/dist/components/sections/brand-guide.js +5 -5
- package/dist/components/sections/register-schemas.js +11 -11
- package/dist/components/sections/register.js +11 -11
- package/dist/components/shell/EditorShell.d.ts +3 -1
- package/dist/components/shell/EditorShell.d.ts.map +1 -1
- package/dist/components/shell/MediaBreadcrumb.d.ts +10 -0
- package/dist/components/shell/MediaBreadcrumb.d.ts.map +1 -0
- package/dist/components/shell/MediaFolderTile.d.ts +15 -0
- package/dist/components/shell/MediaFolderTile.d.ts.map +1 -0
- package/dist/components/shell/MediaFolderTree.d.ts +20 -0
- package/dist/components/shell/MediaFolderTree.d.ts.map +1 -0
- package/dist/components/shell/MediaLibraryModal.d.ts +15 -3
- package/dist/components/shell/MediaLibraryModal.d.ts.map +1 -1
- package/dist/components/shell/MoveToFolderDialog.d.ts +16 -0
- package/dist/components/shell/MoveToFolderDialog.d.ts.map +1 -0
- package/dist/hooks/useCollabSession.d.ts +23 -0
- package/dist/hooks/useCollabSession.d.ts.map +1 -0
- package/dist/hooks/useEditorPersistence.d.ts +21 -1
- package/dist/hooks/useEditorPersistence.d.ts.map +1 -1
- package/dist/hooks/useEditorPublish.d.ts +39 -2
- package/dist/hooks/useEditorPublish.d.ts.map +1 -1
- package/dist/hooks/useMediaFolderDnd.d.ts +27 -0
- package/dist/hooks/useMediaFolderDnd.d.ts.map +1 -0
- package/dist/hooks/useMediaPipeline.d.ts +14 -2
- package/dist/hooks/useMediaPipeline.d.ts.map +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +51 -25
- package/dist/lib/dexie.d.ts +12 -0
- package/dist/lib/dexie.d.ts.map +1 -1
- package/dist/lib/dexie.js +27 -0
- package/dist/lib/index.js +12 -12
- package/dist/lib/media-dnd.d.ts +26 -0
- package/dist/lib/media-dnd.d.ts.map +1 -0
- package/dist/lib/media-folders.d.ts +59 -0
- package/dist/lib/media-folders.d.ts.map +1 -0
- package/dist/lib/registry.d.ts +10 -0
- package/dist/lib/registry.d.ts.map +1 -1
- package/dist/media/folder-drop.d.ts +22 -0
- package/dist/media/folder-drop.d.ts.map +1 -0
- package/dist/media/index.js +6 -2
- package/dist/media/types.d.ts +1 -1
- package/dist/media/types.d.ts.map +1 -1
- package/dist/media/utils.d.ts +7 -0
- package/dist/media/utils.d.ts.map +1 -1
- package/dist/schemas/index.js +5 -3
- package/dist/schemas/media.d.ts +11 -0
- package/dist/schemas/media.d.ts.map +1 -1
- package/dist/types/database.d.ts +70 -0
- package/dist/types/database.d.ts.map +1 -1
- package/package.json +13 -1
- package/src/collab/provider-registry.ts +14 -0
- package/src/collab/rebase.ts +140 -0
- package/src/collab/recovery.ts +149 -0
- package/src/collab/types.ts +96 -0
- package/src/components/editor/AudienceIndicator.tsx +4 -1
- package/src/components/editor/ConflictDialog.tsx +61 -0
- package/src/components/editor/IndicatorPill.tsx +12 -6
- package/src/components/editor/LockBadge.tsx +31 -0
- package/src/components/editor/PresenceAvatars.tsx +53 -0
- package/src/components/editor/SectionWrapper.tsx +60 -29
- package/src/components/editor/StatusIndicator.tsx +4 -0
- package/src/components/shell/EditorShell.tsx +581 -22
- package/src/components/shell/MediaBreadcrumb.tsx +77 -0
- package/src/components/shell/MediaFolderTile.tsx +141 -0
- package/src/components/shell/MediaFolderTree.tsx +127 -0
- package/src/components/shell/MediaLibraryModal.tsx +611 -229
- package/src/components/shell/MoveToFolderDialog.tsx +78 -0
- package/src/hooks/useCollabSession.ts +220 -0
- package/src/hooks/useEditorPersistence.ts +104 -1
- package/src/hooks/useEditorPublish.ts +216 -71
- package/src/hooks/useMediaFolderDnd.ts +129 -0
- package/src/hooks/useMediaPipeline.ts +162 -6
- package/src/index.ts +2 -0
- package/src/lib/dexie.ts +44 -0
- package/src/lib/media-dnd.ts +62 -0
- package/src/lib/media-folders.ts +232 -0
- package/src/lib/registry.ts +8 -0
- package/src/media/folder-drop.ts +62 -0
- package/src/media/types.ts +2 -0
- package/src/media/utils.ts +10 -0
- package/src/schemas/media.ts +13 -0
- package/src/types/database.ts +55 -0
|
@@ -1,17 +1,27 @@
|
|
|
1
|
-
import { useState, useRef, useCallback } from "react";
|
|
2
|
-
import { Search, Upload, Trash2 } from "lucide-react";
|
|
1
|
+
import { useState, useRef, useCallback, useMemo, useEffect } from "react";
|
|
2
|
+
import { Search, Upload, Trash2, FolderPlus, FolderInput } from "lucide-react";
|
|
3
3
|
import { cn } from "../../lib/cn";
|
|
4
4
|
import { Button } from "../shared/Button";
|
|
5
5
|
import { UploadRejectionAlert } from "../shared/UploadRejectionAlert";
|
|
6
6
|
import { Select } from "../shared/Select";
|
|
7
|
-
import type { MediaItem, MediaKind } from "../../media/types";
|
|
8
|
-
import { displayFilename, formatFileSize, mimeToExt } from "../../media/utils";
|
|
7
|
+
import type { MediaFolder, MediaItem, MediaKind } from "../../media/types";
|
|
8
|
+
import { displayFilename, displayName, formatFileSize, mimeToExt } from "../../media/utils";
|
|
9
|
+
import {
|
|
10
|
+
childFolderIds, descendantFolderIds, folderPathIds, folderPathNames, itemFolderId, recursiveItemCounts,
|
|
11
|
+
} from "../../lib/media-folders";
|
|
12
|
+
import { buildMediaDragData } from "../../lib/media-dnd";
|
|
13
|
+
import { useMediaDraggable } from "../../hooks/useMediaFolderDnd";
|
|
14
|
+
import { MediaFolderTree, type MediaFolderDndBindings } from "./MediaFolderTree";
|
|
15
|
+
import { MediaFolderTile } from "./MediaFolderTile";
|
|
16
|
+
import { MediaBreadcrumb } from "./MediaBreadcrumb";
|
|
17
|
+
import { MoveToFolderDialog } from "./MoveToFolderDialog";
|
|
18
|
+
import { collectDroppedFiles, type DroppedFile } from "../../media/folder-drop";
|
|
9
19
|
|
|
10
20
|
export interface MediaLibraryModalProps {
|
|
11
21
|
mode: "select" | "manage";
|
|
12
22
|
items: MediaItem[];
|
|
13
23
|
onSelect: (id: string) => void;
|
|
14
|
-
onUpload: (files: File[]) => void;
|
|
24
|
+
onUpload: (files: File[], targetFolderId?: string | null) => void;
|
|
15
25
|
onDelete: (ids: string[]) => void;
|
|
16
26
|
onAltChange?: (id: string, alt: string) => void;
|
|
17
27
|
referenceCountMap: Record<string, number>;
|
|
@@ -24,6 +34,15 @@ export interface MediaLibraryModalProps {
|
|
|
24
34
|
// client-side size pre-check below, which never reaches the pipeline.
|
|
25
35
|
uploadError?: string | null;
|
|
26
36
|
onDismissUploadError?: () => void;
|
|
37
|
+
folders?: Record<string, MediaFolder>;
|
|
38
|
+
onCreateFolder?: (name: string, parentId: string | null) => string;
|
|
39
|
+
onRenameFolder?: (id: string, name: string) => void;
|
|
40
|
+
onMoveFolder?: (id: string, parentId: string | null) => void;
|
|
41
|
+
onDeleteFolder?: (id: string, contents: "move" | "delete") => void;
|
|
42
|
+
onMoveMedia?: (ids: string[], folderId: string | null) => void;
|
|
43
|
+
onFolderUpload?: (entries: { file: File; relativePath: string }[], currentFolderId: string | null) => void;
|
|
44
|
+
uploadNotice?: string | null;
|
|
45
|
+
onDismissUploadNotice?: () => void;
|
|
27
46
|
}
|
|
28
47
|
|
|
29
48
|
type KindFilter = "all" | MediaKind;
|
|
@@ -42,9 +61,13 @@ function thumbnailSrc(item: MediaItem, localUrls: Record<string, string>): strin
|
|
|
42
61
|
}
|
|
43
62
|
|
|
44
63
|
|
|
45
|
-
function UploadZone({ onUpload }: {
|
|
64
|
+
function UploadZone({ onUpload, onFolderUpload }: {
|
|
65
|
+
onUpload: (files: File[]) => void;
|
|
66
|
+
onFolderUpload?: (files: DroppedFile[]) => void;
|
|
67
|
+
}) {
|
|
46
68
|
const [dragging, setDragging] = useState(false);
|
|
47
69
|
const inputRef = useRef<HTMLInputElement>(null);
|
|
70
|
+
const folderInputRef = useRef<HTMLInputElement>(null);
|
|
48
71
|
|
|
49
72
|
const handleFiles = useCallback(
|
|
50
73
|
(files: FileList | null) => {
|
|
@@ -55,42 +78,107 @@ function UploadZone({ onUpload }: { onUpload: (files: File[]) => void }) {
|
|
|
55
78
|
);
|
|
56
79
|
|
|
57
80
|
return (
|
|
58
|
-
<
|
|
59
|
-
|
|
81
|
+
<div
|
|
82
|
+
role="button"
|
|
83
|
+
tabIndex={0}
|
|
60
84
|
onClick={() => inputRef.current?.click()}
|
|
61
|
-
|
|
62
|
-
e.preventDefault();
|
|
63
|
-
setDragging(true);
|
|
85
|
+
onKeyDown={(e) => {
|
|
86
|
+
if (e.key === "Enter" || e.key === " ") { e.preventDefault(); inputRef.current?.click(); }
|
|
64
87
|
}}
|
|
88
|
+
onDragOver={(e) => { e.preventDefault(); setDragging(true); }}
|
|
65
89
|
onDragLeave={() => setDragging(false)}
|
|
66
90
|
onDrop={(e) => {
|
|
67
91
|
e.preventDefault();
|
|
68
92
|
setDragging(false);
|
|
69
|
-
|
|
93
|
+
const flat = e.dataTransfer.files;
|
|
94
|
+
if (onFolderUpload) {
|
|
95
|
+
void collectDroppedFiles(e.dataTransfer)
|
|
96
|
+
.then((dropped) => {
|
|
97
|
+
if (dropped) onFolderUpload(dropped);
|
|
98
|
+
else handleFiles(flat);
|
|
99
|
+
})
|
|
100
|
+
.catch(() => handleFiles(flat));
|
|
101
|
+
} else {
|
|
102
|
+
handleFiles(flat);
|
|
103
|
+
}
|
|
70
104
|
}}
|
|
71
105
|
className={cn(
|
|
72
106
|
"flex w-full cursor-pointer flex-col items-center justify-center gap-2 rounded-lg border-2 border-dashed px-6 py-8 text-center transition-colors",
|
|
73
|
-
dragging
|
|
74
|
-
? "border-primary bg-primary/5"
|
|
75
|
-
: "border-base-200 hover:border-base-300",
|
|
107
|
+
dragging ? "border-primary bg-primary/5" : "border-base-200 hover:border-base-300",
|
|
76
108
|
)}
|
|
77
109
|
>
|
|
78
110
|
<Upload size={24} className="text-base-contrast-light" />
|
|
79
111
|
<span className="text-sm text-base-contrast-light">
|
|
80
|
-
|
|
112
|
+
{onFolderUpload
|
|
113
|
+
? "Drag & drop files or folders, or click to browse"
|
|
114
|
+
: "Drag & drop files or click to browse"}
|
|
81
115
|
</span>
|
|
116
|
+
{onFolderUpload && (
|
|
117
|
+
<button
|
|
118
|
+
type="button"
|
|
119
|
+
onClick={(e) => { e.stopPropagation(); folderInputRef.current?.click(); }}
|
|
120
|
+
// Enter/Space keydown bubbles to the zone root BEFORE the button's
|
|
121
|
+
// synthetic click fires — stop it so the zone doesn't also open
|
|
122
|
+
// the flat file picker (see the matching chevron fix in
|
|
123
|
+
// MediaFolderTree.tsx).
|
|
124
|
+
onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") e.stopPropagation(); }}
|
|
125
|
+
className="text-xs text-primary underline-offset-2 hover:underline"
|
|
126
|
+
>
|
|
127
|
+
Upload a folder
|
|
128
|
+
</button>
|
|
129
|
+
)}
|
|
130
|
+
{/* Programmatic .click() on these hidden inputs dispatches a click event
|
|
131
|
+
that bubbles to the zone's own onClick — without stopPropagation the
|
|
132
|
+
folder picker would ALSO open the flat picker (only reproducible in a
|
|
133
|
+
real browser; jsdom surfaces no native choosers). */}
|
|
82
134
|
<input
|
|
83
135
|
ref={inputRef}
|
|
84
136
|
type="file"
|
|
85
137
|
multiple
|
|
86
138
|
accept="image/*,video/*"
|
|
87
139
|
className="hidden"
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
e.target.value = "";
|
|
91
|
-
}}
|
|
140
|
+
onClick={(e) => e.stopPropagation()}
|
|
141
|
+
onChange={(e) => { handleFiles(e.target.files); e.target.value = ""; }}
|
|
92
142
|
/>
|
|
93
|
-
|
|
143
|
+
{onFolderUpload && (
|
|
144
|
+
<input
|
|
145
|
+
ref={folderInputRef}
|
|
146
|
+
type="file"
|
|
147
|
+
multiple
|
|
148
|
+
className="hidden"
|
|
149
|
+
onClick={(e) => e.stopPropagation()}
|
|
150
|
+
{...({ webkitdirectory: "" } as Record<string, string>)}
|
|
151
|
+
onChange={(e) => {
|
|
152
|
+
const files = Array.from(e.target.files ?? []);
|
|
153
|
+
if (files.length > 0) {
|
|
154
|
+
onFolderUpload(
|
|
155
|
+
files.map((f) => ({
|
|
156
|
+
file: f,
|
|
157
|
+
relativePath:
|
|
158
|
+
(f as File & { webkitRelativePath?: string }).webkitRelativePath || f.name,
|
|
159
|
+
})),
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
e.target.value = "";
|
|
163
|
+
}}
|
|
164
|
+
/>
|
|
165
|
+
)}
|
|
166
|
+
</div>
|
|
167
|
+
);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function MediaCard({ item, manage, isSelected, dragIds, onClick, children }: {
|
|
171
|
+
item: MediaItem; manage: boolean; isSelected: boolean;
|
|
172
|
+
dragIds: string[]; onClick: () => void; children: React.ReactNode;
|
|
173
|
+
}) {
|
|
174
|
+
const drag = useMediaDraggable<HTMLDivElement>({
|
|
175
|
+
enabled: manage,
|
|
176
|
+
getDragData: () => buildMediaDragData(dragIds),
|
|
177
|
+
});
|
|
178
|
+
return (
|
|
179
|
+
<div ref={drag.ref} className={cn("group flex flex-col gap-1.5", drag.isDragging && "opacity-40")} onClick={onClick}>
|
|
180
|
+
{children}
|
|
181
|
+
</div>
|
|
94
182
|
);
|
|
95
183
|
}
|
|
96
184
|
|
|
@@ -107,15 +195,101 @@ export function MediaLibraryModal({
|
|
|
107
195
|
uploadProgress = {},
|
|
108
196
|
uploadError = null,
|
|
109
197
|
onDismissUploadError,
|
|
198
|
+
folders = {},
|
|
199
|
+
onCreateFolder,
|
|
200
|
+
onRenameFolder,
|
|
201
|
+
onMoveFolder,
|
|
202
|
+
onDeleteFolder,
|
|
203
|
+
onMoveMedia,
|
|
204
|
+
onFolderUpload,
|
|
205
|
+
uploadNotice = null,
|
|
206
|
+
onDismissUploadNotice,
|
|
110
207
|
}: MediaLibraryModalProps) {
|
|
111
208
|
const [search, setSearch] = useState("");
|
|
112
209
|
const [kindFilter, setKindFilter] = useState<KindFilter>("all");
|
|
113
210
|
const [selected, setSelected] = useState<Set<string>>(new Set());
|
|
114
211
|
const [rejectedFiles, setRejectedFiles] = useState<string[]>([]);
|
|
115
212
|
|
|
213
|
+
const [currentFolderId, setCurrentFolderId] = useState<string | null>(null);
|
|
214
|
+
const [expandedIds, setExpandedIds] = useState<Set<string>>(new Set());
|
|
215
|
+
const [newFolderOpen, setNewFolderOpen] = useState(false);
|
|
216
|
+
const [newFolderName, setNewFolderName] = useState("");
|
|
217
|
+
const [showMobileTree, setShowMobileTree] = useState(false);
|
|
218
|
+
const [moveDialogOpen, setMoveDialogOpen] = useState(false);
|
|
219
|
+
const [folderToDelete, setFolderToDelete] = useState<string | null>(null);
|
|
220
|
+
|
|
221
|
+
const manage = mode === "manage";
|
|
222
|
+
const filtering = search !== "" || kindFilter !== "all";
|
|
223
|
+
|
|
224
|
+
const itemsById = useMemo(
|
|
225
|
+
() => Object.fromEntries(items.map((i) => [i.id, i])),
|
|
226
|
+
[items],
|
|
227
|
+
);
|
|
228
|
+
const counts = useMemo(() => recursiveItemCounts(folders, items), [folders, items]);
|
|
229
|
+
|
|
230
|
+
const folderDeleteInfo = useMemo(() => {
|
|
231
|
+
if (!folderToDelete || !folders[folderToDelete]) return null;
|
|
232
|
+
const doomed = descendantFolderIds(folders, folderToDelete);
|
|
233
|
+
const contained = items.filter((item) => {
|
|
234
|
+
const fid = itemFolderId(folders, item);
|
|
235
|
+
return fid !== null && doomed.has(fid);
|
|
236
|
+
});
|
|
237
|
+
const inUse = contained.filter((item) => (referenceCountMap[item.id] ?? 0) > 0).length;
|
|
238
|
+
return { name: folders[folderToDelete].name, count: contained.length, inUse };
|
|
239
|
+
}, [folderToDelete, folders, items, referenceCountMap]);
|
|
240
|
+
|
|
241
|
+
// Fresh state for drop handlers without re-registering dnd targets.
|
|
242
|
+
const foldersRef = useRef(folders);
|
|
243
|
+
foldersRef.current = folders;
|
|
244
|
+
const itemsByIdRef = useRef(itemsById);
|
|
245
|
+
itemsByIdRef.current = itemsById;
|
|
246
|
+
|
|
247
|
+
const dnd: MediaFolderDndBindings | undefined =
|
|
248
|
+
manage && onMoveMedia
|
|
249
|
+
? {
|
|
250
|
+
foldersRef, itemsByIdRef,
|
|
251
|
+
onMoveMedia,
|
|
252
|
+
onMoveFolder: onMoveFolder ?? (() => {}),
|
|
253
|
+
}
|
|
254
|
+
: undefined;
|
|
255
|
+
|
|
256
|
+
const navigate = useCallback((id: string | null) => {
|
|
257
|
+
setCurrentFolderId(id);
|
|
258
|
+
if (id) setExpandedIds((prev) => new Set([...prev, ...folderPathIds(foldersRef.current, id)]));
|
|
259
|
+
}, []);
|
|
260
|
+
|
|
261
|
+
const expand = useCallback((id: string) => {
|
|
262
|
+
setExpandedIds((prev) => (prev.has(id) ? prev : new Set([...prev, id])));
|
|
263
|
+
}, []);
|
|
264
|
+
const toggleExpand = useCallback((id: string) => {
|
|
265
|
+
setExpandedIds((prev) => {
|
|
266
|
+
const next = new Set(prev);
|
|
267
|
+
if (next.has(id)) next.delete(id);
|
|
268
|
+
else next.add(id);
|
|
269
|
+
return next;
|
|
270
|
+
});
|
|
271
|
+
}, []);
|
|
272
|
+
|
|
273
|
+
// Current folder deleted (or healed away) -> back to root.
|
|
274
|
+
useEffect(() => {
|
|
275
|
+
if (currentFolderId && !folders[currentFolderId]) setCurrentFolderId(null);
|
|
276
|
+
}, [folders, currentFolderId]);
|
|
277
|
+
|
|
278
|
+
const childIds = filtering ? [] : childFolderIds(folders, currentFolderId);
|
|
279
|
+
|
|
280
|
+
const commitNewFolder = () => {
|
|
281
|
+
const name = newFolderName.trim();
|
|
282
|
+
if (name && onCreateFolder) {
|
|
283
|
+
onCreateFolder(name, currentFolderId);
|
|
284
|
+
if (currentFolderId) expand(currentFolderId);
|
|
285
|
+
}
|
|
286
|
+
setNewFolderOpen(false);
|
|
287
|
+
setNewFolderName("");
|
|
288
|
+
};
|
|
289
|
+
|
|
116
290
|
const handleUpload = useCallback((files: File[]) => {
|
|
117
291
|
if (!maxFileSize) {
|
|
118
|
-
onUpload(files);
|
|
292
|
+
onUpload(files, currentFolderId);
|
|
119
293
|
return;
|
|
120
294
|
}
|
|
121
295
|
const valid: File[] = [];
|
|
@@ -132,15 +306,15 @@ export function MediaLibraryModal({
|
|
|
132
306
|
}
|
|
133
307
|
}
|
|
134
308
|
if (rejected.length > 0) setRejectedFiles(rejected);
|
|
135
|
-
if (valid.length > 0) onUpload(valid);
|
|
136
|
-
}, [maxFileSize, onUpload]);
|
|
309
|
+
if (valid.length > 0) onUpload(valid, currentFolderId);
|
|
310
|
+
}, [maxFileSize, onUpload, currentFolderId]);
|
|
137
311
|
|
|
138
312
|
const filtered = items.filter((item) => {
|
|
139
|
-
const matchesSearch = item.
|
|
140
|
-
.toLowerCase()
|
|
141
|
-
.includes(search.toLowerCase());
|
|
313
|
+
const matchesSearch = displayName(item).toLowerCase().includes(search.toLowerCase());
|
|
142
314
|
const matchesKind = kindFilter === "all" || item.kind === kindFilter;
|
|
143
|
-
|
|
315
|
+
if (!matchesSearch || !matchesKind) return false;
|
|
316
|
+
if (!filtering && itemFolderId(folders, item) !== currentFolderId) return false;
|
|
317
|
+
return true;
|
|
144
318
|
});
|
|
145
319
|
|
|
146
320
|
function toggleSelect(id: string) {
|
|
@@ -160,9 +334,19 @@ export function MediaLibraryModal({
|
|
|
160
334
|
const selectedIds = Array.from(selected);
|
|
161
335
|
const totalUsage = selectedIds.reduce((sum, id) => sum + (referenceCountMap[id] ?? 0), 0);
|
|
162
336
|
|
|
337
|
+
// The move dialog's no-op target is the selection's folder — not the folder
|
|
338
|
+
// being viewed (selection survives navigation). Heterogeneous selections have
|
|
339
|
+
// no single no-op target, so nothing is disabled.
|
|
340
|
+
const selectionFolderIds = new Set(
|
|
341
|
+
Array.from(selected).map((id) => (itemsById[id] ? itemFolderId(folders, itemsById[id]) : null)),
|
|
342
|
+
);
|
|
343
|
+
const moveDialogCurrentTarget =
|
|
344
|
+
selectionFolderIds.size === 1 ? Array.from(selectionFolderIds)[0] : undefined;
|
|
345
|
+
|
|
163
346
|
function handleBatchDelete() {
|
|
164
347
|
if (totalUsage > 0) {
|
|
165
348
|
setConfirmDelete(true);
|
|
349
|
+
setFolderToDelete(null); // the two delete confirmations are mutually exclusive
|
|
166
350
|
return;
|
|
167
351
|
}
|
|
168
352
|
onDelete(selectedIds);
|
|
@@ -176,230 +360,428 @@ export function MediaLibraryModal({
|
|
|
176
360
|
}
|
|
177
361
|
|
|
178
362
|
const uploadingHashes = Object.keys(uploadProgress);
|
|
363
|
+
const folderTreeProps = {
|
|
364
|
+
folders,
|
|
365
|
+
counts,
|
|
366
|
+
totalCount: items.length,
|
|
367
|
+
currentFolderId,
|
|
368
|
+
expandedIds,
|
|
369
|
+
onExpand: expand,
|
|
370
|
+
onToggleExpand: toggleExpand,
|
|
371
|
+
dnd,
|
|
372
|
+
};
|
|
179
373
|
|
|
180
374
|
return (
|
|
181
|
-
<div className="flex
|
|
182
|
-
<
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
{uploadingHashes.length > 0 && (
|
|
186
|
-
<div className="space-y-1.5">
|
|
187
|
-
{uploadingHashes.map((hash) => {
|
|
188
|
-
const pct = Math.round((uploadProgress[hash] ?? 0) * 100);
|
|
189
|
-
return (
|
|
190
|
-
<div key={hash} className="flex items-center gap-2">
|
|
191
|
-
<span className="shrink-0 text-xs text-base-contrast-light">Uploading video…</span>
|
|
192
|
-
<div className="h-1.5 flex-1 overflow-hidden rounded-full bg-base-200">
|
|
193
|
-
<div
|
|
194
|
-
className="h-full rounded-full bg-primary transition-[width]"
|
|
195
|
-
style={{ width: `${pct}%` }}
|
|
196
|
-
/>
|
|
197
|
-
</div>
|
|
198
|
-
<span className="w-9 shrink-0 text-right text-xs text-base-contrast-light">{pct}%</span>
|
|
199
|
-
</div>
|
|
200
|
-
);
|
|
201
|
-
})}
|
|
202
|
-
</div>
|
|
203
|
-
)}
|
|
375
|
+
<div className="flex h-full">
|
|
376
|
+
<div className="hidden w-1/5 min-w-48 shrink-0 overflow-y-auto border-r border-base-200 p-4 md:block">
|
|
377
|
+
<MediaFolderTree {...folderTreeProps} onNavigate={navigate} />
|
|
378
|
+
</div>
|
|
204
379
|
|
|
205
|
-
{/*
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
380
|
+
{/* Each column carries its own padding (modal body is noPadding) so the
|
|
381
|
+
scrollbars sit at the pane edges; it doubles as paint room for focus
|
|
382
|
+
rings inside the scroll clip. */}
|
|
383
|
+
<div className="flex min-w-0 flex-1 flex-col gap-4 overflow-y-auto p-4">
|
|
384
|
+
<button
|
|
385
|
+
type="button"
|
|
386
|
+
aria-label="Show folders"
|
|
387
|
+
onClick={() => setShowMobileTree((v) => !v)}
|
|
388
|
+
className="flex items-center gap-1.5 self-start rounded border border-base-200 px-2.5 py-1.5 text-xs text-base-contrast-light md:hidden"
|
|
389
|
+
>
|
|
390
|
+
Folders
|
|
391
|
+
</button>
|
|
392
|
+
{showMobileTree && (
|
|
393
|
+
<div className="rounded-lg border border-base-200 p-2 md:hidden">
|
|
394
|
+
<MediaFolderTree
|
|
395
|
+
{...folderTreeProps}
|
|
396
|
+
onNavigate={(id) => { navigate(id); setShowMobileTree(false); }}
|
|
219
397
|
/>
|
|
220
398
|
</div>
|
|
399
|
+
)}
|
|
400
|
+
|
|
401
|
+
<UploadZone
|
|
402
|
+
onUpload={handleUpload}
|
|
403
|
+
onFolderUpload={
|
|
404
|
+
// Folder upload creates folders — a management operation, so it is
|
|
405
|
+
// manage-mode only. Select mode gets a plain UploadZone (regular
|
|
406
|
+
// file uploads while picking remain available).
|
|
407
|
+
manage && onFolderUpload
|
|
408
|
+
? (dropped) => {
|
|
409
|
+
const valid = dropped.filter(({ file }) => {
|
|
410
|
+
const isBucketEligibleVideo = file.type === "video/mp4" || file.type === "video/webm";
|
|
411
|
+
return !maxFileSize || file.size <= maxFileSize || isBucketEligibleVideo;
|
|
412
|
+
});
|
|
413
|
+
const rejected = dropped.length - valid.length;
|
|
414
|
+
if (rejected > 0) setRejectedFiles(dropped.filter((d) => !valid.includes(d)).map((d) => d.file.name));
|
|
415
|
+
if (valid.length > 0) onFolderUpload(valid, currentFolderId);
|
|
416
|
+
}
|
|
417
|
+
: undefined
|
|
418
|
+
}
|
|
419
|
+
/>
|
|
221
420
|
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
421
|
+
{/* Direct-to-bucket large-video upload progress */}
|
|
422
|
+
{uploadingHashes.length > 0 && (
|
|
423
|
+
<div className="space-y-1.5">
|
|
424
|
+
{uploadingHashes.map((hash) => {
|
|
425
|
+
const pct = Math.round((uploadProgress[hash] ?? 0) * 100);
|
|
426
|
+
return (
|
|
427
|
+
<div key={hash} className="flex items-center gap-2">
|
|
428
|
+
<span className="shrink-0 text-xs text-base-contrast-light">Uploading video…</span>
|
|
429
|
+
<div className="h-1.5 flex-1 overflow-hidden rounded-full bg-base-200">
|
|
430
|
+
<div
|
|
431
|
+
className="h-full rounded-full bg-primary transition-[width]"
|
|
432
|
+
style={{ width: `${pct}%` }}
|
|
433
|
+
/>
|
|
434
|
+
</div>
|
|
435
|
+
<span className="w-9 shrink-0 text-right text-xs text-base-contrast-light">{pct}%</span>
|
|
436
|
+
</div>
|
|
437
|
+
);
|
|
438
|
+
})}
|
|
439
|
+
</div>
|
|
440
|
+
)}
|
|
441
|
+
|
|
442
|
+
{/* Sticky toolbar + confirmation */}
|
|
443
|
+
{/* -top-4/-mt-4/pt-4 extend the sticky box up through the column's
|
|
444
|
+
p-4 strip, which scrolled content would otherwise peek through. */}
|
|
445
|
+
<div
|
|
446
|
+
className={cn(
|
|
447
|
+
"sticky -top-4 z-10 -mt-4 space-y-3 bg-base pb-3 pt-4",
|
|
448
|
+
(moveDialogOpen || newFolderOpen) && "border-b border-base-200",
|
|
244
449
|
)}
|
|
245
|
-
|
|
450
|
+
>
|
|
451
|
+
<div className="flex items-center gap-2">
|
|
452
|
+
<div className="relative flex-1">
|
|
453
|
+
<Search
|
|
454
|
+
size={14}
|
|
455
|
+
className="pointer-events-none absolute left-2.5 top-1/2 -translate-y-1/2 text-base-contrast-light"
|
|
456
|
+
/>
|
|
457
|
+
<input
|
|
458
|
+
type="text"
|
|
459
|
+
placeholder="Search media..."
|
|
460
|
+
value={search}
|
|
461
|
+
onChange={(e) => setSearch(e.target.value)}
|
|
462
|
+
className="w-full rounded border border-base-200 bg-base py-1.5 pl-8 pr-3 text-sm text-base-contrast placeholder:text-base-contrast-light focus:outline-none focus:ring-1 focus:ring-primary"
|
|
463
|
+
/>
|
|
464
|
+
</div>
|
|
246
465
|
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
466
|
+
<Select
|
|
467
|
+
value={kindFilter}
|
|
468
|
+
onChange={(v) => setKindFilter(v as KindFilter)}
|
|
469
|
+
options={[
|
|
470
|
+
{ value: "all", label: "All types" },
|
|
471
|
+
{ value: "image", label: "Images" },
|
|
472
|
+
{ value: "animated", label: "Animated" },
|
|
473
|
+
{ value: "video", label: "Video" },
|
|
474
|
+
]}
|
|
475
|
+
selectClassName="py-1.5 pr-7 pl-2.5"
|
|
476
|
+
/>
|
|
477
|
+
|
|
478
|
+
{manage && onCreateFolder && !filtering && (
|
|
479
|
+
<Button variant="secondary" size="sm" onClick={() => setNewFolderOpen(true)} className="flex items-center gap-1.5">
|
|
480
|
+
<FolderPlus size={13} />
|
|
481
|
+
New folder
|
|
256
482
|
</Button>
|
|
257
|
-
|
|
258
|
-
|
|
483
|
+
)}
|
|
484
|
+
|
|
485
|
+
{mode === "manage" && selected.size > 0 && onMoveMedia && (
|
|
486
|
+
<Button variant="secondary" size="sm" onClick={() => setMoveDialogOpen(true)} className="flex items-center gap-1.5">
|
|
487
|
+
<FolderInput size={13} />
|
|
488
|
+
Move {selected.size}
|
|
259
489
|
</Button>
|
|
260
|
-
|
|
490
|
+
)}
|
|
491
|
+
|
|
492
|
+
{mode === "manage" && selected.size > 0 && (
|
|
493
|
+
<Button
|
|
494
|
+
variant="destructive"
|
|
495
|
+
size="sm"
|
|
496
|
+
onClick={handleBatchDelete}
|
|
497
|
+
className="flex items-center gap-1.5"
|
|
498
|
+
>
|
|
499
|
+
<Trash2 size={13} />
|
|
500
|
+
Delete {selected.size}
|
|
501
|
+
</Button>
|
|
502
|
+
)}
|
|
261
503
|
</div>
|
|
262
|
-
)}
|
|
263
504
|
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
505
|
+
{moveDialogOpen && onMoveMedia && (
|
|
506
|
+
<div>
|
|
507
|
+
<p className="mb-2 text-sm font-medium text-base-contrast">Move {selected.size} item{selected.size === 1 ? "" : "s"} to…</p>
|
|
508
|
+
<MoveToFolderDialog
|
|
509
|
+
folders={folders}
|
|
510
|
+
currentTarget={moveDialogCurrentTarget}
|
|
511
|
+
onMove={(target) => { onMoveMedia(Array.from(selected), target); setSelected(new Set()); }}
|
|
512
|
+
onClose={() => setMoveDialogOpen(false)}
|
|
513
|
+
/>
|
|
514
|
+
</div>
|
|
515
|
+
)}
|
|
516
|
+
|
|
517
|
+
{newFolderOpen && (
|
|
518
|
+
<div className="flex items-center gap-2">
|
|
519
|
+
<input
|
|
520
|
+
autoFocus
|
|
521
|
+
placeholder="Folder name"
|
|
522
|
+
value={newFolderName}
|
|
523
|
+
onChange={(e) => setNewFolderName(e.target.value)}
|
|
524
|
+
onKeyDown={(e) => {
|
|
525
|
+
if (e.key === "Enter") commitNewFolder();
|
|
526
|
+
if (e.key === "Escape") { setNewFolderOpen(false); setNewFolderName(""); }
|
|
527
|
+
}}
|
|
528
|
+
className="flex-1 rounded border border-base-200 bg-base px-2 py-1 text-sm text-base-contrast focus:outline-none focus:ring-1 focus:ring-primary"
|
|
529
|
+
/>
|
|
530
|
+
<Button variant="secondary" size="sm" onClick={() => { setNewFolderOpen(false); setNewFolderName(""); }}>Cancel</Button>
|
|
531
|
+
<Button variant="primary" size="sm" onClick={commitNewFolder}>Create</Button>
|
|
532
|
+
</div>
|
|
533
|
+
)}
|
|
534
|
+
|
|
535
|
+
{/* Delete confirmation */}
|
|
536
|
+
{confirmDelete && (
|
|
537
|
+
<div className="flex flex-col items-center rounded-lg border border-red-200 bg-red-50 px-4 py-3 lg:flex-row lg:justify-between dark:border-red-900/50 dark:bg-red-950/30">
|
|
538
|
+
<p className="mb-2 text-center text-sm text-red-800 lg:mb-0 lg:text-left dark:text-red-200">
|
|
539
|
+
Some of these images are being used. Deleting these will remove them from these references.
|
|
540
|
+
</p>
|
|
541
|
+
<div className="flex shrink-0 items-center gap-2">
|
|
542
|
+
<Button variant="secondary" size="sm" onClick={() => setConfirmDelete(false)}>
|
|
543
|
+
Cancel
|
|
544
|
+
</Button>
|
|
545
|
+
<Button variant="destructive" size="sm" onClick={handleConfirmDelete}>
|
|
546
|
+
Confirm Delete
|
|
547
|
+
</Button>
|
|
548
|
+
</div>
|
|
549
|
+
</div>
|
|
550
|
+
)}
|
|
551
|
+
|
|
552
|
+
{folderToDelete && folderDeleteInfo && onDeleteFolder && (
|
|
553
|
+
<div className="flex flex-col gap-2 rounded-lg border border-red-200 bg-red-50 px-4 py-3 dark:border-red-900/50 dark:bg-red-950/30">
|
|
554
|
+
<p className="text-sm text-red-800 dark:text-red-200">
|
|
555
|
+
Delete “{folderDeleteInfo.name}”? It contains {folderDeleteInfo.count} item{folderDeleteInfo.count === 1 ? "" : "s"}
|
|
556
|
+
{folderDeleteInfo.inUse > 0 && (
|
|
557
|
+
<> — {folderDeleteInfo.inUse === 1 ? "1 is in use" : `${folderDeleteInfo.inUse} are in use`} and will be removed from those sections</>
|
|
558
|
+
)}
|
|
559
|
+
.
|
|
560
|
+
</p>
|
|
561
|
+
<div className="flex flex-wrap items-center justify-end gap-2">
|
|
562
|
+
<Button variant="secondary" size="sm" onClick={() => setFolderToDelete(null)}>Cancel</Button>
|
|
563
|
+
<Button variant="secondary" size="sm"
|
|
564
|
+
onClick={() => { onDeleteFolder(folderToDelete, "move"); setFolderToDelete(null); }}>
|
|
565
|
+
Move contents to parent
|
|
566
|
+
</Button>
|
|
567
|
+
<Button variant="destructive" size="sm"
|
|
568
|
+
onClick={() => { onDeleteFolder(folderToDelete, "delete"); setFolderToDelete(null); }}>
|
|
569
|
+
Delete {folderDeleteInfo.count} item{folderDeleteInfo.count === 1 ? "" : "s"}
|
|
570
|
+
</Button>
|
|
571
|
+
</div>
|
|
572
|
+
</div>
|
|
573
|
+
)}
|
|
574
|
+
|
|
575
|
+
{/* File size rejection alert */}
|
|
576
|
+
{rejectedFiles.length > 0 && maxFileSize && (
|
|
577
|
+
<UploadRejectionAlert
|
|
578
|
+
message={
|
|
579
|
+
rejectedFiles.length === 1
|
|
580
|
+
? `"${rejectedFiles[0]}" exceeds the ${formatFileSize(maxFileSize)} file size limit.`
|
|
581
|
+
: `${rejectedFiles.length} files exceed the ${formatFileSize(maxFileSize)} file size limit.`
|
|
582
|
+
}
|
|
583
|
+
onDismiss={() => setRejectedFiles([])}
|
|
584
|
+
/>
|
|
585
|
+
)}
|
|
586
|
+
|
|
587
|
+
{/* Async upload failure (oversized/disallowed file, upload-intent error,
|
|
588
|
+
direct-to-bucket PUT failure) — distinct from the client-side check above. */}
|
|
589
|
+
{uploadError && (
|
|
590
|
+
<UploadRejectionAlert
|
|
591
|
+
message={uploadError}
|
|
592
|
+
onDismiss={() => onDismissUploadError?.()}
|
|
593
|
+
/>
|
|
594
|
+
)}
|
|
595
|
+
|
|
596
|
+
{/* Informational, non-error upload notice (e.g. dedup skip). */}
|
|
597
|
+
{uploadNotice && (
|
|
598
|
+
<UploadRejectionAlert
|
|
599
|
+
message={uploadNotice}
|
|
600
|
+
onDismiss={() => onDismissUploadNotice?.()}
|
|
601
|
+
/>
|
|
602
|
+
)}
|
|
603
|
+
</div>
|
|
604
|
+
|
|
605
|
+
{!filtering && (
|
|
606
|
+
<MediaBreadcrumb folders={folders} currentFolderId={currentFolderId} onNavigate={navigate} dnd={dnd} />
|
|
274
607
|
)}
|
|
275
608
|
|
|
276
|
-
{
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
609
|
+
{childIds.length > 0 && (
|
|
610
|
+
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5">
|
|
611
|
+
{childIds.map((fid) => (
|
|
612
|
+
<MediaFolderTile
|
|
613
|
+
key={fid}
|
|
614
|
+
id={fid}
|
|
615
|
+
name={folders[fid].name}
|
|
616
|
+
count={counts[fid] ?? 0}
|
|
617
|
+
onOpen={navigate}
|
|
618
|
+
manage={manage}
|
|
619
|
+
onRename={onRenameFolder}
|
|
620
|
+
onDeleteRequest={
|
|
621
|
+
manage && onDeleteFolder
|
|
622
|
+
? (id) => {
|
|
623
|
+
const doomed = descendantFolderIds(folders, id);
|
|
624
|
+
const hasContents =
|
|
625
|
+
items.some((item) => {
|
|
626
|
+
const fid = itemFolderId(folders, item);
|
|
627
|
+
return fid !== null && doomed.has(fid);
|
|
628
|
+
}) || doomed.size > 1;
|
|
629
|
+
if (hasContents) {
|
|
630
|
+
setFolderToDelete(id);
|
|
631
|
+
setConfirmDelete(false); // the two delete confirmations are mutually exclusive
|
|
632
|
+
} else onDeleteFolder(id, "move"); // empty: no dialog needed
|
|
633
|
+
}
|
|
634
|
+
: undefined
|
|
635
|
+
}
|
|
636
|
+
dnd={dnd}
|
|
637
|
+
ariaLabel={`Open folder ${folders[fid].name}`}
|
|
638
|
+
/>
|
|
639
|
+
))}
|
|
640
|
+
</div>
|
|
283
641
|
)}
|
|
284
|
-
</div>
|
|
285
642
|
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
643
|
+
{/* Grid */}
|
|
644
|
+
{filtered.length === 0 ? (
|
|
645
|
+
childIds.length === 0 && (
|
|
646
|
+
<div className="flex flex-col items-center justify-center gap-2 py-12 text-center">
|
|
647
|
+
<p className="text-sm font-medium text-base-contrast">
|
|
648
|
+
{!filtering && currentFolderId !== null
|
|
649
|
+
? "This folder is empty"
|
|
650
|
+
: items.length === 0
|
|
651
|
+
? "No media uploaded yet"
|
|
652
|
+
: "No results found"}
|
|
653
|
+
</p>
|
|
654
|
+
<p className="text-xs text-base-contrast-light">
|
|
655
|
+
{!filtering && currentFolderId !== null
|
|
656
|
+
? "Drag media here or upload into this folder."
|
|
657
|
+
: items.length === 0
|
|
658
|
+
? "Upload files using the drop zone above."
|
|
659
|
+
: "Try a different search or filter."}
|
|
660
|
+
</p>
|
|
661
|
+
</div>
|
|
662
|
+
)
|
|
663
|
+
) : (
|
|
664
|
+
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5">
|
|
665
|
+
{filtered.map((item) => {
|
|
666
|
+
const isSelected = selected.has(item.id);
|
|
667
|
+
const refCount = referenceCountMap[item.id] ?? 0;
|
|
668
|
+
const usageLabel = refCount === 0 ? "Not used" : `Used ${refCount}x`;
|
|
669
|
+
const dragIds = selected.has(item.id) && selected.size > 0 ? Array.from(selected) : [item.id];
|
|
670
|
+
|
|
671
|
+
return (
|
|
672
|
+
<MediaCard
|
|
673
|
+
key={item.id}
|
|
674
|
+
item={item}
|
|
675
|
+
manage={manage}
|
|
676
|
+
isSelected={isSelected}
|
|
677
|
+
dragIds={dragIds}
|
|
678
|
+
onClick={() => {
|
|
679
|
+
if (mode === "select") {
|
|
680
|
+
onSelect(item.id);
|
|
681
|
+
} else {
|
|
682
|
+
toggleSelect(item.id);
|
|
683
|
+
}
|
|
684
|
+
}}
|
|
327
685
|
>
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
/>
|
|
338
|
-
) : (
|
|
339
|
-
<img
|
|
340
|
-
src={thumbnailSrc(item, localUrlMap)}
|
|
341
|
-
alt={item.originalName}
|
|
342
|
-
className="h-full w-full object-cover"
|
|
343
|
-
/>
|
|
686
|
+
{/* Thumbnail */}
|
|
687
|
+
<div
|
|
688
|
+
className={cn(
|
|
689
|
+
"relative aspect-square cursor-pointer overflow-hidden rounded-lg border-2 transition-colors",
|
|
690
|
+
mode === "select"
|
|
691
|
+
? "border-transparent hover:border-primary"
|
|
692
|
+
: isSelected
|
|
693
|
+
? "border-primary"
|
|
694
|
+
: "border-transparent hover:border-base-300",
|
|
344
695
|
)}
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
696
|
+
>
|
|
697
|
+
<div className="h-full w-full bg-base-accent">
|
|
698
|
+
{item.kind === "video" ? (
|
|
699
|
+
<video
|
|
700
|
+
src={thumbnailSrc(item, localUrlMap)}
|
|
701
|
+
muted
|
|
702
|
+
playsInline
|
|
703
|
+
className="h-full w-full object-cover"
|
|
704
|
+
onMouseEnter={(e) => e.currentTarget.play().catch(() => {})}
|
|
705
|
+
onMouseLeave={(e) => { e.currentTarget.pause(); e.currentTarget.currentTime = 0; }}
|
|
706
|
+
/>
|
|
707
|
+
) : (
|
|
708
|
+
<img
|
|
709
|
+
src={thumbnailSrc(item, localUrlMap)}
|
|
710
|
+
// alt deliberately stays originalName — an accessibility
|
|
711
|
+
// fallback description, not a display name.
|
|
712
|
+
alt={item.originalName}
|
|
713
|
+
// Images are natively draggable — the browser's own image
|
|
714
|
+
// drag would hijack the card's pragmatic-dnd draggable.
|
|
715
|
+
draggable={false}
|
|
716
|
+
className="h-full w-full object-cover"
|
|
717
|
+
/>
|
|
357
718
|
)}
|
|
358
719
|
</div>
|
|
359
|
-
)}
|
|
360
|
-
|
|
361
|
-
{/* Usage pill — top-right, visible on hover or when selected */}
|
|
362
|
-
{mode === "manage" && (
|
|
363
|
-
<div className={cn(
|
|
364
|
-
"absolute top-1.5 right-1.5 rounded-full px-2 py-0.5 text-[11px] font-medium transition-opacity",
|
|
365
|
-
isSelected
|
|
366
|
-
? "opacity-100"
|
|
367
|
-
: "opacity-0 group-hover:opacity-100",
|
|
368
|
-
refCount > 0
|
|
369
|
-
? "bg-black/50 text-white"
|
|
370
|
-
: "bg-black/50 text-white/70",
|
|
371
|
-
)}>
|
|
372
|
-
{usageLabel}
|
|
373
|
-
</div>
|
|
374
|
-
)}
|
|
375
720
|
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
721
|
+
{/* Selection radio — top-left */}
|
|
722
|
+
{mode === "manage" && (
|
|
723
|
+
<div className={cn(
|
|
724
|
+
"absolute top-1.5 left-1.5 flex h-5 w-5 items-center justify-center rounded-full border-2 transition-colors",
|
|
725
|
+
isSelected
|
|
726
|
+
? "border-primary bg-primary"
|
|
727
|
+
: "border-white/80 bg-white/40 opacity-0 group-hover:opacity-100",
|
|
728
|
+
)}>
|
|
729
|
+
{isSelected && (
|
|
730
|
+
<div className="h-2 w-2 rounded-full bg-white" />
|
|
731
|
+
)}
|
|
732
|
+
</div>
|
|
733
|
+
)}
|
|
734
|
+
|
|
735
|
+
{/* Usage pill — top-right, visible on hover or when selected */}
|
|
736
|
+
{mode === "manage" && (
|
|
737
|
+
<div className={cn(
|
|
738
|
+
"absolute top-1.5 right-1.5 rounded-full px-2 py-0.5 text-[11px] font-medium transition-opacity",
|
|
739
|
+
isSelected
|
|
740
|
+
? "opacity-100"
|
|
741
|
+
: "opacity-0 group-hover:opacity-100",
|
|
742
|
+
refCount > 0
|
|
743
|
+
? "bg-black/50 text-white"
|
|
744
|
+
: "bg-black/50 text-white/70",
|
|
745
|
+
)}>
|
|
746
|
+
{usageLabel}
|
|
747
|
+
</div>
|
|
748
|
+
)}
|
|
749
|
+
|
|
750
|
+
{/* Folder path chip — bottom-left, shown only during global search results */}
|
|
751
|
+
{filtering && itemFolderId(folders, item) !== null && (
|
|
752
|
+
<div className="absolute bottom-1.5 left-1.5 max-w-[calc(100%-12px)] truncate rounded-full bg-black/50 px-2 py-0.5 text-[11px] text-white">
|
|
753
|
+
{folderPathNames(folders, itemFolderId(folders, item) as string).join(" / ")}
|
|
754
|
+
</div>
|
|
755
|
+
)}
|
|
756
|
+
|
|
757
|
+
{/* Filename overlay — bottom, visible on hover */}
|
|
758
|
+
<div className="absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/60 to-transparent px-2 pb-1.5 pt-4 opacity-0 transition-opacity group-hover:opacity-100">
|
|
759
|
+
<p className="truncate text-[11px] text-white">
|
|
760
|
+
{displayFilename(displayName(item), item.mimeType)}
|
|
761
|
+
</p>
|
|
762
|
+
</div>
|
|
381
763
|
</div>
|
|
382
|
-
</div>
|
|
383
764
|
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
765
|
+
{/* Alt text below image */}
|
|
766
|
+
<div className="px-0.5">
|
|
767
|
+
<label className="text-[10px] font-medium uppercase leading-none tracking-wide text-base-contrast-light/50">
|
|
768
|
+
Alt text
|
|
769
|
+
</label>
|
|
770
|
+
<textarea
|
|
771
|
+
value={item.alt}
|
|
772
|
+
placeholder="Describe this image for accessibility"
|
|
773
|
+
rows={2}
|
|
774
|
+
onClick={(e) => e.stopPropagation()}
|
|
775
|
+
onChange={(e) => onAltChange?.(item.id, e.target.value)}
|
|
776
|
+
className="w-full resize-none border-0 bg-transparent p-0 text-xs leading-snug text-base-contrast placeholder:text-base-contrast-light/40 focus:outline-none focus:ring-0"
|
|
777
|
+
/>
|
|
778
|
+
</div>
|
|
779
|
+
</MediaCard>
|
|
780
|
+
);
|
|
781
|
+
})}
|
|
782
|
+
</div>
|
|
783
|
+
)}
|
|
784
|
+
</div>
|
|
403
785
|
</div>
|
|
404
786
|
);
|
|
405
787
|
}
|