@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
@@ -0,0 +1,78 @@
1
+ import { useState } from "react";
2
+ import { Folder, LayoutGrid } from "lucide-react";
3
+ import { cn } from "../../lib/cn";
4
+ import { Button } from "../shared/Button";
5
+ import type { MediaFolder } from "../../media/types";
6
+ import { buildFolderTree, type FolderTreeNode } from "../../lib/media-folders";
7
+
8
+ export interface MoveToFolderDialogProps {
9
+ folders: Record<string, MediaFolder>;
10
+ /** Targets to disable (moving a folder: its own descendant set). */
11
+ disabledIds?: Set<string>;
12
+ /**
13
+ * Target that is a no-op (the items' current folder), disabled too.
14
+ * Omit (undefined) when there is no single no-op target — e.g. a
15
+ * heterogeneous selection — and nothing is disabled on that account.
16
+ */
17
+ currentTarget?: string | null;
18
+ onMove: (targetFolderId: string | null) => void;
19
+ onClose: () => void;
20
+ }
21
+
22
+ /** Sentinel for "no pick yet" — distinct from picking root (null). */
23
+ const UNPICKED = Symbol("unpicked");
24
+
25
+ export function MoveToFolderDialog({
26
+ folders, disabledIds, currentTarget, onMove, onClose,
27
+ }: MoveToFolderDialogProps) {
28
+ const [picked, setPicked] = useState<string | null | typeof UNPICKED>(UNPICKED);
29
+ const tree = buildFolderTree(folders);
30
+
31
+ const row = (target: string | null, label: string, depth: number, icon: React.ReactNode) => {
32
+ const disabled = target === currentTarget || (target !== null && disabledIds?.has(target) === true);
33
+ const active = picked !== UNPICKED && picked === target;
34
+ return (
35
+ <button
36
+ key={target ?? "__root__"}
37
+ type="button"
38
+ disabled={disabled}
39
+ onClick={() => setPicked(target)}
40
+ className={cn(
41
+ "flex w-full items-center gap-1.5 rounded px-2 py-1.5 text-left text-sm",
42
+ disabled ? "cursor-not-allowed text-base-contrast-light/50" : "cursor-pointer hover:bg-base-accent",
43
+ active && "bg-primary/10 font-medium text-primary",
44
+ )}
45
+ style={{ paddingLeft: `${8 + depth * 14}px` }}
46
+ >
47
+ {icon}
48
+ <span className="min-w-0 flex-1 truncate">{label}</span>
49
+ </button>
50
+ );
51
+ };
52
+
53
+ const renderNodes = (nodes: FolderTreeNode[], depth: number): React.ReactNode[] =>
54
+ nodes.flatMap((node) => [
55
+ row(node.id, node.name, depth, <Folder size={14} className="shrink-0 text-base-contrast-light" />),
56
+ ...renderNodes(node.children, depth + 1),
57
+ ]);
58
+
59
+ return (
60
+ <div className="flex flex-col gap-3">
61
+ <div className="max-h-64 overflow-y-auto rounded-lg border border-base-200 p-1.5">
62
+ {row(null, "All media", 0, <LayoutGrid size={14} className="shrink-0 text-base-contrast-light" />)}
63
+ {renderNodes(tree, 0)}
64
+ </div>
65
+ <div className="flex justify-end gap-2">
66
+ <Button variant="secondary" size="sm" onClick={onClose}>Cancel</Button>
67
+ <Button
68
+ variant="primary"
69
+ size="sm"
70
+ disabled={picked === UNPICKED}
71
+ onClick={() => { if (picked !== UNPICKED) { onMove(picked); onClose(); } }}
72
+ >
73
+ Move
74
+ </Button>
75
+ </div>
76
+ </div>
77
+ );
78
+ }
@@ -5,6 +5,7 @@ import type { LoadedSection } from "../lib/loader";
5
5
  import type { MediaManifest, MediaItem } from "../media/types";
6
6
  import { getDirtySectionRows, hasLocalChanges, discardSavedChanges, cacheContent, clearContentCache, getCachedContent, getPendingMediaBlobs, clearPendingMediaByIds } from "../lib/dexie";
7
7
  import { packUploads, type MediaUploadPayload } from "../lib/save-chunks";
8
+ import { assembleSavedManifest } from "../lib/media-folders";
8
9
  import { buildCommittedBaseline } from "../collab/recovery";
