@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.
Files changed (122) hide show
  1. package/dist/{chunk-Q3T23HVW.js → chunk-2DDWWTRX.js} +5 -5
  2. package/dist/chunk-2G2S36SY.js +13 -0
  3. package/dist/{chunk-S55OZ7F2.js → chunk-3LNKOLZP.js} +4 -0
  4. package/dist/{chunk-SWIHUIFT.js → chunk-4SIWBWKZ.js} +12 -2
  5. package/dist/{chunk-SWEVWC5K.js → chunk-BAQYZSFF.js} +22 -22
  6. package/dist/{chunk-IFHRHV7S.js → chunk-EMGLZQCM.js} +1 -1
  7. package/dist/{chunk-NKVZGPR6.js → chunk-K4JD6FD3.js} +1 -1
  8. package/dist/{chunk-SFKYUKVN.js → chunk-MK4POMLZ.js} +1 -1
  9. package/dist/{chunk-7T2WQSWF.js → chunk-NS3UTJ3E.js} +11 -11
  10. package/dist/chunk-REM2NH7K.js +44 -0
  11. package/dist/{chunk-T7MP7ANP.js → chunk-UA6OCJJK.js} +2 -2
  12. package/dist/{chunk-ZRWGV5O6.js → chunk-Z5IYWUW3.js} +3 -3
  13. package/dist/collab/provider-registry.d.ts +4 -0
  14. package/dist/collab/provider-registry.d.ts.map +1 -0
  15. package/dist/collab/provider-registry.js +9 -0
  16. package/dist/collab/rebase.d.ts +37 -0
  17. package/dist/collab/rebase.d.ts.map +1 -0
  18. package/dist/collab/rebase.js +64 -0
  19. package/dist/collab/recovery.d.ts +89 -0
  20. package/dist/collab/recovery.d.ts.map +1 -0
  21. package/dist/collab/types.d.ts +93 -0
  22. package/dist/collab/types.d.ts.map +1 -0
  23. package/dist/collab/types.js +19 -0
  24. package/dist/components/editor/AudienceIndicator.d.ts +3 -1
  25. package/dist/components/editor/AudienceIndicator.d.ts.map +1 -1
  26. package/dist/components/editor/ConflictDialog.d.ts +14 -0
  27. package/dist/components/editor/ConflictDialog.d.ts.map +1 -0
  28. package/dist/components/editor/IndicatorPill.d.ts +4 -1
  29. package/dist/components/editor/IndicatorPill.d.ts.map +1 -1
  30. package/dist/components/editor/LockBadge.d.ts +7 -0
  31. package/dist/components/editor/LockBadge.d.ts.map +1 -0
  32. package/dist/components/editor/PresenceAvatars.d.ts +9 -0
  33. package/dist/components/editor/PresenceAvatars.d.ts.map +1 -0
  34. package/dist/components/editor/SectionWrapper.d.ts +1 -1
  35. package/dist/components/editor/SectionWrapper.d.ts.map +1 -1
  36. package/dist/components/editor/StatusIndicator.d.ts +3 -1
  37. package/dist/components/editor/StatusIndicator.d.ts.map +1 -1
  38. package/dist/components/editor/index.js +95 -47
  39. package/dist/components/primitives/index.js +5 -5
  40. package/dist/components/sections/brand-guide.js +5 -5
  41. package/dist/components/sections/register-schemas.js +11 -11
  42. package/dist/components/sections/register.js +11 -11
  43. package/dist/components/shell/EditorShell.d.ts +3 -1
  44. package/dist/components/shell/EditorShell.d.ts.map +1 -1
  45. package/dist/components/shell/MediaBreadcrumb.d.ts +10 -0
  46. package/dist/components/shell/MediaBreadcrumb.d.ts.map +1 -0
  47. package/dist/components/shell/MediaFolderTile.d.ts +15 -0
  48. package/dist/components/shell/MediaFolderTile.d.ts.map +1 -0
  49. package/dist/components/shell/MediaFolderTree.d.ts +20 -0
  50. package/dist/components/shell/MediaFolderTree.d.ts.map +1 -0
  51. package/dist/components/shell/MediaLibraryModal.d.ts +15 -3
  52. package/dist/components/shell/MediaLibraryModal.d.ts.map +1 -1
  53. package/dist/components/shell/MoveToFolderDialog.d.ts +16 -0
  54. package/dist/components/shell/MoveToFolderDialog.d.ts.map +1 -0
  55. package/dist/hooks/useCollabSession.d.ts +23 -0
  56. package/dist/hooks/useCollabSession.d.ts.map +1 -0
  57. package/dist/hooks/useEditorPersistence.d.ts +21 -1
  58. package/dist/hooks/useEditorPersistence.d.ts.map +1 -1
  59. package/dist/hooks/useEditorPublish.d.ts +39 -2
  60. package/dist/hooks/useEditorPublish.d.ts.map +1 -1
  61. package/dist/hooks/useMediaFolderDnd.d.ts +27 -0
  62. package/dist/hooks/useMediaFolderDnd.d.ts.map +1 -0
  63. package/dist/hooks/useMediaPipeline.d.ts +14 -2
  64. package/dist/hooks/useMediaPipeline.d.ts.map +1 -1
  65. package/dist/index.d.ts +2 -0
  66. package/dist/index.d.ts.map +1 -1
  67. package/dist/index.js +51 -25
  68. package/dist/lib/dexie.d.ts +12 -0
  69. package/dist/lib/dexie.d.ts.map +1 -1
  70. package/dist/lib/dexie.js +27 -0
  71. package/dist/lib/index.js +12 -12
  72. package/dist/lib/media-dnd.d.ts +26 -0
  73. package/dist/lib/media-dnd.d.ts.map +1 -0
  74. package/dist/lib/media-folders.d.ts +59 -0
  75. package/dist/lib/media-folders.d.ts.map +1 -0
  76. package/dist/lib/registry.d.ts +10 -0
  77. package/dist/lib/registry.d.ts.map +1 -1
  78. package/dist/media/folder-drop.d.ts +22 -0
  79. package/dist/media/folder-drop.d.ts.map +1 -0
  80. package/dist/media/index.js +6 -2
  81. package/dist/media/types.d.ts +1 -1
  82. package/dist/media/types.d.ts.map +1 -1
  83. package/dist/media/utils.d.ts +7 -0
  84. package/dist/media/utils.d.ts.map +1 -1
  85. package/dist/schemas/index.js +5 -3
  86. package/dist/schemas/media.d.ts +11 -0
  87. package/dist/schemas/media.d.ts.map +1 -1
  88. package/dist/types/database.d.ts +70 -0
  89. package/dist/types/database.d.ts.map +1 -1
  90. package/package.json +13 -1
  91. package/src/collab/provider-registry.ts +14 -0
  92. package/src/collab/rebase.ts +140 -0
  93. package/src/collab/recovery.ts +149 -0
  94. package/src/collab/types.ts +96 -0
  95. package/src/components/editor/AudienceIndicator.tsx +4 -1
  96. package/src/components/editor/ConflictDialog.tsx +61 -0
  97. package/src/components/editor/IndicatorPill.tsx +12 -6
  98. package/src/components/editor/LockBadge.tsx +31 -0
  99. package/src/components/editor/PresenceAvatars.tsx +53 -0
  100. package/src/components/editor/SectionWrapper.tsx +60 -29
  101. package/src/components/editor/StatusIndicator.tsx +4 -0
  102. package/src/components/shell/EditorShell.tsx +581 -22
  103. package/src/components/shell/MediaBreadcrumb.tsx +77 -0
  104. package/src/components/shell/MediaFolderTile.tsx +141 -0
  105. package/src/components/shell/MediaFolderTree.tsx +127 -0
  106. package/src/components/shell/MediaLibraryModal.tsx +611 -229
  107. package/src/components/shell/MoveToFolderDialog.tsx +78 -0
  108. package/src/hooks/useCollabSession.ts +220 -0
  109. package/src/hooks/useEditorPersistence.ts +104 -1
  110. package/src/hooks/useEditorPublish.ts +216 -71
  111. package/src/hooks/useMediaFolderDnd.ts +129 -0
  112. package/src/hooks/useMediaPipeline.ts +162 -6
  113. package/src/index.ts +2 -0
  114. package/src/lib/dexie.ts +44 -0
  115. package/src/lib/media-dnd.ts +62 -0
  116. package/src/lib/media-folders.ts +232 -0
  117. package/src/lib/registry.ts +8 -0
  118. package/src/media/folder-drop.ts +62 -0
  119. package/src/media/types.ts +2 -0
  120. package/src/media/utils.ts +10 -0
  121. package/src/schemas/media.ts +13 -0
  122. 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) 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,
package/src/index.ts CHANGED
@@ -4,3 +4,5 @@ export * from "./auth";
4
4
  export * from "./media";
5
5
  export * from "./deploy";
6
6
  export * from "./storage";
7
+ export * from "./collab/types";
8
+ export { setCollabProvider, getCollabProvider } from "./collab/provider-registry";
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
+ }
@@ -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