@drawnagency/primitives 0.1.109 → 0.1.111

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 (208) hide show
  1. package/dist/auth/cookies.d.ts +8 -0
  2. package/dist/auth/cookies.d.ts.map +1 -1
  3. package/dist/auth/index.d.ts +1 -1
  4. package/dist/auth/index.d.ts.map +1 -1
  5. package/dist/auth/index.js +3 -1
  6. package/dist/auth/types.d.ts +11 -4
  7. package/dist/auth/types.d.ts.map +1 -1
  8. package/dist/{chunk-WXBHP4JY.js → chunk-3GAG6ZXB.js} +55 -46
  9. package/dist/{chunk-FM4M4Q2W.js → chunk-4TZJZU2T.js} +23 -3
  10. package/dist/{chunk-5PVRACZY.js → chunk-7MI3HCOF.js} +60 -23
  11. package/dist/{chunk-WBDPDGFA.js → chunk-7WQJ57XG.js} +6 -31
  12. package/dist/{chunk-LQHYVPBS.js → chunk-ALMEC3XN.js} +11 -12
  13. package/dist/{chunk-TZQPOR5A.js → chunk-DMCGN5AS.js} +10 -3
  14. package/dist/{chunk-ZKVKNUJC.js → chunk-ELQ4C677.js} +1 -1
  15. package/dist/{chunk-5BM2Y3NJ.js → chunk-FRVO75DK.js} +14 -2
  16. package/dist/{chunk-VKAGMEKE.js → chunk-FTPETWXE.js} +1 -0
  17. package/dist/{chunk-3TO4RRHY.js → chunk-GPPVYLEI.js} +7 -0
  18. package/dist/{chunk-NLQ5F3RQ.js → chunk-HBULBDGP.js} +1 -1
  19. package/dist/{chunk-67ZOQCUD.js → chunk-NXGIHSL5.js} +67 -50
  20. package/dist/{chunk-CPTFFLK2.js → chunk-OTDA6KWL.js} +10 -3
  21. package/dist/{chunk-XIDE4TDF.js → chunk-OUYB2GCW.js} +1 -1
  22. package/dist/{chunk-72CG6AA2.js → chunk-RA5IOFPY.js} +10 -2
  23. package/dist/{chunk-GEENI3OC.js → chunk-SW6PCQQD.js} +4 -0
  24. package/dist/{chunk-BOZUEKWL.js → chunk-UVIYI4J6.js} +1 -1
  25. package/dist/{chunk-AB3XVXI3.js → chunk-VCCNKTFD.js} +37 -29
  26. package/dist/{chunk-XBYJQ5RS.js → chunk-XQMDWXD6.js} +21 -6
  27. package/dist/collab/rebase.d.ts +6 -0
  28. package/dist/collab/rebase.d.ts.map +1 -1
  29. package/dist/collab/rebase.js +12 -3
  30. package/dist/collab/recovery.d.ts +0 -20
  31. package/dist/collab/recovery.d.ts.map +1 -1
  32. package/dist/components/brandguide/ColorSwatchSettings.d.ts.map +1 -1
  33. package/dist/components/brandguide/ColorsView.d.ts.map +1 -1
  34. package/dist/components/brandguide/colors-shared.d.ts +10 -1
  35. package/dist/components/brandguide/colors-shared.d.ts.map +1 -1
  36. package/dist/components/chat/ChatPanel.d.ts.map +1 -1
  37. package/dist/components/docfile/DocFilePane.d.ts.map +1 -1
  38. package/dist/components/docfile/PdfCanvasViewer.d.ts.map +1 -1
  39. package/dist/components/docfile/doc-file-state.d.ts.map +1 -1
  40. package/dist/components/editor/IndicatorPill.d.ts.map +1 -1
  41. package/dist/components/editor/NavGroupRow.d.ts +7 -2
  42. package/dist/components/editor/NavGroupRow.d.ts.map +1 -1
  43. package/dist/components/editor/PagesModal.d.ts.map +1 -1
  44. package/dist/components/editor/index.js +13 -12
  45. package/dist/components/primitives/EditableRichText.d.ts.map +1 -1
  46. package/dist/components/primitives/LinkPopover.d.ts.map +1 -1
  47. package/dist/components/primitives/index.js +14 -12
  48. package/dist/components/primitives/useEditableCollection.d.ts.map +1 -1
  49. package/dist/components/primitives/useEditablePlainText.d.ts.map +1 -1
  50. package/dist/components/sections/IconList/index.d.ts.map +1 -1
  51. package/dist/components/sections/Prose/Prose.d.ts.map +1 -1
  52. package/dist/components/sections/brand-guide.js +8 -8
  53. package/dist/components/sections/register-schemas.js +14 -14
  54. package/dist/components/sections/register.js +14 -14
  55. package/dist/components/shared/FontPicker.d.ts.map +1 -1
  56. package/dist/components/shared/HistoryChangesModal.d.ts.map +1 -1
  57. package/dist/components/shared/HistoryPopover.d.ts.map +1 -1
  58. package/dist/components/shared/Navigation.d.ts.map +1 -1
  59. package/dist/components/shared/Popover.d.ts.map +1 -1
  60. package/dist/components/shell/AudienceAddForm.d.ts +1 -5
  61. package/dist/components/shell/AudienceAddForm.d.ts.map +1 -1
  62. package/dist/components/shell/AudienceRow.d.ts +2 -3
  63. package/dist/components/shell/AudienceRow.d.ts.map +1 -1
  64. package/dist/components/shell/BugReportFAB.d.ts.map +1 -1
  65. package/dist/components/shell/EditorShell.d.ts.map +1 -1
  66. package/dist/components/shell/MediaLibraryModal.d.ts.map +1 -1
  67. package/dist/components/shell/SiteSettingsUsers.d.ts.map +1 -1
  68. package/dist/components/shell/SiteSettingsViewerAccess.d.ts.map +1 -1
  69. package/dist/components/shell/view-branch.d.ts.map +1 -1
  70. package/dist/document/components/DocRail.d.ts.map +1 -1
  71. package/dist/document/components/DocViewer.d.ts.map +1 -1
  72. package/dist/document/components/measure.d.ts.map +1 -1
  73. package/dist/document/components/useDocPages.d.ts.map +1 -1
  74. package/dist/document/engine/blocks.d.ts.map +1 -1
  75. package/dist/document/engine/masonry.d.ts.map +1 -1
  76. package/dist/document/engine/paginate.d.ts.map +1 -1
  77. package/dist/document/engine/settings.d.ts +3 -0
  78. package/dist/document/engine/settings.d.ts.map +1 -1
  79. package/dist/document/index.js +79 -28
  80. package/dist/hooks/useBuildStatus.d.ts.map +1 -1
  81. package/dist/hooks/useDocumentUploads.d.ts +1 -0
  82. package/dist/hooks/useDocumentUploads.d.ts.map +1 -1
  83. package/dist/hooks/useEditorPersistence.d.ts.map +1 -1
  84. package/dist/hooks/useEditorPublish.d.ts.map +1 -1
  85. package/dist/hooks/useFocusTrap.d.ts +6 -0
  86. package/dist/hooks/useFocusTrap.d.ts.map +1 -1
  87. package/dist/hooks/useMediaPipeline.d.ts +3 -2
  88. package/dist/hooks/useMediaPipeline.d.ts.map +1 -1
  89. package/dist/hooks/useSanitizedHtml.d.ts +12 -0
  90. package/dist/hooks/useSanitizedHtml.d.ts.map +1 -0
  91. package/dist/index.js +19 -13
  92. package/dist/lib/block-move.d.ts.map +1 -1
  93. package/dist/lib/contrast.d.ts.map +1 -1
  94. package/dist/lib/dexie.d.ts +2 -8
  95. package/dist/lib/dexie.d.ts.map +1 -1
  96. package/dist/lib/dexie.js +21 -49
  97. package/dist/lib/index-ops.d.ts.map +1 -1
  98. package/dist/lib/index-ops.js +43 -23
  99. package/dist/lib/index.js +8 -8
  100. package/dist/lib/loader.d.ts.map +1 -1
  101. package/dist/lib/media-folders.d.ts +12 -4
  102. package/dist/lib/media-folders.d.ts.map +1 -1
  103. package/dist/lib/media-folders.js +12 -2
  104. package/dist/lib/migrate-sections-transform.js +1 -1
  105. package/dist/lib/nav-progress.d.ts +14 -0
  106. package/dist/lib/nav-progress.d.ts.map +1 -0
  107. package/dist/lib/nav-tree.d.ts.map +1 -1
  108. package/dist/lib/nav.d.ts.map +1 -1
  109. package/dist/lib/pages.d.ts.map +1 -1
  110. package/dist/lib/registry.d.ts.map +1 -1
  111. package/dist/lib/registry.js +1 -1
  112. package/dist/lib/sanitize.d.ts +6 -0
  113. package/dist/lib/sanitize.d.ts.map +1 -1
  114. package/dist/lib/timestamp.d.ts.map +1 -1
  115. package/dist/media/index.js +4 -4
  116. package/dist/media/queue.d.ts.map +1 -1
  117. package/dist/media/resolve.d.ts.map +1 -1
  118. package/dist/media/utils.d.ts.map +1 -1
  119. package/dist/media/videoPoster.d.ts.map +1 -1
  120. package/dist/schemas/audience.d.ts +5 -1
  121. package/dist/schemas/audience.d.ts.map +1 -1
  122. package/dist/schemas/audience.js +1 -1
  123. package/dist/schemas/index.js +8 -6
  124. package/dist/schemas/sections.d.ts +12 -0
  125. package/dist/schemas/sections.d.ts.map +1 -1
  126. package/dist/storage/index.d.ts +1 -1
  127. package/dist/storage/index.d.ts.map +1 -1
  128. package/dist/storage/index.js +3 -1
  129. package/dist/storage/types.d.ts +9 -0
  130. package/dist/storage/types.d.ts.map +1 -1
  131. package/dist/types/database.d.ts +0 -3
  132. package/dist/types/database.d.ts.map +1 -1
  133. package/package.json +1 -1
  134. package/src/auth/cookies.ts +11 -0
  135. package/src/auth/index.ts +1 -1
  136. package/src/auth/types.ts +18 -4
  137. package/src/collab/rebase.ts +26 -1
  138. package/src/collab/recovery.ts +29 -3
  139. package/src/components/brandguide/ColorSwatchSettings.tsx +40 -13
  140. package/src/components/brandguide/ColorsEditable.tsx +7 -9
  141. package/src/components/brandguide/ColorsView.tsx +12 -11
  142. package/src/components/brandguide/colors-shared.ts +17 -1
  143. package/src/components/chat/ChatPanel.tsx +19 -0
  144. package/src/components/docfile/DocFilePane.tsx +11 -0
  145. package/src/components/docfile/PdfCanvasViewer.tsx +46 -5
  146. package/src/components/docfile/doc-file-state.ts +7 -0
  147. package/src/components/editor/IndicatorPill.tsx +5 -1
  148. package/src/components/editor/NavGroupRow.tsx +8 -3
  149. package/src/components/editor/PagesModal.tsx +10 -2
  150. package/src/components/primitives/EditableList.tsx +10 -2
  151. package/src/components/primitives/EditableRichText.tsx +4 -3
  152. package/src/components/primitives/LinkPopover.tsx +23 -1
  153. package/src/components/primitives/useEditableCollection.ts +61 -41
  154. package/src/components/primitives/useEditablePlainText.ts +24 -3
  155. package/src/components/sections/IconList/index.tsx +8 -1
  156. package/src/components/sections/Prose/Prose.tsx +4 -2
  157. package/src/components/shared/FontPicker.tsx +16 -4
  158. package/src/components/shared/HistoryChangesModal.tsx +12 -1
  159. package/src/components/shared/HistoryPopover.tsx +18 -4
  160. package/src/components/shared/Navigation.tsx +20 -2
  161. package/src/components/shared/Popover.tsx +9 -5
  162. package/src/components/shell/AudienceAddForm.tsx +6 -26
  163. package/src/components/shell/AudienceRow.tsx +29 -39
  164. package/src/components/shell/BugReportFAB.tsx +20 -2
  165. package/src/components/shell/EditorShell.tsx +200 -50
  166. package/src/components/shell/MediaLibraryModal.tsx +52 -10
  167. package/src/components/shell/SiteSettingsUsers.tsx +3 -0
  168. package/src/components/shell/SiteSettingsViewerAccess.tsx +21 -16
  169. package/src/components/shell/view-branch.ts +4 -1
  170. package/src/document/components/DocRail.tsx +27 -2
  171. package/src/document/components/DocViewer.tsx +5 -1
  172. package/src/document/components/measure.tsx +22 -6
  173. package/src/document/components/useDocPages.ts +13 -3
  174. package/src/document/engine/blocks.ts +33 -4
  175. package/src/document/engine/masonry.ts +8 -2
  176. package/src/document/engine/paginate.ts +4 -1
  177. package/src/document/engine/settings.ts +40 -3
  178. package/src/hooks/useBuildStatus.ts +6 -0
  179. package/src/hooks/useDocumentUploads.ts +46 -3
  180. package/src/hooks/useEditorPersistence.ts +33 -19
  181. package/src/hooks/useEditorPublish.ts +83 -34
  182. package/src/hooks/useFocusTrap.ts +18 -0
  183. package/src/hooks/useMediaPipeline.ts +111 -38
  184. package/src/hooks/useSanitizedHtml.ts +29 -0
  185. package/src/lib/audience-secret.ts +1 -0
  186. package/src/lib/block-move.ts +13 -2
  187. package/src/lib/contrast.ts +14 -14
  188. package/src/lib/dexie.ts +46 -49
  189. package/src/lib/index-ops.ts +63 -20
  190. package/src/lib/loader.ts +41 -12
  191. package/src/lib/media-folders.ts +31 -5
  192. package/src/lib/migrate-sections-transform.ts +22 -2
  193. package/src/lib/nav-progress.ts +108 -0
  194. package/src/lib/nav-tree.ts +16 -2
  195. package/src/lib/nav.ts +10 -1
  196. package/src/lib/pages.ts +24 -6
  197. package/src/lib/registry.ts +3 -0
  198. package/src/lib/sanitize.ts +9 -0
  199. package/src/lib/timestamp.ts +5 -0
  200. package/src/media/queue.ts +4 -0
  201. package/src/media/resolve.ts +14 -1
  202. package/src/media/utils.ts +3 -1
  203. package/src/media/videoPoster.ts +14 -3
  204. package/src/schemas/audience.ts +10 -2
  205. package/src/schemas/sections.ts +23 -1
  206. package/src/storage/index.ts +1 -1
  207. package/src/storage/types.ts +13 -0
  208. package/src/types/database.ts +0 -3
