@drawnagency/primitives 0.1.77 → 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.
Files changed (63) hide show
  1. package/dist/{chunk-B2DLHZKC.js → chunk-2DDWWTRX.js} +1 -1
  2. package/dist/{chunk-S55OZ7F2.js → chunk-3LNKOLZP.js} +4 -0
  3. package/dist/{chunk-SWIHUIFT.js → chunk-4SIWBWKZ.js} +12 -2
  4. package/dist/{chunk-RO4E5UJI.js → chunk-BAQYZSFF.js} +6 -6
  5. package/dist/{chunk-IFHRHV7S.js → chunk-EMGLZQCM.js} +1 -1
  6. package/dist/{chunk-YP7IHMC3.js → chunk-K4JD6FD3.js} +1 -1
  7. package/dist/{chunk-SFKYUKVN.js → chunk-MK4POMLZ.js} +1 -1
  8. package/dist/{chunk-63BQQNZO.js → chunk-NS3UTJ3E.js} +3 -3
  9. package/dist/{chunk-UUCOJL7O.js → chunk-UA6OCJJK.js} +2 -2
  10. package/dist/{chunk-XFDEUK37.js → chunk-Z5IYWUW3.js} +1 -1
  11. package/dist/components/editor/index.js +4 -4
  12. package/dist/components/primitives/index.js +5 -5
  13. package/dist/components/sections/brand-guide.js +5 -5
  14. package/dist/components/sections/register-schemas.js +8 -8
  15. package/dist/components/sections/register.js +8 -8
  16. package/dist/components/shell/EditorShell.d.ts.map +1 -1
  17. package/dist/components/shell/MediaBreadcrumb.d.ts +10 -0
  18. package/dist/components/shell/MediaBreadcrumb.d.ts.map +1 -0
  19. package/dist/components/shell/MediaFolderTile.d.ts +15 -0
  20. package/dist/components/shell/MediaFolderTile.d.ts.map +1 -0
  21. package/dist/components/shell/MediaFolderTree.d.ts +20 -0
  22. package/dist/components/shell/MediaFolderTree.d.ts.map +1 -0
  23. package/dist/components/shell/MediaLibraryModal.d.ts +15 -3
  24. package/dist/components/shell/MediaLibraryModal.d.ts.map +1 -1
  25. package/dist/components/shell/MoveToFolderDialog.d.ts +16 -0
  26. package/dist/components/shell/MoveToFolderDialog.d.ts.map +1 -0
  27. package/dist/hooks/useEditorPublish.d.ts.map +1 -1
  28. package/dist/hooks/useMediaFolderDnd.d.ts +27 -0
  29. package/dist/hooks/useMediaFolderDnd.d.ts.map +1 -0
  30. package/dist/hooks/useMediaPipeline.d.ts +14 -2
  31. package/dist/hooks/useMediaPipeline.d.ts.map +1 -1
  32. package/dist/index.js +8 -4
  33. package/dist/lib/index.js +2 -2
  34. package/dist/lib/media-dnd.d.ts +26 -0
  35. package/dist/lib/media-dnd.d.ts.map +1 -0
  36. package/dist/lib/media-folders.d.ts +59 -0
  37. package/dist/lib/media-folders.d.ts.map +1 -0
  38. package/dist/media/folder-drop.d.ts +22 -0
  39. package/dist/media/folder-drop.d.ts.map +1 -0
  40. package/dist/media/index.js +6 -2
  41. package/dist/media/types.d.ts +1 -1
  42. package/dist/media/types.d.ts.map +1 -1
  43. package/dist/media/utils.d.ts +7 -0
  44. package/dist/media/utils.d.ts.map +1 -1
  45. package/dist/schemas/index.js +4 -2
  46. package/dist/schemas/media.d.ts +11 -0
  47. package/dist/schemas/media.d.ts.map +1 -1
  48. package/package.json +1 -1
  49. package/src/components/shell/EditorShell.tsx +20 -4
  50. package/src/components/shell/MediaBreadcrumb.tsx +77 -0
  51. package/src/components/shell/MediaFolderTile.tsx +141 -0
  52. package/src/components/shell/MediaFolderTree.tsx +127 -0
  53. package/src/components/shell/MediaLibraryModal.tsx +611 -229
  54. package/src/components/shell/MoveToFolderDialog.tsx +78 -0
  55. package/src/hooks/useEditorPublish.ts +14 -11
  56. package/src/hooks/useMediaFolderDnd.ts +129 -0
  57. package/src/hooks/useMediaPipeline.ts +162 -6
  58. package/src/lib/media-dnd.ts +62 -0
  59. package/src/lib/media-folders.ts +232 -0
  60. package/src/media/folder-drop.ts +62 -0
  61. package/src/media/types.ts +2 -0
  62. package/src/media/utils.ts +10 -0
  63. package/src/schemas/media.ts +13 -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 }: { onUpload: (files: File[]) => void }) {
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
- <button
59
- type="button"
81
+ <div
82
+ role="button"
83
+ tabIndex={0}
60
84
  onClick={() => inputRef.current?.click()}
61
- onDragOver={(e) => {
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
- handleFiles(e.dataTransfer.files);
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
- Drag &amp; drop files or click to browse
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
- onChange={(e) => {
89
- handleFiles(e.target.files);
90
- e.target.value = "";
91
- }}
140
+ onClick={(e) => e.stopPropagation()}
141
+ onChange={(e) => { handleFiles(e.target.files); e.target.value = ""; }}
92
142
  />
93
- </button>
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.originalName
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
- return matchesSearch && matchesKind;
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 flex-col gap-4">
182
- <UploadZone onUpload={handleUpload} />
183
-
184
- {/* Direct-to-bucket large-video upload progress */}
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
- {/* Sticky toolbar + confirmation */}
206
- <div className="sticky top-[-16px] z-10 -mx-6 space-y-3 bg-base px-6 pb-3 pt-4">
207
- <div className="flex items-center gap-2">
208
- <div className="relative flex-1">
209
- <Search
210
- size={14}
211
- className="pointer-events-none absolute left-2.5 top-1/2 -translate-y-1/2 text-base-contrast-light"
212
- />
213
- <input
214
- type="text"
215
- placeholder="Search media..."
216
- value={search}
217
- onChange={(e) => setSearch(e.target.value)}
218
- 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"
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
- <Select
223
- value={kindFilter}
224
- onChange={(v) => setKindFilter(v as KindFilter)}
225
- options={[
226
- { value: "all", label: "All types" },
227
- { value: "image", label: "Images" },
228
- { value: "animated", label: "Animated" },
229
- { value: "video", label: "Video" },
230
- ]}
231
- selectClassName="py-1.5 pr-7 pl-2.5"
232
- />
233
-
234
- {mode === "manage" && selected.size > 0 && (
235
- <Button
236
- variant="destructive"
237
- size="sm"
238
- onClick={handleBatchDelete}
239
- className="flex items-center gap-1.5"
240
- >
241
- <Trash2 size={13} />
242
- Delete {selected.size}
243
- </Button>
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
- </div>
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
- {/* Delete confirmation */}
248
- {confirmDelete && (
249
- <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">
250
- <p className="mb-2 text-center text-sm text-red-800 lg:mb-0 lg:text-left dark:text-red-200">
251
- Some of these images are being used. Deleting these will remove them from these references.
252
- </p>
253
- <div className="flex shrink-0 items-center gap-2">
254
- <Button variant="secondary" size="sm" onClick={() => setConfirmDelete(false)}>
255
- Cancel
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
- <Button variant="destructive" size="sm" onClick={handleConfirmDelete}>
258
- Confirm Delete
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
- </div>
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
- {/* File size rejection alert */}
265
- {rejectedFiles.length > 0 && maxFileSize && (
266
- <UploadRejectionAlert
267
- message={
268
- rejectedFiles.length === 1
269
- ? `"${rejectedFiles[0]}" exceeds the ${formatFileSize(maxFileSize)} file size limit.`
270
- : `${rejectedFiles.length} files exceed the ${formatFileSize(maxFileSize)} file size limit.`
271
- }
272
- onDismiss={() => setRejectedFiles([])}
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 &ldquo;{folderDeleteInfo.name}&rdquo;? 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
- {/* Async upload failure (oversized/disallowed file, upload-intent error,
277
- direct-to-bucket PUT failure) — distinct from the client-side check above. */}
278
- {uploadError && (
279
- <UploadRejectionAlert
280
- message={uploadError}
281
- onDismiss={() => onDismissUploadError?.()}
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
- {/* Grid */}
287
- {filtered.length === 0 ? (
288
- <div className="flex flex-col items-center justify-center gap-2 py-12 text-center">
289
- <p className="text-sm font-medium text-base-contrast">
290
- {items.length === 0 ? "No media uploaded yet" : "No results found"}
291
- </p>
292
- <p className="text-xs text-base-contrast-light">
293
- {items.length === 0
294
- ? "Upload files using the drop zone above."
295
- : "Try a different search or filter."}
296
- </p>
297
- </div>
298
- ) : (
299
- <div className="grid grid-cols-2 gap-3 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5">
300
- {filtered.map((item) => {
301
- const isSelected = selected.has(item.id);
302
- const refCount = referenceCountMap[item.id] ?? 0;
303
- const usageLabel = refCount === 0 ? "Not used" : `Used ${refCount}x`;
304
-
305
- return (
306
- <div
307
- key={item.id}
308
- className="group flex flex-col gap-1.5"
309
- onClick={() => {
310
- if (mode === "select") {
311
- onSelect(item.id);
312
- } else {
313
- toggleSelect(item.id);
314
- }
315
- }}
316
- >
317
- {/* Thumbnail */}
318
- <div
319
- className={cn(
320
- "relative aspect-square cursor-pointer overflow-hidden rounded-lg border-2 transition-colors",
321
- mode === "select"
322
- ? "border-transparent hover:border-primary"
323
- : isSelected
324
- ? "border-primary"
325
- : "border-transparent hover:border-base-300",
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
- <div className="h-full w-full bg-base-accent">
329
- {item.kind === "video" ? (
330
- <video
331
- src={thumbnailSrc(item, localUrlMap)}
332
- muted
333
- playsInline
334
- className="h-full w-full object-cover"
335
- onMouseEnter={(e) => e.currentTarget.play().catch(() => {})}
336
- onMouseLeave={(e) => { e.currentTarget.pause(); e.currentTarget.currentTime = 0; }}
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
- </div>
346
-
347
- {/* Selection radio — top-left */}
348
- {mode === "manage" && (
349
- <div className={cn(
350
- "absolute top-1.5 left-1.5 flex h-5 w-5 items-center justify-center rounded-full border-2 transition-colors",
351
- isSelected
352
- ? "border-primary bg-primary"
353
- : "border-white/80 bg-white/40 opacity-0 group-hover:opacity-100",
354
- )}>
355
- {isSelected && (
356
- <div className="h-2 w-2 rounded-full bg-white" />
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
- {/* Filename overlay — bottom, visible on hover */}
377
- <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">
378
- <p className="truncate text-[11px] text-white">
379
- {displayFilename(item.originalName, item.mimeType)}
380
- </p>
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
- {/* Alt text below image */}
385
- <div className="px-0.5">
386
- <label className="text-[10px] font-medium uppercase leading-none tracking-wide text-base-contrast-light/50">
387
- Alt text
388
- </label>
389
- <textarea
390
- value={item.alt}
391
- placeholder="Describe this image for accessibility"
392
- rows={2}
393
- onClick={(e) => e.stopPropagation()}
394
- onChange={(e) => onAltChange?.(item.id, e.target.value)}
395
- 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"
396
- />
397
- </div>
398
- </div>
399
- );
400
- })}
401
- </div>
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
  }