@drawnagency/primitives 0.1.76 → 0.1.78
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/{chunk-Q3T23HVW.js → chunk-2DDWWTRX.js} +5 -5
- package/dist/chunk-2G2S36SY.js +13 -0
- package/dist/{chunk-S55OZ7F2.js → chunk-3LNKOLZP.js} +4 -0
- package/dist/{chunk-SWIHUIFT.js → chunk-4SIWBWKZ.js} +12 -2
- package/dist/{chunk-SWEVWC5K.js → chunk-BAQYZSFF.js} +22 -22
- package/dist/{chunk-IFHRHV7S.js → chunk-EMGLZQCM.js} +1 -1
- package/dist/{chunk-NKVZGPR6.js → chunk-K4JD6FD3.js} +1 -1
- package/dist/{chunk-SFKYUKVN.js → chunk-MK4POMLZ.js} +1 -1
- package/dist/{chunk-7T2WQSWF.js → chunk-NS3UTJ3E.js} +11 -11
- package/dist/chunk-REM2NH7K.js +44 -0
- package/dist/{chunk-T7MP7ANP.js → chunk-UA6OCJJK.js} +2 -2
- package/dist/{chunk-ZRWGV5O6.js → chunk-Z5IYWUW3.js} +3 -3
- package/dist/collab/provider-registry.d.ts +4 -0
- package/dist/collab/provider-registry.d.ts.map +1 -0
- package/dist/collab/provider-registry.js +9 -0
- package/dist/collab/rebase.d.ts +37 -0
- package/dist/collab/rebase.d.ts.map +1 -0
- package/dist/collab/rebase.js +64 -0
- package/dist/collab/recovery.d.ts +89 -0
- package/dist/collab/recovery.d.ts.map +1 -0
- package/dist/collab/types.d.ts +93 -0
- package/dist/collab/types.d.ts.map +1 -0
- package/dist/collab/types.js +19 -0
- package/dist/components/editor/AudienceIndicator.d.ts +3 -1
- package/dist/components/editor/AudienceIndicator.d.ts.map +1 -1
- package/dist/components/editor/ConflictDialog.d.ts +14 -0
- package/dist/components/editor/ConflictDialog.d.ts.map +1 -0
- package/dist/components/editor/IndicatorPill.d.ts +4 -1
- package/dist/components/editor/IndicatorPill.d.ts.map +1 -1
- package/dist/components/editor/LockBadge.d.ts +7 -0
- package/dist/components/editor/LockBadge.d.ts.map +1 -0
- package/dist/components/editor/PresenceAvatars.d.ts +9 -0
- package/dist/components/editor/PresenceAvatars.d.ts.map +1 -0
- package/dist/components/editor/SectionWrapper.d.ts +1 -1
- package/dist/components/editor/SectionWrapper.d.ts.map +1 -1
- package/dist/components/editor/StatusIndicator.d.ts +3 -1
- package/dist/components/editor/StatusIndicator.d.ts.map +1 -1
- package/dist/components/editor/index.js +95 -47
- package/dist/components/primitives/index.js +5 -5
- package/dist/components/sections/brand-guide.js +5 -5
- package/dist/components/sections/register-schemas.js +11 -11
- package/dist/components/sections/register.js +11 -11
- package/dist/components/shell/EditorShell.d.ts +3 -1
- package/dist/components/shell/EditorShell.d.ts.map +1 -1
- package/dist/components/shell/MediaBreadcrumb.d.ts +10 -0
- package/dist/components/shell/MediaBreadcrumb.d.ts.map +1 -0
- package/dist/components/shell/MediaFolderTile.d.ts +15 -0
- package/dist/components/shell/MediaFolderTile.d.ts.map +1 -0
- package/dist/components/shell/MediaFolderTree.d.ts +20 -0
- package/dist/components/shell/MediaFolderTree.d.ts.map +1 -0
- package/dist/components/shell/MediaLibraryModal.d.ts +15 -3
- package/dist/components/shell/MediaLibraryModal.d.ts.map +1 -1
- package/dist/components/shell/MoveToFolderDialog.d.ts +16 -0
- package/dist/components/shell/MoveToFolderDialog.d.ts.map +1 -0
- package/dist/hooks/useCollabSession.d.ts +23 -0
- package/dist/hooks/useCollabSession.d.ts.map +1 -0
- package/dist/hooks/useEditorPersistence.d.ts +21 -1
- package/dist/hooks/useEditorPersistence.d.ts.map +1 -1
- package/dist/hooks/useEditorPublish.d.ts +39 -2
- package/dist/hooks/useEditorPublish.d.ts.map +1 -1
- package/dist/hooks/useMediaFolderDnd.d.ts +27 -0
- package/dist/hooks/useMediaFolderDnd.d.ts.map +1 -0
- package/dist/hooks/useMediaPipeline.d.ts +14 -2
- package/dist/hooks/useMediaPipeline.d.ts.map +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +51 -25
- package/dist/lib/dexie.d.ts +12 -0
- package/dist/lib/dexie.d.ts.map +1 -1
- package/dist/lib/dexie.js +27 -0
- package/dist/lib/index.js +12 -12
- package/dist/lib/media-dnd.d.ts +26 -0
- package/dist/lib/media-dnd.d.ts.map +1 -0
- package/dist/lib/media-folders.d.ts +59 -0
- package/dist/lib/media-folders.d.ts.map +1 -0
- package/dist/lib/registry.d.ts +10 -0
- package/dist/lib/registry.d.ts.map +1 -1
- package/dist/media/folder-drop.d.ts +22 -0
- package/dist/media/folder-drop.d.ts.map +1 -0
- package/dist/media/index.js +6 -2
- package/dist/media/types.d.ts +1 -1
- package/dist/media/types.d.ts.map +1 -1
- package/dist/media/utils.d.ts +7 -0
- package/dist/media/utils.d.ts.map +1 -1
- package/dist/schemas/index.js +5 -3
- package/dist/schemas/media.d.ts +11 -0
- package/dist/schemas/media.d.ts.map +1 -1
- package/dist/types/database.d.ts +70 -0
- package/dist/types/database.d.ts.map +1 -1
- package/package.json +13 -1
- package/src/collab/provider-registry.ts +14 -0
- package/src/collab/rebase.ts +140 -0
- package/src/collab/recovery.ts +149 -0
- package/src/collab/types.ts +96 -0
- package/src/components/editor/AudienceIndicator.tsx +4 -1
- package/src/components/editor/ConflictDialog.tsx +61 -0
- package/src/components/editor/IndicatorPill.tsx +12 -6
- package/src/components/editor/LockBadge.tsx +31 -0
- package/src/components/editor/PresenceAvatars.tsx +53 -0
- package/src/components/editor/SectionWrapper.tsx +60 -29
- package/src/components/editor/StatusIndicator.tsx +4 -0
- package/src/components/shell/EditorShell.tsx +581 -22
- package/src/components/shell/MediaBreadcrumb.tsx +77 -0
- package/src/components/shell/MediaFolderTile.tsx +141 -0
- package/src/components/shell/MediaFolderTree.tsx +127 -0
- package/src/components/shell/MediaLibraryModal.tsx +611 -229
- package/src/components/shell/MoveToFolderDialog.tsx +78 -0
- package/src/hooks/useCollabSession.ts +220 -0
- package/src/hooks/useEditorPersistence.ts +104 -1
- package/src/hooks/useEditorPublish.ts +216 -71
- package/src/hooks/useMediaFolderDnd.ts +129 -0
- package/src/hooks/useMediaPipeline.ts +162 -6
- package/src/index.ts +2 -0
- package/src/lib/dexie.ts +44 -0
- package/src/lib/media-dnd.ts +62 -0
- package/src/lib/media-folders.ts +232 -0
- package/src/lib/registry.ts +8 -0
- package/src/media/folder-drop.ts +62 -0
- package/src/media/types.ts +2 -0
- package/src/media/utils.ts +10 -0
- package/src/schemas/media.ts +13 -0
- package/src/types/database.ts +55 -0
|
@@ -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,
|
package/src/index.ts
CHANGED
package/src/lib/dexie.ts
CHANGED
|
@@ -67,6 +67,14 @@ interface PendingMediaDeletionRow {
|
|
|
67
67
|
deletedAt: string;
|
|
68
68
|
}
|
|
69
69
|
|
|
70
|
+
export interface PreservedEditRow {
|
|
71
|
+
id: string;
|
|
72
|
+
sectionId: string;
|
|
73
|
+
content: SectionContent;
|
|
74
|
+
reason: "takeover" | "conflict";
|
|
75
|
+
preservedAt: string;
|
|
76
|
+
}
|
|
77
|
+
|
|
70
78
|
class EditorDatabase extends Dexie {
|
|
71
79
|
sections!: Dexie.Table<SectionRow, string>;
|
|
72
80
|
siteIndex!: Dexie.Table<SiteIndexRow, string>;
|
|
@@ -76,6 +84,7 @@ class EditorDatabase extends Dexie {
|
|
|
76
84
|
mediaManifest!: Dexie.Table<MediaManifestRow, string>;
|
|
77
85
|
pendingMedia!: Dexie.Table<PendingMediaRow, string>;
|
|
78
86
|
pendingMediaDeletions!: Dexie.Table<PendingMediaDeletionRow, string>;
|
|
87
|
+
preservedEdits!: Dexie.Table<PreservedEditRow, string>;
|
|
79
88
|
|
|
80
89
|
constructor(siteId: string) {
|
|
81
90
|
super(`brand-portal-${siteId}`);
|
|
@@ -164,6 +173,20 @@ class EditorDatabase extends Dexie {
|
|
|
164
173
|
await tx.table("siteIndex").clear();
|
|
165
174
|
await tx.table("contentCache").clear();
|
|
166
175
|
});
|
|
176
|
+
// v9: preservedEdits store — snapshots of local edits about to be
|
|
177
|
+
// superseded by a takeover or conflict resolution, so nothing is silently
|
|
178
|
+
// destroyed.
|
|
179
|
+
this.version(9).stores({
|
|
180
|
+
sections: "sectionId",
|
|
181
|
+
siteIndex: "key",
|
|
182
|
+
meta: "key",
|
|
183
|
+
siteConfig: "key",
|
|
184
|
+
contentCache: "key",
|
|
185
|
+
mediaManifest: "key",
|
|
186
|
+
pendingMedia: "id",
|
|
187
|
+
pendingMediaDeletions: "id",
|
|
188
|
+
preservedEdits: "id, sectionId",
|
|
189
|
+
});
|
|
167
190
|
}
|
|
168
191
|
}
|
|
169
192
|
|
|
@@ -539,3 +562,24 @@ export async function clearPendingMediaByIds(
|
|
|
539
562
|
if (deletionIds.length > 0) await database.pendingMediaDeletions.bulkDelete(deletionIds);
|
|
540
563
|
});
|
|
541
564
|
}
|
|
565
|
+
|
|
566
|
+
/** Snapshot a local edit that is about to be superseded (takeover or conflict
|
|
567
|
+
* resolution) so it is recoverable rather than silently destroyed. */
|
|
568
|
+
export async function preserveEdit(
|
|
569
|
+
sectionId: string,
|
|
570
|
+
content: SectionContent,
|
|
571
|
+
reason: "takeover" | "conflict",
|
|
572
|
+
): Promise<string> {
|
|
573
|
+
const preservedAt = new Date().toISOString();
|
|
574
|
+
const id = `${sectionId}:${preservedAt}`;
|
|
575
|
+
await getDb().preservedEdits.put({ id, sectionId, content, reason, preservedAt });
|
|
576
|
+
return id;
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
export async function listPreservedEdits(): Promise<PreservedEditRow[]> {
|
|
580
|
+
return getDb().preservedEdits.toArray();
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
export async function removePreservedEdit(id: string): Promise<void> {
|
|
584
|
+
await getDb().preservedEdits.delete(id);
|
|
585
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
import type { MediaFolder, MediaItem, MediaManifest } from "../media/types";
|
|
2
|
+
|
|
3
|
+
export interface FolderTreeNode {
|
|
4
|
+
id: string;
|
|
5
|
+
name: string;
|
|
6
|
+
children: FolderTreeNode[];
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface FolderUploadPlan {
|
|
10
|
+
/** New folder entities to merge into manifest.folders, keyed by new id. */
|
|
11
|
+
newFolders: Record<string, MediaFolder>;
|
|
12
|
+
/** Display-folder target per uploaded file (null = root). */
|
|
13
|
+
fileTargets: { file: File; folderId: string | null }[];
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Display-folder id. NOT related to MediaItem.folder (the git storage dir). */
|
|
17
|
+
export function createFolderId(): string {
|
|
18
|
+
const bytes = new Uint8Array(6);
|
|
19
|
+
crypto.getRandomValues(bytes);
|
|
20
|
+
return `f_${Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("")}`;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** True when this folder's parent chain loops back to itself. */
|
|
24
|
+
export function isCyclicFolder(folders: Record<string, MediaFolder>, id: string): boolean {
|
|
25
|
+
const max = Object.keys(folders).length;
|
|
26
|
+
let cursor = folders[id]?.parentId ?? null;
|
|
27
|
+
for (let steps = 0; cursor !== null && folders[cursor] && steps <= max; steps++) {
|
|
28
|
+
if (cursor === id) return true;
|
|
29
|
+
cursor = folders[cursor].parentId;
|
|
30
|
+
}
|
|
31
|
+
return false;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Effective parent for rendering: null (root) when the parent is missing,
|
|
36
|
+
* dangling, or `id` itself sits on a parent cycle. Cycle members re-root,
|
|
37
|
+
* which breaks the loop for their (kept) descendants — corrupt manifests
|
|
38
|
+
* always render, and healMediaManifest persists this view on save.
|
|
39
|
+
*/
|
|
40
|
+
export function effectiveParentId(folders: Record<string, MediaFolder>, id: string): string | null {
|
|
41
|
+
const parentId = folders[id]?.parentId ?? null;
|
|
42
|
+
if (parentId === null || !folders[parentId]) return null;
|
|
43
|
+
return isCyclicFolder(folders, id) ? null : parentId;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Direct children of `parentId`, alphabetically (case-insensitive). */
|
|
47
|
+
export function childFolderIds(folders: Record<string, MediaFolder>, parentId: string | null): string[] {
|
|
48
|
+
return Object.keys(folders)
|
|
49
|
+
.filter((id) => effectiveParentId(folders, id) === parentId)
|
|
50
|
+
.sort((a, b) => folders[a].name.localeCompare(folders[b].name, undefined, { sensitivity: "base" }));
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function buildFolderTree(folders: Record<string, MediaFolder>): FolderTreeNode[] {
|
|
54
|
+
const build = (parentId: string | null): FolderTreeNode[] =>
|
|
55
|
+
childFolderIds(folders, parentId).map((id) => ({
|
|
56
|
+
id,
|
|
57
|
+
name: folders[id].name,
|
|
58
|
+
children: build(id),
|
|
59
|
+
}));
|
|
60
|
+
return build(null);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Names from root to `id` inclusive, cycle-safe. */
|
|
64
|
+
export function folderPathNames(folders: Record<string, MediaFolder>, id: string): string[] {
|
|
65
|
+
const names: string[] = [];
|
|
66
|
+
let cursor: string | null = id;
|
|
67
|
+
while (cursor !== null && folders[cursor]) {
|
|
68
|
+
names.unshift(folders[cursor].name);
|
|
69
|
+
cursor = effectiveParentId(folders, cursor);
|
|
70
|
+
}
|
|
71
|
+
return names;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Folder ids from root to `id` inclusive, cycle-safe. */
|
|
75
|
+
export function folderPathIds(folders: Record<string, MediaFolder>, id: string): string[] {
|
|
76
|
+
const ids: string[] = [];
|
|
77
|
+
let cursor: string | null = id;
|
|
78
|
+
while (cursor !== null && folders[cursor]) {
|
|
79
|
+
ids.unshift(cursor);
|
|
80
|
+
cursor = effectiveParentId(folders, cursor);
|
|
81
|
+
}
|
|
82
|
+
return ids;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** All folders under `id` (by effective parent), INCLUDING `id` itself. */
|
|
86
|
+
export function descendantFolderIds(folders: Record<string, MediaFolder>, id: string): Set<string> {
|
|
87
|
+
const result = new Set<string>([id]);
|
|
88
|
+
const ids = Object.keys(folders);
|
|
89
|
+
let grew = true;
|
|
90
|
+
while (grew) {
|
|
91
|
+
grew = false;
|
|
92
|
+
for (const fid of ids) {
|
|
93
|
+
if (result.has(fid)) continue;
|
|
94
|
+
const parent = effectiveParentId(folders, fid);
|
|
95
|
+
if (parent !== null && result.has(parent)) {
|
|
96
|
+
result.add(fid);
|
|
97
|
+
grew = true;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
return result;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** May `id` re-parent to `targetParentId`? Blocks self/descendant targets and no-ops. */
|
|
105
|
+
export function canMoveFolder(
|
|
106
|
+
folders: Record<string, MediaFolder>,
|
|
107
|
+
id: string,
|
|
108
|
+
targetParentId: string | null,
|
|
109
|
+
): boolean {
|
|
110
|
+
if (!folders[id]) return false;
|
|
111
|
+
if (targetParentId === null) return effectiveParentId(folders, id) !== null;
|
|
112
|
+
if (!folders[targetParentId]) return false;
|
|
113
|
+
if (descendantFolderIds(folders, id).has(targetParentId)) return false;
|
|
114
|
+
return effectiveParentId(folders, id) !== targetParentId;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Trimmed `desired`, auto-suffixed " (2)", " (3)"… on case-insensitive sibling collision. */
|
|
118
|
+
export function uniqueFolderName(
|
|
119
|
+
folders: Record<string, MediaFolder>,
|
|
120
|
+
parentId: string | null,
|
|
121
|
+
desired: string,
|
|
122
|
+
excludeId?: string,
|
|
123
|
+
): string {
|
|
124
|
+
const base = desired.trim();
|
|
125
|
+
const siblings = new Set(
|
|
126
|
+
childFolderIds(folders, parentId)
|
|
127
|
+
.filter((id) => id !== excludeId)
|
|
128
|
+
.map((id) => folders[id].name.toLowerCase()),
|
|
129
|
+
);
|
|
130
|
+
if (!siblings.has(base.toLowerCase())) return base;
|
|
131
|
+
for (let n = 2; ; n++) {
|
|
132
|
+
const candidate = `${base} (${n})`;
|
|
133
|
+
if (!siblings.has(candidate.toLowerCase())) return candidate;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** The folder an item renders in: its folderId when it points at a real folder, else root. */
|
|
138
|
+
export function itemFolderId(
|
|
139
|
+
folders: Record<string, MediaFolder>,
|
|
140
|
+
item: Pick<MediaItem, "folderId">,
|
|
141
|
+
): string | null {
|
|
142
|
+
return item.folderId && folders[item.folderId] ? item.folderId : null;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** Recursive item counts per folder id (an item counts toward every ancestor). */
|
|
146
|
+
export function recursiveItemCounts(
|
|
147
|
+
folders: Record<string, MediaFolder>,
|
|
148
|
+
items: MediaItem[],
|
|
149
|
+
): Record<string, number> {
|
|
150
|
+
const counts: Record<string, number> = {};
|
|
151
|
+
for (const item of items) {
|
|
152
|
+
let cursor = itemFolderId(folders, item);
|
|
153
|
+
const seen = new Set<string>();
|
|
154
|
+
while (cursor !== null && !seen.has(cursor)) {
|
|
155
|
+
seen.add(cursor);
|
|
156
|
+
counts[cursor] = (counts[cursor] ?? 0) + 1;
|
|
157
|
+
cursor = effectiveParentId(folders, cursor);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
return counts;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** Pure heal: dangling folderIds dropped, orphan/cyclic folders re-rooted. */
|
|
164
|
+
export function healMediaManifest(manifest: MediaManifest): MediaManifest {
|
|
165
|
+
const folders: Record<string, MediaFolder> = {};
|
|
166
|
+
for (const [id, f] of Object.entries(manifest.folders)) {
|
|
167
|
+
folders[id] = { ...f, parentId: effectiveParentId(manifest.folders, id) };
|
|
168
|
+
}
|
|
169
|
+
const images: MediaManifest["images"] = {};
|
|
170
|
+
for (const [id, entry] of Object.entries(manifest.images)) {
|
|
171
|
+
if (entry.folderId && !folders[entry.folderId]) {
|
|
172
|
+
const { folderId: _dropped, ...rest } = entry;
|
|
173
|
+
images[id] = rest;
|
|
174
|
+
} else {
|
|
175
|
+
images[id] = entry;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
return { ...manifest, images, folders };
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Save-time manifest assembly: merge uploaded pending items, apply deletions,
|
|
183
|
+
* preserve every other manifest key (folders!), then heal.
|
|
184
|
+
*/
|
|
185
|
+
export function assembleSavedManifest(
|
|
186
|
+
manifest: MediaManifest,
|
|
187
|
+
uploadedItems: MediaItem[],
|
|
188
|
+
deletedIds: string[],
|
|
189
|
+
): MediaManifest {
|
|
190
|
+
const images = { ...manifest.images };
|
|
191
|
+
for (const entry of uploadedItems) images[entry.id] = entry;
|
|
192
|
+
for (const id of deletedIds) delete images[id];
|
|
193
|
+
return healMediaManifest({ ...manifest, images });
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Map webkitRelativePath-style paths onto folder entities under
|
|
198
|
+
* `currentFolderId`, merging with existing folders by case-insensitive name.
|
|
199
|
+
*/
|
|
200
|
+
export function planFolderUpload(
|
|
201
|
+
files: { file: File; relativePath: string }[],
|
|
202
|
+
folders: Record<string, MediaFolder>,
|
|
203
|
+
currentFolderId: string | null,
|
|
204
|
+
newId: () => string = createFolderId,
|
|
205
|
+
): FolderUploadPlan {
|
|
206
|
+
const newFolders: Record<string, MediaFolder> = {};
|
|
207
|
+
// (parentId, lowercased name) -> folder id, seeded from existing folders.
|
|
208
|
+
const byName = new Map<string, string>();
|
|
209
|
+
for (const [fid, f] of Object.entries(folders)) {
|
|
210
|
+
byName.set(`${effectiveParentId(folders, fid)}/${f.name.toLowerCase()}`, fid);
|
|
211
|
+
}
|
|
212
|
+
const ensureDir = (segments: string[]): string | null => {
|
|
213
|
+
let parent = currentFolderId;
|
|
214
|
+
for (const segment of segments) {
|
|
215
|
+
const key = `${parent}/${segment.toLowerCase()}`;
|
|
216
|
+
let fid = byName.get(key);
|
|
217
|
+
if (!fid) {
|
|
218
|
+
fid = newId();
|
|
219
|
+
newFolders[fid] = { name: segment, parentId: parent };
|
|
220
|
+
byName.set(key, fid);
|
|
221
|
+
}
|
|
222
|
+
parent = fid;
|
|
223
|
+
}
|
|
224
|
+
return parent;
|
|
225
|
+
};
|
|
226
|
+
const fileTargets = files.map(({ file, relativePath }) => {
|
|
227
|
+
const segments = relativePath.split("/").filter(Boolean);
|
|
228
|
+
segments.pop(); // filename
|
|
229
|
+
return { file, folderId: ensureDir(segments) };
|
|
230
|
+
});
|
|
231
|
+
return { newFolders, fileTargets };
|
|
232
|
+
}
|
package/src/lib/registry.ts
CHANGED
|
@@ -109,6 +109,14 @@ export interface WrapperProps {
|
|
|
109
109
|
mainStatus?: string | null;
|
|
110
110
|
contentDiffersFromMain?: boolean;
|
|
111
111
|
isLocalOnly?: boolean;
|
|
112
|
+
/** Collaboration: when another editor holds this section's lock, their identity.
|
|
113
|
+
* Renders a lock badge, makes content inert, and suppresses mutation chrome. */
|
|
114
|
+
lockedBy?: { email: string } | null;
|
|
115
|
+
/** Collaboration: request the lock be stolen from the current holder. */
|
|
116
|
+
onTakeover?: () => void;
|
|
117
|
+
/** Collaboration: fired on focus-in to request the lock. Resolves false when
|
|
118
|
+
* denied (caller keeps the section read-only via `lockedBy`). */
|
|
119
|
+
onEditIntent?: () => Promise<boolean>;
|
|
112
120
|
children: React.ReactNode;
|
|
113
121
|
}
|
|
114
122
|
|