@@ -6,6 +6,8 @@ import Fab from "../shared/Fab";
6
6
 
7
7
  type Category = "Visual" | "Unexpected Behavior" | "Media / Media Library" | "Save / Publish" | "Feature Request" | "Other";
8
8
 
9
+ const MAX_IMAGES = 5;
10
+
9
11
  const CATEGORIES: Category[] = [
10
12
  "Visual",
11
13
  "Unexpected Behavior",
@@ -38,8 +40,11 @@ export function BugReportFAB() {
38
40
  const [isDragOver, setIsDragOver] = useState(false);
39
41
  const [isSubmitting, setIsSubmitting] = useState(false);
40
42
  const [submitError, setSubmitError] = useState<string | null>(null);
43
+ const [imageNotice, setImageNotice] = useState<string | null>(null);
41
44
  const [descriptionError, setDescriptionError] = useState<string | null>(null);
42
45
  const [toast, setToast] = useState<string | null>(null);
46
+ const toastTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
47
+ useEffect(() => () => { if (toastTimerRef.current) clearTimeout(toastTimerRef.current); }, []);
43
48
  const capturedContextRef = useRef<CapturedContext | null>(null);
44
49
  const dropZoneRef = useRef<HTMLDivElement>(null);
45
50
 
@@ -87,11 +92,22 @@ export function BugReportFAB() {
87
92
  name: file.name,
88
93
  })),