9
10
 
10
11
  function blobToBase64(blob: Blob): Promise<string> {
@@ -198,19 +199,18 @@ export function useEditorPublish({
198
199
 
199
200
  let updatedManifest: MediaManifest | undefined;
200
201
  if (hasMediaChanges) {
201
- // KNOWN LIMITATION (deferred): the manifest is rebuilt from THIS editor's
202
- // view of manifest.images, so concurrent media changes by another editor
202
+ // Preserves folders (and any future manifest keys) and heals dangling
203
+ // folder refs — never rebuild the manifest as a bare { images }.
204
+ // KNOWN LIMITATION (deferred): the assembly starts from THIS editor's
205
+ // view of the manifest, so concurrent media changes by another editor
203
206
  // are last-writer-wins — a manifest entry they added since our load is
204
207
  // dropped from the write (their files remain; the entry orphans). Fixing
205
208
  // this needs a merge against the remote manifest at save time.
206
- const images = { ...manifest.images };
207
- for (const upload of mediaUploads) {
208
- images[upload.item.id] = upload.item;
209
- }
210
- for (const id of pendingDeletions) {
211
- delete images[id];
212
- }
213
- updatedManifest = { images };
209
+ updatedManifest = assembleSavedManifest(
210
+ manifest,
211
+ mediaUploads.map((u) => u.item),
212
+ pendingDeletions,
213
+ );
214
214
  }
215
215
 
216
216
  return { mediaUploads, updatedManifest, hasMediaChanges };
@@ -286,7 +286,10 @@ export function useEditorPublish({
286
286
  for (const chunk of leadChunks) {
287
287
  setSaveProgress(`Saving images ${sent + chunk.length}/${totalUploads}…`);
288
288
  for (const upload of chunk) committedImages[upload.item.id] = upload.item;
289
- const manifest: MediaManifest = { images: { ...args.mediaManifest.images, ...committedImages } };
289
+ const manifest: MediaManifest = {
290
+ images: { ...args.mediaManifest.images, ...committedImages },
291
+ folders: args.mediaManifest.folders,
292
+ };
290
293
  const data = await postSave({
291
294
  baseVersion,
292
295
  media: { uploads: chunk, deletions: [], manifest },
@@ -0,0 +1,129 @@
1
+ import { useEffect, useRef, useState } from "react";
2
+ import type { MediaFolder, MediaItem } from "../media/types";
3
+ import {
4
+ buildFolderDropData, canDropFolderInto, canDropMediaInto,
5
+ isFolderDragData, isMediaDragData,
6
+ type MediaDragData, type MediaFolderDragData,
7
+ } from "../lib/media-dnd";
8
+
9
+ /** Delay before a hovered collapsed tree node auto-expands during a drag. */
10
+ const HOVER_EXPAND_MS = 600;
11
+
12
+ interface UseFolderDropTargetArgs {
13
+ targetFolderId: string | null;
14
+ enabled: boolean;
15
+ /** Refs (not values) so drops read fresh state without re-registering targets. */
16
+ foldersRef: React.RefObject<Record<string, MediaFolder>>;
17
+ itemsByIdRef: React.RefObject<Record<string, MediaItem>>;
18
+ onMoveMedia: (ids: string[], target: string | null) => void;
19
+ onMoveFolder: (id: string, target: string | null) => void;
20
+ /** Fired after a sustained drag hover (tree hover-to-expand). */
21
+ onDragHover?: () => void;
22
+ }
23
+
24
+ export function useFolderDropTarget<E extends HTMLElement>({
25
+ targetFolderId, enabled, foldersRef, itemsByIdRef, onMoveMedia, onMoveFolder, onDragHover,
26
+ }: UseFolderDropTargetArgs) {
27
+ const ref = useRef<E>(null);
28
+ const [isDropTarget, setIsDropTarget] = useState(false);
29
+ const callbacksRef = useRef({ onMoveMedia, onMoveFolder, onDragHover });
30
+ callbacksRef.current = { onMoveMedia, onMoveFolder, onDragHover };
31
+
32
+ useEffect(() => {
33
+ if (!enabled) return;
34
+ const element = ref.current;
35
+ if (!element) return;
36
+
37
+ let cleanup: (() => void) | undefined;
38
+ let cancelled = false;
39
+ let hoverTimer: ReturnType<typeof setTimeout> | undefined;
40
+ const clearHover = () => {
41
+ if (hoverTimer !== undefined) clearTimeout(hoverTimer);
42
+ hoverTimer = undefined;
43
+ };
44
+
45
+ import("@atlaskit/pragmatic-drag-and-drop/element/adapter").then(({ dropTargetForElements }) => {
46
+ if (cancelled) return;
47
+ cleanup = dropTargetForElements({
48
+ element,
49
+ getData: () => buildFolderDropData(targetFolderId),
50
+ canDrop: ({ source }) => {
51
+ const folders = foldersRef.current ?? {};
52
+ if (isMediaDragData(source.data)) {
53
+ return canDropMediaInto(source.data, targetFolderId, folders, itemsByIdRef.current ?? {});
54
+ }
55
+ if (isFolderDragData(source.data)) {
56
+ return canDropFolderInto(source.data, targetFolderId, folders);
57
+ }
58
+ return false;
59
+ },
60
+ onDragEnter: () => {
61
+ setIsDropTarget(true);
62
+ if (callbacksRef.current.onDragHover) {
63
+ clearHover();
64
+ hoverTimer = setTimeout(() => callbacksRef.current.onDragHover?.(), HOVER_EXPAND_MS);
65
+ }
66
+ },
67
+ onDragLeave: () => {
68
+ setIsDropTarget(false);
69
+ clearHover();
70
+ },
71
+ onDrop: ({ source }) => {
72
+ setIsDropTarget(false);
73
+ clearHover();
74
+ if (isMediaDragData(source.data)) {
75
+ callbacksRef.current.onMoveMedia(source.data.mediaIds, targetFolderId);
76
+ } else if (isFolderDragData(source.data)) {
77
+ callbacksRef.current.onMoveFolder(source.data.folderId, targetFolderId);
78
+ }
79
+ },
80
+ });
81
+ });
82
+
83
+ return () => {
84
+ cancelled = true;
85
+ clearHover();
86
+ cleanup?.();
87
+ };
88
+ }, [enabled, targetFolderId, foldersRef, itemsByIdRef]);
89
+
90
+ return { ref, isDropTarget };
91
+ }
92
+
93
+ interface UseMediaDraggableArgs {
94
+ enabled: boolean;
95
+ getDragData: () => MediaDragData | MediaFolderDragData;
96
+ }
97
+
98
+ export function useMediaDraggable<E extends HTMLElement>({ enabled, getDragData }: UseMediaDraggableArgs) {
99
+ const ref = useRef<E>(null);
100
+ const [isDragging, setIsDragging] = useState(false);
101
+ const getDragDataRef = useRef(getDragData);
102
+ getDragDataRef.current = getDragData;
103
+
104
+ useEffect(() => {
105
+ if (!enabled) return;
106
+ const element = ref.current;
107
+ if (!element) return;
108
+
109
+ let cleanup: (() => void) | undefined;
110
+ let cancelled = false;
111
+
112
+ import("@atlaskit/pragmatic-drag-and-drop/element/adapter").then(({ draggable }) => {
113
+ if (cancelled) return;
114
+ cleanup = draggable({
115
+ element,
116
+ getInitialData: () => getDragDataRef.current(),
117
+ onDragStart: () => setIsDragging(true),
118
+ onDrop: () => setIsDragging(false),
119
+ });
120
+ });
121
+
122
+ return () => {
123
+ cancelled = true;
124
+ cleanup?.();
125
+ };
126
+ }, [enabled]);
127
+
128
+ return { ref, isDragging };
129
+ }
@@ -14,6 +14,11 @@ import {
14
14
  } from "../lib/dexie";
15
15
  import { generateVideoPoster } from "../media/videoPoster";
16
16
  import { tallyImageIds, collectImageIds, clearImageIds } from "../lib/image-refs";
17
+ import {
18
+ createFolderId, uniqueFolderName, effectiveParentId, canMoveFolder,
19
+ descendantFolderIds, itemFolderId, planFolderUpload,
20
+ } from "../lib/media-folders";
21
+ import type { MediaFolder } from "../media/types";
17
22
 
18
23
  interface UseMediaPipelineInput {
19
24
  siteConfig: SiteConfig | null;
@@ -28,6 +33,9 @@ interface UseMediaPipelineInput {
28
33
  // upload-intent errors, direct-to-bucket PUT failures) to the caller, which
29
34
  // decides how to display them (e.g. the media modal's rejection banner).
30
35
  onUploadError?: (message: string) => void;
36
+ // Surfaces non-error, informational notices (e.g. "N files already exist
37
+ // and were left where they are" on dedup skip) to the caller.
38
+ onUploadNotice?: (message: string) => void;
31
39
  }
32
40
 
33
41
  export function useMediaPipeline({
@@ -40,6 +48,7 @@ export function useMediaPipeline({
40
48
  setDirtySectionIds,
41
49
  markSectionDirty,
42
50
  onUploadError,
51
+ onUploadNotice,
43
52
  }: UseMediaPipelineInput) {
44
53
  const [processingItems, setProcessingItems] = useState<QueueItem[]>([]);
45
54
  const [pendingMediaItems, setPendingMediaItems] = useState<LibraryMediaItem[]>([]);
@@ -59,6 +68,9 @@ export function useMediaPipeline({
59
68
 
60
69
  const queueRef = useRef<ProcessingQueue | null>(null);
61
70
  const uploadCallbacksRef = useRef<Map<string, (imageId: string) => void>>(new Map());
71
+ // Display-folder target per in-flight upload, keyed by content hash. Set at
72
+ // enqueue time, consumed when the processing queue finalizes the item.
73
+ const uploadTargetsRef = useRef<Map<string, string | null>>(new Map());
62
74
  const destroyedRef = useRef(false);
63
75
 
64
76
  // Shared persistence path for every pending media item — the processing
@@ -135,6 +147,7 @@ export function useMediaPipeline({
135
147
  height: number,
136
148
  ) => {
137
149
  if (destroyedRef.current) return;
150
+ const targetFolderId = uploadTargetsRef.current.get(event.item.hash) ?? null;
138
151
  const item: LibraryMediaItem = {
139
152
  id: event.item.hash,
140
153
  hash: event.item.hash,
@@ -152,9 +165,11 @@ export function useMediaPipeline({
152
165
  })),
153
166
  alt: "",
154
167
  storage: "github",
168
+ ...(targetFolderId ? { folderId: targetFolderId } : {}),
155
169
  };
156
170
 
157
171
  const persisted = await finalizePendingMedia(item, localUrls, blobsMap);
172
+ uploadTargetsRef.current.delete(event.item.hash);
158
173
  if (!persisted) return;
159
174
 
160
175
  const callback = uploadCallbacksRef.current.get(event.item.hash);
@@ -201,6 +216,7 @@ export function useMediaPipeline({
201
216
  destroyedRef.current = true;
202
217
  queue.destroy();
203
218
  uploadCallbacksRef.current = new Map();
219
+ uploadTargetsRef.current = new Map();
204
220
  };
205
221
  // eslint-disable-next-line react-hooks/exhaustive-deps -- rebuild only when processing params change, not on every siteConfig reference change
206
222
  }, [mediaConfigKey, setLocalChangesExist]);
@@ -220,7 +236,7 @@ export function useMediaPipeline({
220
236
  // anything upstream of those stages (e.g. hashing), and the single outer
221
237
  // `finally` is the one place progress gets cleared, regardless of which
222
238
  // stage — or none — ran.
223
- const uploadLargeVideo = useCallback(async (file: File, mediaConfig: SiteConfig["media"]) => {
239
+ const uploadLargeVideo = useCallback(async (file: File, mediaConfig: SiteConfig["media"], targetFolderId: string | null) => {
224
240
  let hash: string | undefined;
225
241
  try {
226
242
  const buffer = await file.arrayBuffer();
@@ -295,6 +311,7 @@ export function useMediaPipeline({
295
311
  // which ImageManifestSchema's z.number().positive().optional()
296
312
  // rejects on the next save — omit the field entirely instead.
297
313
  ...(Number.isFinite(duration) && duration > 0 ? { duration } : {}),
314
+ ...(targetFolderId ? { folderId: targetFolderId } : {}),
298
315
  };
299
316
  // Only the poster blob is persisted — the original lives in the bucket, so
300
317
  // save/publish never ships large base64 through the Netlify function.
@@ -322,12 +339,13 @@ export function useMediaPipeline({
322
339
  }
323
340
  }, [mediaManifest, pendingMediaItems, pendingDeletions, onUploadError, finalizePendingMedia]);
324
341
 
325
- const handleMediaUpload = useCallback(async (files: File[]) => {
342
+ const handleMediaUpload = useCallback(async (files: File[], targetFolderId: string | null = null) => {
326
343
  if (!siteConfig) return;
327
344
  const mediaConfig = siteConfig.media;
328
345
  const queue = queueRef.current;
329
346
  if (!queue) return;
330
347
 
348
+ let alreadyExisting = 0;
331
349
  for (const file of files) {
332
350
  if (file.size > mediaConfig.maxFileSize) {
333
351
  if (file.type === "video/mp4" || file.type === "video/webm") {
@@ -336,7 +354,7 @@ export function useMediaPipeline({
336
354
  // this catch is defense-in-depth only, so it must NOT call
337
355
  // onUploadError again — doing so would double-report.
338
356
  try {
339
- await uploadLargeVideo(file, mediaConfig);
357
+ await uploadLargeVideo(file, mediaConfig, targetFolderId);
340
358
  } catch (err) {
341
359
  console.error(`[useMediaPipeline] uploadLargeVideo threw unexpectedly for "${file.name}":`, err);
342
360
  }
@@ -356,7 +374,12 @@ export function useMediaPipeline({
356
374
  const isDeleted = pendingDeletions.includes(hash);
357
375
  const existsInManifest = hash in mediaManifest.images;
358
376
  const existsInPending = pendingMediaItems.some((i) => i.hash === hash);
359
- if ((existsInManifest || existsInPending) && !isDeleted) continue;
377
+ if ((existsInManifest || existsInPending) && !isDeleted) {
378
+ alreadyExisting++;
379
+ continue;
380
+ }
381
+
382
+ uploadTargetsRef.current.set(hash, targetFolderId);
360
383
 
361
384
  let kind: "image" | "animated" | "video" = "image";
362
385
  if (file.type === "image/gif" || file.type === "image/apng") kind = "animated";
@@ -364,7 +387,14 @@ export function useMediaPipeline({
364
387
 
365
388
  queue.add({ buffer, originalName: file.name, mimeType: file.type, hash, kind });
366
389
  }
367
- }, [siteConfig, mediaManifest, pendingMediaItems, pendingDeletions, onUploadError, uploadLargeVideo]);
390
+ if (alreadyExisting > 0) {
391
+ onUploadNotice?.(
392
+ alreadyExisting === 1
393
+ ? "1 file already exists in the library and was left where it is."
394
+ : `${alreadyExisting} files already exist in the library and were left where they are.`,
395
+ );
396
+ }
397
+ }, [siteConfig, mediaManifest, pendingMediaItems, pendingDeletions, onUploadError, onUploadNotice, uploadLargeVideo]);
368
398
 
369
399
  const handleMediaDelete = useCallback(async (ids: string[]) => {
370
400
  const idSet = new Set(ids);
@@ -417,7 +447,7 @@ export function useMediaPipeline({
417
447
  setMediaManifest((prev) => {
418
448
  const item = prev.images[id];
419
449
  if (!item) return prev;
420
- return { images: { ...prev.images, [id]: { ...item, alt } } };
450
+ return { ...prev, images: { ...prev.images, [id]: { ...item, alt } } };
421
451
  });
422
452
  setPendingMediaItems((prev) =>
423
453
  prev.map((item) => (item.id === id ? { ...item, alt } : item)),
@@ -428,6 +458,126 @@ export function useMediaPipeline({
428
458
  setLocalChangesExist(true);
429
459
  }, [setMediaManifest, setLocalChangesExist]);
430
460
 
461
+ // --- Display-folder operations (pure manifest metadata; ride Save like alt text) ---
462
+
463
+ const handleFolderCreate = useCallback((name: string, parentId: string | null): string => {
464
+ const id = createFolderId();
465
+ setMediaManifest((prev) => ({
466
+ ...prev,
467
+ folders: { ...prev.folders, [id]: { name: uniqueFolderName(prev.folders, parentId, name), parentId } },
468
+ }));
469
+ setManifestDirty(true);
470
+ setLocalChangesExist(true);
471
+ return id;
472
+ }, [setMediaManifest, setLocalChangesExist]);
473
+
474
+ const handleFolderRename = useCallback((id: string, name: string) => {
475
+ setMediaManifest((prev) => {
476
+ const folder = prev.folders[id];
477
+ if (!folder) return prev;
478
+ const unique = uniqueFolderName(prev.folders, effectiveParentId(prev.folders, id), name, id);
479
+ return { ...prev, folders: { ...prev.folders, [id]: { ...folder, name: unique } } };
480
+ });
481
+ setManifestDirty(true);
482
+ setLocalChangesExist(true);
483
+ }, [setMediaManifest, setLocalChangesExist]);
484
+
485
+ const handleFolderMove = useCallback((id: string, parentId: string | null) => {
486
+ setMediaManifest((prev) => {
487
+ if (!canMoveFolder(prev.folders, id, parentId)) return prev;
488
+ return { ...prev, folders: { ...prev.folders, [id]: { ...prev.folders[id], parentId } } };
489
+ });
490
+ setManifestDirty(true);
491
+ setLocalChangesExist(true);
492
+ }, [setMediaManifest, setLocalChangesExist]);
493
+
494
+ const stripFolderId = (entry: LibraryMediaItem): LibraryMediaItem => {
495
+ const { folderId: _dropped, ...rest } = entry;
496
+ return rest;
497
+ };
498
+
499
+ const handleMediaMove = useCallback((ids: string[], folderId: string | null) => {
500
+ const idSet = new Set(ids);
501
+ const retarget = (entry: LibraryMediaItem): LibraryMediaItem =>
502
+ folderId === null ? stripFolderId(entry) : { ...entry, folderId };
503
+ setMediaManifest((prev) => {
504
+ const images = { ...prev.images };
505
+ for (const id of ids) if (images[id]) images[id] = retarget(images[id]);
506
+ return { ...prev, images };
507
+ });
508
+ setPendingMediaItems((prev) => prev.map((entry) => (idSet.has(entry.id) ? retarget(entry) : entry)));
509
+ setManifestDirty(true);
510
+ setLocalChangesExist(true);
511
+ }, [setMediaManifest, setLocalChangesExist]);
512
+
513
+ const handleFolderDelete = useCallback(async (id: string, contents: "move" | "delete") => {
514
+ if (contents === "delete") {
515
+ const doomed = descendantFolderIds(mediaManifest.folders, id);
516
+ const inDoomed = (entry: LibraryMediaItem) => {
517
+ const fid = itemFolderId(mediaManifest.folders, entry);
518
+ return fid !== null && doomed.has(fid);
519
+ };
520
+ const mediaIds = [
521
+ ...Object.values(mediaManifest.images).filter(inDoomed),
522
+ ...pendingMediaItems.filter(inDoomed),
523
+ ].map((entry) => entry.id);
524
+ if (mediaIds.length > 0) await handleMediaDelete([...new Set(mediaIds)]);
525
+ setMediaManifest((prev) => {
526
+ const folders = { ...prev.folders };
527
+ for (const fid of descendantFolderIds(prev.folders, id)) delete folders[fid];
528
+ return { ...prev, folders };
529
+ });
530
+ } else {
531
+ setMediaManifest((prev) => {
532
+ if (!prev.folders[id]) return prev;
533
+ const parentId = effectiveParentId(prev.folders, id);
534
+ const folders: Record<string, MediaFolder> = {};
535
+ for (const [fid, f] of Object.entries(prev.folders)) {
536
+ if (fid === id) continue;
537
+ folders[fid] = effectiveParentId(prev.folders, fid) === id ? { ...f, parentId } : f;
538
+ }
539
+ const images = { ...prev.images };
540
+ for (const [iid, entry] of Object.entries(images)) {
541
+ if (itemFolderId(prev.folders, entry) !== id) continue;
542
+ images[iid] = parentId === null ? stripFolderId(entry) : { ...entry, folderId: parentId };
543
+ }
544
+ return { ...prev, images, folders };
545
+ });
546
+ setPendingMediaItems((prev) =>
547
+ prev.map((entry) => {
548
+ if (entry.folderId !== id) return entry;
549
+ const parentId = effectiveParentId(mediaManifest.folders, id);
550
+ return parentId === null ? stripFolderId(entry) : { ...entry, folderId: parentId };
551
+ }),
552
+ );
553
+ }
554
+ setManifestDirty(true);
555
+ setLocalChangesExist(true);
556
+ }, [mediaManifest, pendingMediaItems, handleMediaDelete, setMediaManifest, setLocalChangesExist]);
557
+
558
+ const handleFolderUpload = useCallback(async (
559
+ entries: { file: File; relativePath: string }[],
560
+ currentFolderId: string | null,
561
+ ) => {
562
+ const plan = planFolderUpload(entries, mediaManifest.folders, currentFolderId);
563
+ if (Object.keys(plan.newFolders).length > 0) {
564
+ setMediaManifest((prev) => ({ ...prev, folders: { ...prev.folders, ...plan.newFolders } }));
565
+ setManifestDirty(true);
566
+ setLocalChangesExist(true);
567
+ }
568
+ // Group per target folder and reuse the standard upload path — dedup, size
569
+ // gates, and large-video tiering all apply per file.
570
+ const groups = new Map<string | null, File[]>();
571
+ for (const { file, folderId } of plan.fileTargets) {
572
+ const list = groups.get(folderId);
573
+ if (list) list.push(file);
574
+ else groups.set(folderId, [file]);
575
+ }
576
+ for (const [folderId, groupFiles] of groups) {
577
+ await handleMediaUpload(groupFiles, folderId);
578
+ }
579
+ }, [mediaManifest, handleMediaUpload, setMediaManifest, setLocalChangesExist]);
580
+
431
581
  // Count imageId references with a single structural pass per section (O(n)),
432
582
  // instead of stringifying every section and regex-scanning it per known id.
433
583
  // Counts every referenced imageId; the consumer (MediaLibraryModal) only looks
@@ -514,6 +664,12 @@ export function useMediaPipeline({
514
664
  handleMediaUpload,
515
665
  handleMediaDelete,
516
666
  handleMediaAltChange,
667
+ handleFolderCreate,
668
+ handleFolderRename,
669
+ handleFolderMove,
670
+ handleFolderDelete,
671
+ handleMediaMove,
672
+ handleFolderUpload,
517
673
  referenceCountMap,
518
674
  contextValue,
519
675
  enqueueFile,
@@ -0,0 +1,62 @@
1
+ import type { MediaFolder, MediaItem } from "../media/types";
2
+ import { canMoveFolder, itemFolderId } from "./media-folders";
3
+
4
+ export interface MediaDragData {
5
+ dragType: "media-item";
6
+ mediaIds: string[];
7
+ [key: string | symbol]: unknown;
8
+ }
9
+
10
+ export interface MediaFolderDragData {
11
+ dragType: "media-folder";
12
+ folderId: string;
13
+ [key: string | symbol]: unknown;
14
+ }
15
+
16
+ export interface MediaFolderDropData {
17
+ dropType: "media-folder";
18
+ targetFolderId: string | null;
19
+ [key: string | symbol]: unknown;
20
+ }
21
+
22
+ export function buildMediaDragData(mediaIds: string[]): MediaDragData {
23
+ return { dragType: "media-item", mediaIds };
24
+ }
25
+
26
+ export function buildFolderDragData(folderId: string): MediaFolderDragData {
27
+ return { dragType: "media-folder", folderId };
28
+ }
29
+
30
+ export function buildFolderDropData(targetFolderId: string | null): MediaFolderDropData {
31
+ return { dropType: "media-folder", targetFolderId };
32
+ }
33
+
34
+ export function isMediaDragData(d: Record<string | symbol, unknown>): d is MediaDragData {
35
+ return d.dragType === "media-item";
36
+ }
37
+
38
+ export function isFolderDragData(d: Record<string | symbol, unknown>): d is MediaFolderDragData {
39
+ return d.dragType === "media-folder";
40
+ }
41
+
42
+ /** True when at least one dragged item would actually change folder. */
43
+ export function canDropMediaInto(
44
+ drag: MediaDragData,
45
+ targetFolderId: string | null,
46
+ folders: Record<string, MediaFolder>,
47
+ itemsById: Record<string, MediaItem>,
48
+ ): boolean {
49
+ return drag.mediaIds.some((id) => {
50
+ const entry = itemsById[id];
51
+ return entry !== undefined && itemFolderId(folders, entry) !== targetFolderId;
52
+ });
53
+ }
54
+
55
+ /** Folder drops delegate to the tree-integrity guard (self/descendant/no-op). */
56
+ export function canDropFolderInto(
57
+ drag: MediaFolderDragData,
58
+ targetFolderId: string | null,
59
+ folders: Record<string, MediaFolder>,
60
+ ): boolean {
61
+ return canMoveFolder(folders, drag.folderId, targetFolderId);
62
+ }