@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.
- package/dist/{chunk-B2DLHZKC.js → chunk-2DDWWTRX.js} +1 -1
- package/dist/{chunk-S55OZ7F2.js → chunk-3LNKOLZP.js} +4 -0
- package/dist/{chunk-SWIHUIFT.js → chunk-4SIWBWKZ.js} +12 -2
- package/dist/{chunk-RO4E5UJI.js → chunk-BAQYZSFF.js} +6 -6
- package/dist/{chunk-IFHRHV7S.js → chunk-EMGLZQCM.js} +1 -1
- package/dist/{chunk-YP7IHMC3.js → chunk-K4JD6FD3.js} +1 -1
- package/dist/{chunk-SFKYUKVN.js → chunk-MK4POMLZ.js} +1 -1
- package/dist/{chunk-63BQQNZO.js → chunk-NS3UTJ3E.js} +3 -3
- package/dist/{chunk-UUCOJL7O.js → chunk-UA6OCJJK.js} +2 -2
- package/dist/{chunk-XFDEUK37.js → chunk-Z5IYWUW3.js} +1 -1
- package/dist/components/editor/index.js +4 -4
- package/dist/components/primitives/index.js +5 -5
- package/dist/components/sections/brand-guide.js +5 -5
- package/dist/components/sections/register-schemas.js +8 -8
- package/dist/components/sections/register.js +8 -8
- 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/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.js +8 -4
- package/dist/lib/index.js +2 -2
- 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/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 +4 -2
- package/dist/schemas/media.d.ts +11 -0
- package/dist/schemas/media.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/components/shell/EditorShell.tsx +20 -4
- 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/useEditorPublish.ts +14 -11
- package/src/hooks/useMediaFolderDnd.ts +129 -0
- package/src/hooks/useMediaPipeline.ts +162 -6
- package/src/lib/media-dnd.ts +62 -0
- package/src/lib/media-folders.ts +232 -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
|
@@ -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
|
-
//
|
|
202
|
-
//
|
|
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
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
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 = {
|
|
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)
|
|
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
|
-
|
|
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
|
+
}
|