89
94
  );
90
- setImages((prev) => [...prev, ...newPreviews].slice(0, 5));
95
+ setImages((prev) => {
96
+ const next = [...prev, ...newPreviews];
97
+ // The cap keeps the FIRST five, so a sixth drop silently threw away the
98
+ // shot the reporter had just taken. Say what happened instead.
99
+ if (next.length > MAX_IMAGES) {
100
+ setImageNotice(`Only ${MAX_IMAGES} screenshots can be attached — ${next.length - MAX_IMAGES} were not added.`);
101
+ return next.slice(0, MAX_IMAGES);
102
+ }
103
+ setImageNotice(null);
104
+ return next;
105
+ });
91
106
  }, [fileToDataUri]);
92
107
 
93
108
  const handleRemoveImage = useCallback((index: number) => {
94
109
  setImages((prev) => prev.filter((_, i) => i !== index));
110
+ setImageNotice(null);
95
111
  }, []);
96
112
 
97
113
  const handleDragOver = useCallback((e: React.DragEvent) => {
@@ -171,7 +187,8 @@ export function BugReportFAB() {
171
187
 
172
188
  handleClose();
173
189
  setToast("Bug report submitted");
174
- setTimeout(() => setToast(null), 3000);
190
+ if (toastTimerRef.current) clearTimeout(toastTimerRef.current);
191
+ toastTimerRef.current = setTimeout(() => setToast(null), 3000);
175
192
  } catch (err) {
176
193
  setSubmitError(err instanceof Error ? err.message : "Submission failed");
177
194
  } finally {
@@ -287,6 +304,7 @@ export function BugReportFAB() {
287
304
  Drop images here or paste from clipboard
288
305
  </p>
289
306
  </div>
307
+ {imageNotice && <p className="mt-1 text-xs text-red-600">{imageNotice}</p>}
290
308
 
291
309
  {/* Thumbnail previews */}
292
310
  {images.length > 0 && (
@@ -31,7 +31,7 @@ import { EditorModal } from "./EditorModal";
31
31
  import { SiteSettingsModal } from "./SiteSettingsModal";
32
32
  import ChatWidget from "../chat/ChatWidget";
33
33
  import { MediaLibraryModal } from "./MediaLibraryModal";
34
- import { MediaLibraryContext, type LibraryKind } from "./MediaLibraryContext";
34
+ import { MediaLibraryContext, type LibraryKind, type MediaLibraryContextValue } from "./MediaLibraryContext";
35
35
  import { ProcessingIndicator } from "./ProcessingIndicator";
36
36
  import { SectionSkeleton } from "./SectionSkeleton";
37
37
  import { SectionTypePicker } from "./SectionTypePicker";
@@ -179,6 +179,12 @@ interface Props {
179
179
  collabBootstrap?: CollabBootstrap | null;
180
180
  }
181
181
 
182
+ /** Stable empty map for the closed-modal case, so the memos below don't churn. */
183
+ const EMPTY_COUNTS: Record<string, number> = {};
184
+
185
+ /** Short enough that a colour-picker drag still reads as live. */
186
+ const CONFIG_PREVIEW_DEBOUNCE_MS = 120;
187
+
182
188
  export default function EditorShell({
183
189
  headSha,
184
190
  draftHeadSha,
@@ -208,6 +214,18 @@ export default function EditorShell({
208
214
  // documents): U6's registry publish skips null so document rows keep the
209
215
  // overlay island's SSR seed instead of flashing "File missing" on mount.
210
216
  const [documentManifest, setDocumentManifest] = useState<DocumentManifest | null>(null);
217
+ // Read by the post-await cacheContent calls below. Every one of them re-primes
218
+ // the cache at a NEW sha, so omitting the manifest doesn't just skip a field —
219
+ // it writes null, and the next reload takes the cache hit and reports every
220
+ // document missing until the cache is invalidated. The collab handlers read it
221
+ // too: reading the state directly there would either capture a stale closure
222
+ // copy or force documentManifest into their dep arrays, churning the callback
223
+ // identity the collab wiring subscribes with.
224
+ //
225
+ // Assigned synchronously on render, NOT in an effect — the readers above run
226
+ // after an await in the same tick, which an effect-updated ref serves stale.
227
+ const documentManifestRef = useRef<DocumentManifest | null>(documentManifest);
228
+ documentManifestRef.current = documentManifest;
211
229
  const [showMediaLibrary, setShowMediaLibrary] = useState(false);
212
230
  const [mediaModalMode, setMediaModalMode] = useState<"select" | "manage">("manage");
213
231
  const [mediaSelectCallback, setMediaSelectCallback] = useState<((id: string) => void) | null>(null);
@@ -309,13 +327,11 @@ export default function EditorShell({
309
327
  // immediate 409 retry assembles the saved manifest against the rebased copy.
310
328
  const mediaManifestRef = useRef<MediaManifest>(mediaManifest);
311
329
  useEffect(() => { mediaManifestRef.current = mediaManifest; }, [mediaManifest]);
312
- // Same reason, for the document sidecar: the collab handlers cache it against a
313
- // remote sha, and reading the state directly there would either capture a stale
314
- // closure copy (writing an outdated sidecar for that sha — "File missing" on the
315
- // next cache hit) or force documentManifest into their dep arrays, churning the
316
- // callback identity the collab wiring subscribes with.
317
- const documentManifestRef = useRef<DocumentManifest | null>(documentManifest);
318
- useEffect(() => { documentManifestRef.current = documentManifest; }, [documentManifest]);
330
+ // The manifest our working copy diverged from — the last one we fetched, and
331
+ // the only way rebaseMediaManifest can tell "we deleted this folder" from
332
+ // "they created it". Advanced on every load and every rebase, never on a local
333
+ // folder edit.
334
+ const baseMediaManifestRef = useRef<MediaManifest | null>(null);
319
335
  // Guards the debounced preview-image composition effect (below): skips
320
336
  // recomposing on the very first run when a preview already exists, and
321
337
  // dedupes concurrent runs against the same composition signature.
@@ -548,6 +564,7 @@ export default function EditorShell({
548
564
  dirtySectionIds: dirtyIds,
549
565
  locallyAddedIds,
550
566
  locallyDeletedIds: deletedSections,
567
+ baselineIndex: cached?.index,
551
568
  remoteSections: remote.sections,
552
569
  remoteIndex: remote.index,
553
570
  // rebaseContent self-detects remote deletions of dirty sections, so we
@@ -584,9 +601,14 @@ export default function EditorShell({
584
601
  }
585
602
 
586
603
  const rebasedManifest = remote.mediaManifest
587
- ? rebaseMediaManifest(mediaManifestRef.current, remote.mediaManifest)
604
+ ? rebaseMediaManifest(
605
+ mediaManifestRef.current,
606
+ remote.mediaManifest,
607
+ baseMediaManifestRef.current ?? undefined,
608
+ )
588
609
  : null;
589
610
  if (rebasedManifest) setMediaManifest(rebasedManifest);
611
+ if (remote.mediaManifest) baseMediaManifestRef.current = remote.mediaManifest;
590
612
 
591
613
  setSections(mergedSections);
592
614
  setSiteIndex(mergedIndex);
@@ -603,7 +625,12 @@ export default function EditorShell({
603
625
  siteIndexRef.current = mergedIndex;
604
626
  changedSectionIdsRef.current = new Set(remote.changedSectionIds);
605
627
  mainIndexRef.current = remote.mainIndex;
606
- remoteSectionIdsRef.current = new Set(mergedIndex.pages.flatMap((p) => p.order));
628
+ // From the FETCHED remote index, not `mergedIndex` — the rebase
629
+ // deliberately preserves locallyAddedIds, and a never-saved local section
630
+ // is by definition not on the remote. Taking it from the merge would let a
631
+ // later delete of that section enqueue a file deletion for a path that was
632
+ // never committed.
633
+ remoteSectionIdsRef.current = new Set(remote.index.pages.flatMap((p) => p.order));
607
634
  // C2: the cache is the CLEAN baseline — always the fetched remote
608
635
  // snapshot, never the rebased result. Caching the rebase (local dirty
609
636
  // overlays folded in) would poison the next recovery's diff: dirty
@@ -686,6 +713,7 @@ export default function EditorShell({
686
713
  dirtySectionIds: dirtyIds,
687
714
  locallyAddedIds,
688
715
  locallyDeletedIds: deletedSections,
716
+ baselineIndex: cached?.index,
689
717
  remoteSections: remote.sections,
690
718
  remoteIndex: remote.index,
691
719
  remoteChangedIds,
@@ -693,9 +721,14 @@ export default function EditorShell({
693
721
 
694
722
  // The rebased result goes into React state (live view)...
695
723
  if (remote.mediaManifest) {
696
- const rebasedManifest = rebaseMediaManifest(mediaManifestRef.current, remote.mediaManifest);
724
+ const rebasedManifest = rebaseMediaManifest(
725
+ mediaManifestRef.current,
726
+ remote.mediaManifest,
727
+ baseMediaManifestRef.current ?? undefined,
728
+ );
697
729
  setMediaManifest(rebasedManifest);
698
730
  mediaManifestRef.current = rebasedManifest;
731
+ baseMediaManifestRef.current = remote.mediaManifest;
699
732
  }
700
733
  setSections(result.sections);
701
734
  setSiteIndex(result.index);
@@ -707,7 +740,9 @@ export default function EditorShell({
707
740
  savedShaRef.current = remote.savedBranchSha;
708
741
  changedSectionIdsRef.current = new Set(remote.changedSectionIds);
709
742
  mainIndexRef.current = remote.mainIndex;
710
- remoteSectionIdsRef.current = new Set(result.index.pages.flatMap((p) => p.order));
743
+ // Fetched remote, not the rebased `result` — see the identical note in
744
+ // recoverConflict.
745
+ remoteSectionIdsRef.current = new Set(remote.index.pages.flatMap((p) => p.order));
711
746
 
712
747
  // ...but the CLEAN fetched remote snapshot is what gets cached as the
713
748
  // baseline (amendment 1: caching the rebased result would poison the next
@@ -808,6 +843,16 @@ export default function EditorShell({
808
843
  });
809
844
  useEffect(() => { collabRef.current = collab; }, [collab]);
810
845
 
846
+ // Cached closures for sections that no longer exist are dead weight for the
847
+ // rest of the session — a long editing run of adds and deletes accumulates one
848
+ // per id ever seen.
849
+ useEffect(() => {
850
+ const live = new Set(sections.map((l) => l.section.id));
851
+ for (const id of editIntentByIdRef.current.keys()) {
852
+ if (!live.has(id)) editIntentByIdRef.current.delete(id);
853
+ }
854
+ }, [sections]);
855
+
811
856
  // Stable per-section onEditIntent: focus + lock request. The cached closure
812
857
  // always calls the LIVE session via collabRef, so it needn't change identity.
813
858
  const getEditIntent = useCallback((id: string): (() => Promise<boolean>) => {
@@ -1191,6 +1236,10 @@ export default function EditorShell({
1191
1236
  setMainSha(headSha);
1192
1237
  setChangedSectionIds(new Set(cached.changedSectionIds ?? []));
1193
1238
  setMainIndex(cached.mainIndex ?? loadedIndex);
1239
+ // Cached alongside the content: without it a cache-hit load showed no
1240
+ // damaged-section banner at all, so a broken section file stayed
1241
+ // invisible until the next fresh fetch.
1242
+ setUnparseableSectionIds(cached.unparseableSectionIds ?? []);
1194
1243
  // Without this, a cache-hit load left documentManifest null forever —
1195
1244
  // the siteDocuments memo's null-guard then never lifted and every
1196
1245
  // document row (committed or staged) read "File missing". The row's copy
@@ -1209,6 +1258,9 @@ export default function EditorShell({
1209
1258
  savedBranchSha: data.savedBranchSha,
1210
1259
  changedSectionIds: data.changedSectionIds,
1211
1260
  mainIndex: data.mainIndex,
1261
+ // Rides the same row as the content it describes — a cache-hit load
1262
+ // showed no damaged-section banner at all until this came along.
1263
+ unparseableSectionIds: data.unparseableSectionIds,
1212
1264
  },
1213
1265
  )
1214
1266
  setDocumentManifest(data.documentManifest ?? {});
@@ -1237,6 +1289,7 @@ export default function EditorShell({
1237
1289
  remoteSectionIdsRef.current = new Set(loadedIndex.pages.flatMap((p) => p.order));
1238
1290
  setSiteConfig(loadedConfig);
1239
1291
  setMediaManifest(loadedManifest);
1292
+ baseMediaManifestRef.current = loadedManifest;
1240
1293
  siteIndexRef.current = loadedIndex;
1241
1294
  applySiteConfigPreview(loadedConfig);
1242
1295
 
@@ -1353,6 +1406,69 @@ export default function EditorShell({
1353
1406
  setShellState({ phase: "ready" });
1354
1407
  }, [sections, applySiteConfigPreview, persistence]);
1355
1408
 
1409
+ // Usage counts are only read by the media modal, and computing them walks every
1410
+ // section (plus the whole siteConfig for images). Gate them on the modal being
1411
+ // open so a keystroke doesn't pay for two deep structural passes.
1412
+ const referenceCountMap = useMemo(
1413
+ () => (showMediaLibrary ? mediaPipeline.computeReferenceCountMap() : EMPTY_COUNTS),
1414
+ [showMediaLibrary, mediaPipeline.computeReferenceCountMap],
1415
+ );
1416
+ const documentReferenceCountMap = useMemo(
1417
+ () => (showMediaLibrary ? mediaPipeline.computeDocumentReferenceCountMap() : EMPTY_COUNTS),
1418
+ [showMediaLibrary, mediaPipeline.computeDocumentReferenceCountMap],
1419
+ );
1420
+
1421
+ // Memoized: rebuilt inline, these two arrays changed identity on every shell
1422
+ // render, so a keystroke in the modal's alt-text field (which is controlled off
1423
+ // mediaManifest) re-rendered the entire media grid.
1424
+ const mediaLibraryItems = useMemo(
1425
+ () => [
1426
+ ...Object.values(mediaManifest.images),
1427
+ ...mediaPipeline.pendingMediaItems,
1428
+ ].filter((item) => !mediaPipeline.pendingDeletions.includes(item.id) && !item.hidden),
1429
+ [mediaManifest.images, mediaPipeline.pendingMediaItems, mediaPipeline.pendingDeletions],
1430
+ );
1431
+
1432
+ const mediaLibraryDocuments = useMemo(
1433
+ () =>
1434
+ Object.entries({
1435
+ ...documentManifest,
1436
+ ...Object.fromEntries(mediaPipeline.pendingDocuments.map((d) => [d.id, d.entry])),
1437
+ })
1438
+ // Deleted-but-unsaved documents stay in the manifest until the save
1439
+ // commits them; the library must not offer them again.
1440
+ .filter(([id]) => !mediaPipeline.pendingDocumentDeletions.includes(id))
1441
+ .map(([id, entry]) => ({
1442
+ id,
1443
+ filename: entry.filename,
1444
+ size: entry.size,
1445
+ kind: entry.kind,
1446
+ })),
1447
+ [documentManifest, mediaPipeline.pendingDocuments, mediaPipeline.pendingDocumentDeletions],
1448
+ );
1449
+
1450
+ // The two overrides EditorShell layers onto the pipeline's context, hoisted so
1451
+ // they have stable identities.
1452
+ const openSelectModal = useCallback<MediaLibraryContextValue["openSelectModal"]>((onSelect, opts) => {
1453
+ setMediaModalMode("select");
1454
+ setMediaSelectCallback(() => onSelect);
1455
+ setMediaSelectKinds(opts?.kinds ?? null);
1456
+ setShowMediaLibrary(true);
1457
+ }, []);
1458
+
1459
+ const reportRejectedUploads = useCallback<MediaLibraryContextValue["reportRejectedUploads"]>((files) => {
1460
+ setRejectedUploads(files);
1461
+ }, []);
1462
+
1463
+ // Memoized: an inline object literal with two inline arrows defeated
1464
+ // useMediaPipeline's own memoised contextValue entirely. useResolvedMedia and
1465
+ // useResolvedDocument memoise on [id, ctx], so a fresh context per render made
1466
+ // EVERY image on the page re-run provider.resolve() on every keystroke.
1467
+ const mediaLibraryValue = useMemo(
1468
+ () => ({ ...mediaPipeline.contextValue, openSelectModal, reportRejectedUploads }),
1469
+ [mediaPipeline.contextValue, openSelectModal, reportRejectedUploads],
1470
+ );
1471
+
1356
1472
  const handleDiscard = useCallback(async () => {
1357
1473
  await discardLocalChanges();
1358
1474
  setLocalChangesExist(false);
@@ -1365,6 +1481,12 @@ export default function EditorShell({
1365
1481
  mediaPipeline.setPendingMediaItems([]);
1366
1482
  mediaPipeline.setPendingLocalUrls({});
1367
1483
  mediaPipeline.setPendingDeletions([]);
1484
+ // Documents are a separate pipeline with its own two pending states, and
1485
+ // both outlive discard unless cleared here: a doomed committed document
1486
+ // would still be sent as `deletedDocumentIds` by the next save, and a
1487
+ // staged upload would still be committed by it.
1488
+ mediaPipeline.setPendingDocumentDeletions([]);
1489
+ await mediaPipeline.unstageAllDocuments();
1368
1490
  const cached = await getCachedContent();
1369
1491
  if (cached) {
1370
1492
  setSections(cached.sections);
@@ -1378,7 +1500,12 @@ export default function EditorShell({
1378
1500
  }
1379
1501
  // Discard restores the cached snapshot, so the sidecars must come from that
1380
1502
  // same row — anything else could disagree with the sections we just set.
1381
- if (cached.mediaManifest) setMediaManifest(cached.mediaManifest);
1503
+ if (cached.mediaManifest) {
1504
+ setMediaManifest(cached.mediaManifest);
1505
+ // Discard re-seeds the working copy from this row, so it is also the new
1506
+ // divergence base for rebaseMediaManifest.
1507
+ baseMediaManifestRef.current = cached.mediaManifest;
1508
+ }
1382
1509
  if (cached.documentManifest) setDocumentManifest(cached.documentManifest);
1383
1510
  }
1384
1511
  persistence.clearConfigDirty();
@@ -1638,12 +1765,37 @@ export default function EditorShell({
1638
1765
  [persistence],
1639
1766
  );
1640
1767
 
1768
+ // Debounced side effects for Site Settings edits. `setSiteConfig` stays
1769
+ // immediate (the form is controlled off it), but the preview writes nine CSS
1770
+ // custom properties, four synchronous localStorage.setItem calls and two event
1771
+ // dispatches, and persistConfig is a Dexie write — all of which ran on every
1772
+ // keystroke in the site-name field.
1773
+ const configFlushTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
1774
+ const pendingConfigRef = useRef<SiteConfig | null>(null);
1775
+
1776
+ const flushSiteConfigSideEffects = useCallback(() => {
1777
+ if (configFlushTimerRef.current) {
1778
+ clearTimeout(configFlushTimerRef.current);
1779
+ configFlushTimerRef.current = null;
1780
+ }
1781
+ const config = pendingConfigRef.current;
1782
+ if (!config) return;
1783
+ pendingConfigRef.current = null;
1784
+ applySiteConfigPreview(config);
1785
+ void persistence.persistConfig(config);
1786
+ }, [applySiteConfigPreview, persistence]);
1787
+
1788
+ // Trailing edge always fires, and unmount flushes — so the last keystroke
1789
+ // before closing Site Settings is never the one that gets dropped.
1790
+ useEffect(() => flushSiteConfigSideEffects, [flushSiteConfigSideEffects]);
1791
+
1641
1792
  const handleSiteConfigChange = useCallback((config: SiteConfig) => {
1642
1793
  setSiteConfig(config);
1643
- applySiteConfigPreview(config);
1644
- persistence.persistConfig(config);
1645
1794
  setLocalChangesExist(true);
1646
- }, [applySiteConfigPreview, persistence]);
1795
+ pendingConfigRef.current = config;
1796
+ if (configFlushTimerRef.current) clearTimeout(configFlushTimerRef.current);
1797
+ configFlushTimerRef.current = setTimeout(flushSiteConfigSideEffects, CONFIG_PREVIEW_DEBOUNCE_MS);
1798
+ }, [flushSiteConfigSideEffects]);
1647
1799
 
1648
1800
  // Recompose the social-preview raster (siteConfig.previewImage) when brand
1649
1801
  // inputs change. Debounced so text edits don't compose per keystroke. The
@@ -1680,7 +1832,10 @@ export default function EditorShell({
1680
1832
  const blob = await composePreviewImage(inputs);
1681
1833
  const file = new File([blob], "site-preview.webp", { type: "image/webp" });
1682
1834
  const { width, height } = servedPreviewSize(siteConfigRef.current!.media.sizes);
1683
- mediaPipeline.contextValue.uploadFileWithCallback(file, (imageId) => {
1835
+ // enqueueFile, not uploadFileWithCallback: the latter mints a blob URL
1836
+ // for an immediate local preview, and this call site discards it — so
1837
+ // it was only ever revoked if the upload happened to succeed.
1838
+ mediaPipeline.enqueueFile(file, (imageId) => {
1684
1839
  // Ignore a stale upload: a newer compose has superseded this one.
1685
1840
  if (gen !== previewGenRef.current) return;
1686
1841
  handleSiteConfigChange({
@@ -1811,19 +1966,11 @@ export default function EditorShell({
1811
1966
  <EditorProvider>
1812
1967
  <ViewBranchWatcher
1813
1968
  setViewSections={setViewSections}
1969
+ onError={setRemoteNotice}
1814
1970
  />
1815
1971
  <HistoryWatcher onHistoryActiveChange={setHistoryActive} />
1816
1972
  <EditorModalProvider>
1817
- <MediaLibraryContext.Provider value={{
1818
- ...mediaPipeline.contextValue,
1819
- openSelectModal: (onSelect, opts) => {
1820
- setMediaModalMode("select");
1821
- setMediaSelectCallback(() => onSelect);
1822
- setMediaSelectKinds(opts?.kinds ?? null);
1823
- setShowMediaLibrary(true);
1824
- },
1825
- reportRejectedUploads: (files) => setRejectedUploads(files),
1826
- }}>
1973
+ <MediaLibraryContext.Provider value={mediaLibraryValue}>
1827
1974
  <div className="editor-shell relative">
1828
1975
  <EditorToolbar
1829
1976
  buttonState={buttonState}
@@ -2094,23 +2241,8 @@ export default function EditorShell({
2094
2241
  >
2095
2242
  <MediaLibraryModal
2096
2243
  mode={mediaModalMode}
2097
- items={[
2098
- ...Object.values(mediaManifest.images),
2099
- ...mediaPipeline.pendingMediaItems,
2100
- ].filter((item) => !mediaPipeline.pendingDeletions.includes(item.id) && !item.hidden)}
2101
- documents={Object.entries({
2102
- ...documentManifest,
2103
- ...Object.fromEntries(mediaPipeline.pendingDocuments.map((d) => [d.id, d.entry])),
2104
- })
2105
- // Deleted-but-unsaved documents stay in the manifest until the
2106
- // save commits them; the library must not offer them again.
2107
- .filter(([id]) => !mediaPipeline.pendingDocumentDeletions.includes(id))
2108
- .map(([id, entry]) => ({
2109
- id,
2110
- filename: entry.filename,
2111
- size: entry.size,
2112
- kind: entry.kind,
2113
- }))}
2244
+ items={mediaLibraryItems}
2245
+ documents={mediaLibraryDocuments}
2114
2246
  kinds={mediaModalMode === "manage" ? undefined : (mediaSelectKinds ?? undefined)}
2115
2247
  onSelect={(id) => {
2116
2248
  if (mediaSelectCallback) {
@@ -2124,8 +2256,8 @@ export default function EditorShell({
2124
2256
  onDelete={mediaPipeline.handleMediaDelete}
2125
2257
  onDeleteDocuments={(ids) => { void mediaPipeline.handleDocumentDelete(ids); }}
2126
2258
  onAltChange={mediaPipeline.handleMediaAltChange}
2127
- referenceCountMap={mediaPipeline.referenceCountMap}
2128
- documentReferenceCountMap={mediaPipeline.documentReferenceCountMap}
2259
+ referenceCountMap={referenceCountMap}
2260
+ documentReferenceCountMap={documentReferenceCountMap}
2129
2261
  localUrlMap={mediaPipeline.pendingLocalUrls}
2130
2262
  maxFileSize={siteConfig?.media.maxFileSize}
2131
2263
  uploadProgress={mediaPipeline.uploadProgress}
@@ -2219,8 +2351,10 @@ export default function EditorShell({
2219
2351
 
2220
2352
  function ViewBranchWatcher({
2221
2353
  setViewSections,
2354
+ onError,
2222
2355
  }: {
2223
2356
  setViewSections: (sections: LoadedSection[] | null) => void;
2357
+ onError: (message: string) => void;
2224
2358
  }) {
2225
2359
  const { isEditMode, viewBranch } = useEditorContext();
2226
2360
 
@@ -2234,13 +2368,29 @@ function ViewBranchWatcher({
2234
2368
  return;
2235
2369
  }
2236
2370
 
2237
- fetch(`/api/content?branch=main`)
2238
- .then((res) => res.json())
2371
+ // Cancelled on switch-away: without this a late resolve re-entered
2372
+ // branch-preview over the draft canvas after the user had already left it.
2373
+ const controller = new AbortController();
2374
+ fetch(`/api/content?branch=main`, { signal: controller.signal })
2375
+ .then((res) => {
2376
+ // A non-JSON 5xx used to parse to `undefined` and render an empty
2377
+ // "Published" view with nothing explaining it.
2378
+ if (!res.ok) throw new Error(`Failed to load the published view: ${res.status}`);
2379
+ return res.json();
2380
+ })
2239
2381
  .then((data) => {
2382
+ if (!Array.isArray(data?.sections)) throw new Error("Malformed published-view response");
2240
2383
  setViewSections(data.sections);
2241
2384
  })
2242
- .catch(console.error);
2243
- }, [viewBranch, isEditMode]);
2385
+ .catch((err) => {
2386
+ if (err?.name === "AbortError") return;
2387
+ console.error(err);
2388
+ // Fall back to the in-memory path rather than showing an empty page.
2389
+ setViewSections(null);
2390
+ onError("Couldn't load the published view — showing your current edits.");
2391
+ });
2392
+ return () => controller.abort();
2393
+ }, [viewBranch, isEditMode, setViewSections, onError]);
2244
2394
 
2245
2395
  return null;
2246
2396
  }
@@ -260,6 +260,50 @@ function UploadZone({ onUpload, onFolderUpload }: {
260
260
  );
261
261
  }
262
262
 
263
+ // A local draft, committed on blur. Bound straight to the manifest it fired a
264
+ // manifest write per keystroke, and every one of those re-rendered the whole
265
+ // editor shell — visible typing lag on a large library.
266
+ function AltTextField({ id, alt, onAltChange }: {
267
+ id: string;
268
+ alt: string;
269
+ onAltChange?: (id: string, alt: string) => void;
270
+ }) {
271
+ const [draft, setDraft] = useState(alt);
272
+ const committedRef = useRef(alt);
273
+
274
+ // Adopt an external change (a peer's save, a manifest reload); our own commit
275
+ // comes back through this prop too, hence the comparison against `committed`.
276
+ useEffect(() => {
277
+ if (alt !== committedRef.current) {
278
+ committedRef.current = alt;
279
+ setDraft(alt);
280
+ }
281
+ }, [alt]);
282
+
283
+ const commit = () => {
284
+ if (draft === committedRef.current) return;
285
+ committedRef.current = draft;
286
+ onAltChange?.(id, draft);
287
+ };
288
+ // Closing the modal unmounts the field without a blur; without this the last
289
+ // edit would be silently dropped.
290
+ const commitRef = useRef(commit);
291
+ commitRef.current = commit;
292
+ useEffect(() => () => commitRef.current(), []);
293
+
294
+ return (
295
+ <textarea
296
+ value={draft}
297
+ placeholder="Describe this image for accessibility"
298
+ rows={2}
299
+ onClick={(e) => e.stopPropagation()}
300
+ onChange={(e) => setDraft(e.target.value)}
301
+ onBlur={commit}
302
+ className="w-full resize-none border-0 bg-transparent p-0 text-xs leading-snug text-base-contrast placeholder:text-base-contrast-light/40 focus:outline-none focus:ring-0"
303
+ />
304
+ );
305
+ }
306
+
263
307
  function MediaCard({ item, manage, isSelected, dragIds, onClick, children }: {
264
308
  item: MediaItem; manage: boolean; isSelected: boolean;
265
309
  dragIds: string[]; onClick: (e: React.MouseEvent) => void; children: React.ReactNode;
@@ -528,7 +572,10 @@ export function MediaLibraryModal({
528
572
  }
529
573
 
530
574
  function handleBatchDelete() {
531
- if (totalUsage > 0) {
575
+ // Confirm for anything in use, and for any multi-selection: an unused
576
+ // multi-delete went straight through, so one mis-aimed click could take out
577
+ // a whole rubber-banded range with nothing to stop it.
578
+ if (totalUsage > 0 || selectedIds.length > 1) {
532
579
  setConfirmDelete(true);
533
580
  setFolderToDelete(null); // the two delete confirmations are mutually exclusive
534
581
  return;
@@ -723,7 +770,9 @@ export function MediaLibraryModal({
723
770
  {confirmDelete && (
724
771
  <div className="flex flex-col items-center rounded-lg border border-red-200 bg-red-50 px-4 py-3 lg:flex-row lg:justify-between dark:border-red-900/50 dark:bg-red-950/30">
725
772
  <p className="mb-2 text-center text-sm text-red-800 lg:mb-0 lg:text-left dark:text-red-200">
726
- Some of these {usedNoun} are being used. Deleting them will remove them from the sections that use them.
773
+ {totalUsage > 0
774
+ ? `Some of these ${usedNoun} are being used. Deleting them will remove them from the sections that use them.`
775
+ : `Delete ${selectedIds.length} ${usedNoun}? No section uses them.`}
727
776
  </p>
728
777
  <div className="flex shrink-0 items-center gap-2">
729
778
  <Button variant="secondary" size="sm" onClick={() => setConfirmDelete(false)}>
@@ -982,14 +1031,7 @@ export function MediaLibraryModal({
982
1031
  <label className="text-[10px] font-medium uppercase leading-none tracking-wide text-base-contrast-light/50">
983
1032
  Alt text
984
1033
  </label>
985
- <textarea
986
- value={item.alt}
987
- placeholder="Describe this image for accessibility"
988
- rows={2}
989
- onClick={(e) => e.stopPropagation()}
990
- onChange={(e) => onAltChange?.(item.id, e.target.value)}
991
- className="w-full resize-none border-0 bg-transparent p-0 text-xs leading-snug text-base-contrast placeholder:text-base-contrast-light/40 focus:outline-none focus:ring-0"
992
- />
1034
+ <AltTextField id={item.id} alt={item.alt} onAltChange={onAltChange} />
993
1035
  </div>
994
1036
  </MediaCard>
995
1037
  );
@@ -32,6 +32,9 @@ export function SiteSettingsUsers({ currentUser }: Props) {
32
32
  }
33
33
  const data = await res.json();
34
34
  setUsers(data.users);
35
+ // A successful reload has to clear the previous failure, or a transient
36
+ // network blip leaves its message on screen for the rest of the session.
37
+ setError(null);
35
38
  } catch {
36
39
  setError("Network error while loading users");
37
40
  setUsers([]);