@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
@@ -10,7 +10,7 @@ import type { SiteIndex, SiteConfig, Page } from "../../schemas/site-config";
10
10
  import type { Audience } from "../../auth/types";
11
11
  import type { MediaManifest } from "../../media/types";
12
12
  import type { QueueItem } from "../../media/queue";
13
- import { SiteConfigSchema } from "../../schemas/site-config";
13
+ import { SiteConfigSchema, IndexSchema } from "../../schemas/site-config";
14
14
  import { ensureSanitizer } from "../../lib/sanitize";
15
15
  import { EditorProvider, useEditorContext } from "./EditorContext";
16
16
  import { makeBlockMoveDispatch } from "./blockMoveDispatch";
@@ -27,7 +27,7 @@ import { ProcessingIndicator } from "./ProcessingIndicator";
27
27
  import { SectionSkeleton } from "./SectionSkeleton";
28
28
  import { SectionTypePicker } from "./SectionTypePicker";
29
29
  import { ensureSectionsRegistered } from "../sections/register";
30
- import { getSection, getAllSections } from "../../lib/registry";
30
+ import { getSection, getAllSections, getSchema } from "../../lib/registry";
31
31
  import { childInsertableTypes, insertChildAt } from "../../lib/container-ops";
32
32
  import { buildInsertMenuOptions, resolveInsertedContent } from "../../lib/insert-menu";
33
33
 
@@ -49,8 +49,18 @@ import {
49
49
  getPendingMediaItems,
50
50
  getPendingMediaBlobs,
51
51
  getPendingMediaDeletions,
52
+ getDirtySectionRows,
53
+ deleteSectionRows,
54
+ preserveEdit,
52
55
  } from "../../lib/dexie";
56
+ import { rebaseContent } from "../../collab/rebase";
57
+ import { deriveRecoveryInputs, applyConflictChoices } from "../../collab/recovery";
58
+ import { getCollabProvider } from "../../collab/provider-registry";
59
+ import type { CollabBootstrap, CollabPeer, CollabStatus, SavedMessage, TakeoverMessage } from "../../collab/types";
60
+ import { ConflictDialog, type ConflictItem } from "../editor/ConflictDialog";
61
+ import { PresenceAvatars } from "../editor/PresenceAvatars";
53
62
  import { useEditorPersistence } from "../../hooks/useEditorPersistence";
63
+ import { useCollabSession, type UseCollabSessionResult } from "../../hooks/useCollabSession";
54
64
  import { useEditorPublish } from "../../hooks/useEditorPublish";
55
65
  import { useContentLifecycle } from "../../hooks/useContentLifecycle";
56
66
  import { useBuildStatus } from "../../hooks/useBuildStatus";
@@ -89,6 +99,18 @@ type ShellState =
89
99
  | { phase: "recovery"; latestTimestamp: string }
90
100
  | { phase: "ready" };
91
101
 
102
+ // The parsed shape of GET /api/content?branch=saved (the fresh draft snapshot).
103
+ interface SavedContent {
104
+ sha: string;
105
+ sections: LoadedSection[];
106
+ index: SiteIndex;
107
+ siteConfig: Record<string, unknown>;
108
+ savedBranchSha: string | null;
109
+ changedSectionIds: string[];
110
+ mainIndex: SiteIndex;
111
+ mediaManifest?: MediaManifest;
112
+ }
113
+
92
114
  interface Props {
93
115
  headSha: string;
94
116
  // Head of the branch the editor actually loads (draft `saved` if it exists,
@@ -110,6 +132,11 @@ interface Props {
110
132
  // Controls the configurator's banner + test panel; the config UI itself
111
133
  // always works so editors can prepare before activation.
112
134
  chatbotPlatformEnabled?: boolean;
135
+ // Server-minted collaboration bootstrap (Task 10 wires this through the edit
136
+ // page). Null/absent → solo mode: the provider registry has no provider, or
137
+ // this session can't join, so useCollabSession stays offline and every
138
+ // lock/broadcast call is a no-op.
139
+ collabBootstrap?: CollabBootstrap | null;
113
140
  }
114
141
 
115
142
  export default function EditorShell({
@@ -120,6 +147,7 @@ export default function EditorShell({
120
147
  capabilities,
121
148
  currentUser,
122
149
  chatbotPlatformEnabled = false,
150
+ collabBootstrap,
123
151
  }: Props) {
124
152
  const [shellState, setShellState] = useState<ShellState>({ phase: "loading-content" });
125
153
  const [sections, setSections] = useState<LoadedSection[]>([]);
@@ -130,7 +158,7 @@ export default function EditorShell({
130
158
  const [showSiteSettings, setShowSiteSettings] = useState(false);
131
159
  const [dirtySectionIds, setDirtySectionIds] = useState<Set<string>>(new Set());
132
160
  const [siteConfig, setSiteConfig] = useState<SiteConfig | null>(null);
133
- const [mediaManifest, setMediaManifest] = useState<MediaManifest>({ images: {} });
161
+ const [mediaManifest, setMediaManifest] = useState<MediaManifest>({ images: {}, folders: {} });
134
162
  const [showMediaLibrary, setShowMediaLibrary] = useState(false);
135
163
  const [mediaModalMode, setMediaModalMode] = useState<"select" | "manage">("manage");
136
164
  const [mediaSelectCallback, setMediaSelectCallback] = useState<((id: string) => void) | null>(null);
@@ -146,6 +174,13 @@ export default function EditorShell({
146
174
  }, [savedSha]);
147
175
  const [changedSectionIds, setChangedSectionIds] = useState<Set<string>>(new Set());
148
176
  const [mainIndex, setMainIndex] = useState<SiteIndex | null>(null);
177
+ // Ref mirrors of the diff state, so save-time callbacks (which run inside a
178
+ // stale handleSave closure) and recoverConflict's synchronous post-rebase
179
+ // bookkeeping always read/write the latest values.
180
+ const changedSectionIdsRef = useRef<Set<string>>(new Set());
181
+ useEffect(() => { changedSectionIdsRef.current = changedSectionIds; }, [changedSectionIds]);
182
+ const mainIndexRef = useRef<SiteIndex | null>(null);
183
+ useEffect(() => { mainIndexRef.current = mainIndex; }, [mainIndex]);
149
184
  const [viewSections, setViewSections] = useState<LoadedSection[] | null>(null);
150
185
  const [showRestoreModal, setShowRestoreModal] = useState(false);
151
186
  const [showOrderingModal, setShowOrderingModal] = useState(false);
@@ -157,8 +192,21 @@ export default function EditorShell({
157
192
  // upload-intent failures, direct-to-bucket PUT failures) in the media
158
193
  // library modal, alongside its existing client-side rejection banner.
159
194
  const [uploadErrorMessage, setUploadErrorMessage] = useState<string | null>(null);
195
+ // Surfaces useMediaPipeline's onUploadNotice (e.g. "N files already exist
196
+ // and were left where they are" on dedup skip) in the media library modal.
197
+ const [uploadNotice, setUploadNotice] = useState<string | null>(null);
160
198
  const [activePageId, setActivePageId] = useState<string | null>(null);
161
199
  const [movingSectionId, setMovingSectionId] = useState<string | null>(null);
200
+ // Set by recoverConflict when a rebase surfaces overlapping edits; drives the
201
+ // keep-mine/take-theirs dialog. `resolve` is the promise resolver recovery
202
+ // awaits, so the dialog's Continue hands the choices straight back.
203
+ const [conflictState, setConflictState] = useState<
204
+ { items: ConflictItem[]; resolve: (choices: Record<string, "mine" | "theirs">) => void } | null
205
+ >(null);
206
+ // Collaboration: transient toast ("Drew took over Hero …") and the section a
207
+ // takeover confirmation is pending for.
208
+ const [remoteNotice, setRemoteNotice] = useState<string | null>(null);
209
+ const [pendingTakeoverId, setPendingTakeoverId] = useState<string | null>(null);
162
210
 
163
211
  const siteIndexRef = useRef<SiteIndex>({ siteId, pages: [], sections: {} });
164
212
  // Ids of sections that exist on the remote (loaded from server, or persisted by
@@ -171,7 +219,51 @@ export default function EditorShell({
171
219
  useEffect(() => { siteIndexRef.current = siteIndex; }, [siteIndex]);
172
220
  useEffect(() => { void ensureSanitizer(); }, []);
173
221
 
174
- const persistence = useEditorPersistence(siteIndexRef);
222
+ // --- Collaboration refs ---
223
+ // Freshest dirty-section set for the inbound remote-section handler (never
224
+ // apply a peer's edit over our own unsaved work).
225
+ const dirtySectionIdsRef = useRef<Set<string>>(new Set());
226
+ useEffect(() => { dirtySectionIdsRef.current = dirtySectionIds; }, [dirtySectionIds]);
227
+ // Toggled by useEditorPublish around a save; the remote saved-event handler
228
+ // defers while it is true (our own save's response path owns the state).
229
+ const inFlightSaveRef = useRef(false);
230
+ // Mirror of the live collab session, read by handlers/callbacks defined before
231
+ // useCollabSession is instantiated (breaks the definition-order cycle).
232
+ const collabRef = useRef<UseCollabSessionResult | null>(null);
233
+ const noticeTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
234
+ useEffect(() => () => { if (noticeTimerRef.current) clearTimeout(noticeTimerRef.current); }, []);
235
+ // Per-section memoized onEditIntent closures — SectionWrapper re-binds its
236
+ // focus listeners whenever onEditIntent's identity changes, so each section
237
+ // must get a STABLE function instance across renders.
238
+ const editIntentByIdRef = useRef(new Map<string, () => Promise<boolean>>());
239
+ // The section the local user last expressed edit intent on. Drives the
240
+ // focus-move lock release: when intent moves to a new section, the previous
241
+ // one's lock is released if it carries no unsaved work.
242
+ const focusedSectionIdRef = useRef<string | null>(null);
243
+
244
+ // Transient collaboration toast (single slot, 8s auto-dismiss).
245
+ const showRemoteNotice = useCallback((text: string) => {
246
+ setRemoteNotice(text);
247
+ if (noticeTimerRef.current) clearTimeout(noticeTimerRef.current);
248
+ noticeTimerRef.current = setTimeout(() => setRemoteNotice(null), 8000);
249
+ }, []);
250
+
251
+ // Broadcast piggyback: after a debounced flush, forward held-lock section
252
+ // edits + index changes to peers. Only sections THIS editor holds are
253
+ // broadcast (peers ignore updates for sections they don't see us holding).
254
+ const onFlush = useCallback(
255
+ (entries: { sectionId: string; content: SectionContent }[], indexFlushed: boolean) => {
256
+ const collab = collabRef.current;
257
+ if (!collab) return;
258
+ for (const { sectionId, content } of entries) {
259
+ if (collab.heldByMe.has(sectionId)) collab.broadcastSectionUpdate(sectionId, content);
260
+ }
261
+ if (indexFlushed) collab.broadcastIndexUpdate(siteIndexRef.current);
262
+ },
263
+ [],
264
+ );
265
+
266
+ const persistence = useEditorPersistence(siteIndexRef, { onFlush });
175
267
 
176
268
  const sectionsById = useMemo(() => {
177
269
  const map: Record<string, LoadedSection> = {};
@@ -228,10 +320,354 @@ export default function EditorShell({
228
320
  setDirtySectionIds,
229
321
  markSectionDirty: persistence.markSectionDirty,
230
322
  onUploadError: (message) => setUploadErrorMessage(message),
323
+ onUploadNotice: setUploadNotice,
231
324
  });
232
325
 
233
326
  const buildStatus = useBuildStatus();
234
327
 
328
+ // Fetch + parse the fresh saved-branch snapshot. Shared by the initial
329
+ // loadContent and by recoverConflict (409 auto-recovery) so both read the
330
+ // draft head the same way.
331
+ const fetchSavedContent = useCallback(async (): Promise<SavedContent> => {
332
+ const response = await fetch("/api/content?branch=saved");
333
+ if (!response.ok) throw new Error(`Failed to load content: ${response.status}`);
334
+ const data = await response.json();
335
+ return {
336
+ sha: data.sha,
337
+ sections: data.sections as LoadedSection[],
338
+ index: data.index as SiteIndex,
339
+ siteConfig: data.siteConfig as Record<string, unknown>,
340
+ savedBranchSha: data.savedBranchSha ?? null,
341
+ changedSectionIds: (data.changedSectionIds ?? []) as string[],
342
+ mainIndex: (data.mainIndex ?? data.index) as SiteIndex,
343
+ mediaManifest: data.mediaManifest as MediaManifest | undefined,
344
+ };
345
+ }, []);
346
+
347
+ // 409 auto-recovery: another editor moved the draft head. Re-fetch the fresh
348
+ // saved branch, rebase local unsaved work on top, resolve any true overlaps
349
+ // via the keep-mine/take-theirs dialog, refresh the optimistic-concurrency
350
+ // base, and report success so the save retries once. The decision logic lives
351
+ // in the pure collab/recovery + collab/rebase modules; this function is the
352
+ // IO glue (fetch, Dexie, dialog, setState).
353
+ const recoverConflict = useCallback(async (): Promise<boolean> => {
354
+ try {
355
+ const remote = await fetchSavedContent();
356
+ const dirtyRows = await getDirtySectionRows();
357
+ const dirtyIds = new Set(dirtyRows.map((r) => r.sectionId));
358
+
359
+ // Clean baseline = the last agreed-with-server snapshot in the Dexie
360
+ // contentCache — NOT sectionsRef.current, which carries local dirty edits.
361
+ // The cache is re-primed at every lifecycle point (load, successful save
362
+ // via onBaselineCommitted, and below on every recovery), so it is present
363
+ // whenever a 409 can happen; if it is somehow missing,
364
+ // deriveRecoveryInputs degrades conservatively (dialog, never a silent
365
+ // local-wins merge).
366
+ const cached = await getCachedContent();
367
+ const { remoteChangedIds, locallyAddedIds } = deriveRecoveryInputs({
368
+ baseline: cached ? { sections: cached.sections, index: cached.index } : null,
369
+ remote: { sections: remote.sections, index: remote.index },
370
+ dirtyRows,
371
+ deletedIds: deletedSections,
372
+ });
373
+
374
+ const result = rebaseContent({
375
+ localSections: sectionsRef.current,
376
+ localIndex: siteIndexRef.current,
377
+ localIndexDirty: true, // conservative: preserves any local structural work
378
+ dirtySectionIds: dirtyIds,
379
+ locallyAddedIds,
380
+ locallyDeletedIds: deletedSections,
381
+ remoteSections: remote.sections,
382
+ remoteIndex: remote.index,
383
+ // rebaseContent self-detects remote deletions of dirty sections, so we
384
+ // only fold in remote CONTENT changes here (per Task 4 contract).
385
+ remoteChangedIds,
386
+ });
387
+
388
+ let mergedSections = result.sections;
389
+ let mergedIndex = result.index;
390
+ if (result.conflictIds.length > 0) {
391
+ const choices = await new Promise<Record<string, "mine" | "theirs">>((resolve) => {
392
+ setConflictState({
393
+ items: result.conflictIds.map((id) => ({
394
+ sectionId: id,
395
+ label: getSection(siteIndexRef.current.sections[id]?.type ?? "")?.label ?? id,
396
+ })),
397
+ resolve,
398
+ });
399
+ });
400
+ setConflictState(null);
401
+ const applied = applyConflictChoices({
402
+ merged: { sections: mergedSections, index: mergedIndex },
403
+ remoteSections: remote.sections,
404
+ choices,
405
+ });
406
+ mergedSections = applied.sections;
407
+ mergedIndex = applied.index;
408
+ for (const p of applied.preserved) {
409
+ // The local copy is always snapshotted — no choice is destructive.
410
+ await preserveEdit(p.sectionId, p.section as unknown as SectionContent, "conflict");
411
+ }
412
+ // Drop the dirty rows "theirs" overrode so the retry doesn't re-send them.
413
+ if (applied.droppedDirtyIds.length > 0) await deleteSectionRows(applied.droppedDirtyIds);
414
+ }
415
+
416
+ setSections(mergedSections);
417
+ setSiteIndex(mergedIndex);
418
+ setSavedSha(remote.savedBranchSha);
419
+ setChangedSectionIds(new Set(remote.changedSectionIds));
420
+ setMainIndex(remote.mainIndex);
421
+ // Refresh the refs synchronously: getBaseVersion reads savedShaRef and
422
+ // performSave reads siteIndexRef + the dirty rows on the IMMEDIATE retry
423
+ // (and onBaselineCommitted reads the diff refs right after it), before
424
+ // React flushes the setState calls above into their mirror effects.
425
+ savedShaRef.current = remote.savedBranchSha;
426
+ sectionsRef.current = mergedSections;
427
+ siteIndexRef.current = mergedIndex;
428
+ changedSectionIdsRef.current = new Set(remote.changedSectionIds);
429
+ mainIndexRef.current = remote.mainIndex;
430
+ remoteSectionIdsRef.current = new Set(mergedIndex.pages.flatMap((p) => p.order));
431
+ // C2: the cache is the CLEAN baseline — always the fetched remote
432
+ // snapshot, never the rebased result. Caching the rebase (local dirty
433
+ // overlays folded in) would poison the next recovery's diff: dirty
434
+ // sections would read as remote-unchanged (silent local-wins) and a
435
+ // locally-added section would read as "deleted remotely".
436
+ await cacheContent(
437
+ remote.sha ?? remote.savedBranchSha ?? headSha,
438
+ remote.sections,
439
+ remote.index,
440
+ remote.siteConfig,
441
+ { savedBranchSha: remote.savedBranchSha, changedSectionIds: remote.changedSectionIds, mainIndex: remote.mainIndex },
442
+ );
443
+ return true;
444
+ } catch (err) {
445
+ console.error("Conflict recovery failed:", err);
446
+ return false;
447
+ }
448
+ }, [fetchSavedContent, deletedSections, headSha]);
449
+
450
+ // --- Collaboration: inbound saved-event rebase + takeover ---
451
+
452
+ // A peer committed a save to the draft branch. Re-fetch the fresh saved
453
+ // snapshot and rebase local unsaved work under it — the SAME machinery as the
454
+ // 409 recovery, but from a background event, so it NEVER opens the conflict
455
+ // dialog: local dirty work wins for any conflictIds (rebaseContent keeps the
456
+ // local copies), and the next interactive save's 409 recovery arbitrates.
457
+ const handleRemoteSaved = useCallback(async (msg: SavedMessage): Promise<void> => {
458
+ // Bail conditions, re-checked after EVERY await: (a) our own save is in
459
+ // flight — its response path owns state, and a stale refetch landing
460
+ // mid-save would regress savedShaRef/baseline and force spurious 409s over
461
+ // the user's own work; (b) own-save echo — the server fans our save back to
462
+ // us, but savedShaRef already points at msg.newSha, so there is nothing to
463
+ // sync (and refetching would be wasted work racing the post-save cleanup).
464
+ const stale = () => inFlightSaveRef.current || savedShaRef.current === msg.newSha;
465
+ if (stale()) return;
466
+ try {
467
+ const remote = await fetchSavedContent();
468
+ if (stale()) return;
469
+ const dirtyRows = await getDirtySectionRows();
470
+ if (stale()) return;
471
+ // Clean baseline from the content cache (never sectionsRef, which carries
472
+ // dirty overlays). deriveRecoveryInputs computes BOTH remoteChangedIds and
473
+ // locallyAddedIds; rebaseContent self-detects remote deletions.
474
+ const cached = await getCachedContent();
475
+ // Last await above — one final check guards the whole synchronous
476
+ // setState + cacheContent block below.
477
+ if (stale()) return;
478
+ // Dirty = Dexie rows ∪ the synchronously-maintained state mirror ∪ held
479
+ // locks: a just-typed edit can lag the Dexie rows by up to the 500ms
480
+ // flush debounce, and anything we hold must never be overwritten by a
481
+ // background sync. The rebase reads content from sectionsRef, which
482
+ // already has the freshest keystrokes.
483
+ const dirtyIds = new Set([
484
+ ...dirtyRows.map((r) => r.sectionId),
485
+ ...dirtySectionIdsRef.current,
486
+ ...(collabRef.current?.heldByMe ?? []),
487
+ ]);
488
+ const { remoteChangedIds, locallyAddedIds } = deriveRecoveryInputs({
489
+ baseline: cached ? { sections: cached.sections, index: cached.index } : null,
490
+ remote: { sections: remote.sections, index: remote.index },
491
+ dirtyRows: [...dirtyIds].map((sectionId) => ({ sectionId })),
492
+ deletedIds: deletedSections,
493
+ });
494
+ const result = rebaseContent({
495
+ localSections: sectionsRef.current,
496
+ localIndex: siteIndexRef.current,
497
+ // Durable unsaved-structure signal — NOT isIndexDirty(), which clears
498
+ // ~500ms after the edit (flush-pending only) and would hand a remote
499
+ // save the local structural work on a plate.
500
+ localIndexDirty: persistence.hasUnsavedIndex(),
501
+ dirtySectionIds: dirtyIds,
502
+ locallyAddedIds,
503
+ locallyDeletedIds: deletedSections,
504
+ remoteSections: remote.sections,
505
+ remoteIndex: remote.index,
506
+ remoteChangedIds,
507
+ });
508
+
509
+ // The rebased result goes into React state (live view)...
510
+ setSections(result.sections);
511
+ setSiteIndex(result.index);
512
+ setSavedSha(remote.savedBranchSha);
513
+ setChangedSectionIds(new Set(remote.changedSectionIds));
514
+ setMainIndex(remote.mainIndex);
515
+ sectionsRef.current = result.sections;
516
+ siteIndexRef.current = result.index;
517
+ savedShaRef.current = remote.savedBranchSha;
518
+ changedSectionIdsRef.current = new Set(remote.changedSectionIds);
519
+ mainIndexRef.current = remote.mainIndex;
520
+ remoteSectionIdsRef.current = new Set(result.index.pages.flatMap((p) => p.order));
521
+
522
+ // ...but the CLEAN fetched remote snapshot is what gets cached as the
523
+ // baseline (amendment 1: caching the rebased result would poison the next
524
+ // 409 recovery — dirty sections would read as remote-unchanged and a ghost
525
+ // race would never surface).
526
+ await cacheContent(
527
+ remote.sha ?? remote.savedBranchSha ?? headSha,
528
+ remote.sections,
529
+ remote.index,
530
+ remote.siteConfig,
531
+ { savedBranchSha: remote.savedBranchSha, changedSectionIds: remote.changedSectionIds, mainIndex: remote.mainIndex },
532
+ );
533
+ } catch (err) {
534
+ console.error("Remote saved-event sync failed:", err);
535
+ }
536
+ }, [fetchSavedContent, deletedSections, persistence, headSha]);
537
+
538
+ // A peer stole this section's lock. Snapshot the local edit (recoverable),
539
+ // drop the dirty row so it isn't re-sent, and surface a toast.
540
+ const handleTakenOver = useCallback(async (msg: TakeoverMessage): Promise<void> => {
541
+ const local = sectionsRef.current.find((s) => s.section.id === msg.sectionId);
542
+ if (local) await preserveEdit(msg.sectionId, local.section as unknown as SectionContent, "takeover");
543
+ // Drop BOTH stores of the victim's edit: the persisted Dexie row AND the
544
+ // un-flushed in-memory pending entry — otherwise a keystroke typed just
545
+ // before the takeover re-persists on the next debounce flush and the next
546
+ // save re-sends stale content for a section we no longer hold.
547
+ persistence.dropPendingSection(msg.sectionId);
548
+ await deleteSectionRows([msg.sectionId]);
549
+ setDirtySectionIds((prev) => {
550
+ const next = new Set(prev);
551
+ next.delete(msg.sectionId);
552
+ return next;
553
+ });
554
+ const label = getSection(siteIndexRef.current.sections[msg.sectionId]?.type ?? "")?.label
555
+ ?? siteIndexRef.current.sections[msg.sectionId]?.type
556
+ ?? msg.sectionId;
557
+ showRemoteNotice(`${msg.byEmail} took over "${label}" — your unsaved edits to it were safely stored.`);
558
+ }, [persistence, showRemoteNotice]);
559
+
560
+ const collab = useCollabSession({
561
+ provider: getCollabProvider(),
562
+ bootstrap: collabBootstrap ?? null,
563
+ onRemoteSectionUpdate: (msg) => {
564
+ // Ephemeral live view: never touch Dexie, never apply over own dirty work
565
+ // or a section we hold.
566
+ if (dirtySectionIdsRef.current.has(msg.sectionId)) return;
567
+ if (collabRef.current?.heldByMe.has(msg.sectionId)) return;
568
+ const raw = msg.content;
569
+ // z.unknown() passes when the key is absent — guard undefined/null/non-object.
570
+ if (raw === undefined || raw === null || typeof raw !== "object") return;
571
+ const type = (raw as { type?: unknown }).type;
572
+ if (typeof type !== "string") return;
573
+ const schema = getSchema(type);
574
+ if (!schema) return;
575
+ const parsed = schema.safeParse(raw);
576
+ if (!parsed.success) return;
577
+ const validated = parsed.data as Record<string, unknown>;
578
+ setSections((prev) => prev.map((s) =>
579
+ s.section.id === msg.sectionId ? { ...s, section: { ...s.section, ...validated } } : s,
580
+ ));
581
+ },
582
+ onRemoteIndexUpdate: (msg) => {
583
+ // Ignore inbound indexes until the initial load settles: before
584
+ // loadContent finishes, sectionsRef.current is [] so the prune below would
585
+ // strip every order to empty and momentarily install an empty-order index.
586
+ if (shellState.phase !== "ready") return;
587
+ // While we have unsaved structural work, the save-time rebase resolves
588
+ // the overlap — don't clobber local structure with the peer's. Durable
589
+ // signal (survives the flush debounce), not isIndexDirty().
590
+ if (persistence.hasUnsavedIndex()) return;
591
+ const parsed = IndexSchema.safeParse(msg.index);
592
+ if (!parsed.success) return;
593
+ // PRUNE ids we hold no content for before applying. An id in the peer's
594
+ // index with no counterpart in our local sections state is either (a) the
595
+ // peer's unsaved ADD — its section file is not on the branch yet, so
596
+ // letting it into siteIndexRef would poison OUR next save with a dangling
597
+ // index ref the server rejects (live new-section appearance is deliberately
598
+ // deferred to the peer's save, which arrives via the saved-event sync); or
599
+ // (b) a section WE deleted unsaved — our deletion stands until save-time
600
+ // arbitration.
601
+ const localIds = new Set(sectionsRef.current.map((s) => s.section.id));
602
+ const pruned: SiteIndex = {
603
+ ...parsed.data,
604
+ pages: parsed.data.pages.map((p) => ({ ...p, order: p.order.filter((id) => localIds.has(id)) })),
605
+ sections: Object.fromEntries(
606
+ Object.entries(parsed.data.sections).filter(([id]) => localIds.has(id)),
607
+ ),
608
+ };
609
+ setSiteIndex(pruned);
610
+ siteIndexRef.current = pruned;
611
+ },
612
+ onRemoteSaved: (msg) => { void handleRemoteSaved(msg); },
613
+ onTakenOver: (msg) => { void handleTakenOver(msg); },
614
+ });
615
+ useEffect(() => { collabRef.current = collab; }, [collab]);
616
+
617
+ // Stable per-section onEditIntent: focus + lock request. The cached closure
618
+ // always calls the LIVE session via collabRef, so it needn't change identity.
619
+ const getEditIntent = useCallback((id: string): (() => Promise<boolean>) => {
620
+ const cache = editIntentByIdRef.current;
621
+ let fn = cache.get(id);
622
+ if (!fn) {
623
+ fn = async () => {
624
+ const session = collabRef.current;
625
+ // Focus-move release: browse-clicking must not accumulate locks that
626
+ // block peers on sections we never edited. Release the previously
627
+ // focused section's lock IF it carries no unsaved work — dirty state,
628
+ // an un-flushed keystroke, or a local add all keep the lock held.
629
+ const prev = focusedSectionIdRef.current;
630
+ if (
631
+ prev && prev !== id && session?.heldByMe.has(prev) &&
632
+ !dirtySectionIdsRef.current.has(prev) &&
633
+ !persistence.hasPendingSection(prev)
634
+ ) {
635
+ session.releaseAfterSave([prev]);
636
+ }
637
+ focusedSectionIdRef.current = id;
638
+ session?.setFocus(id);
639
+ const ok = (await session?.requestEdit(id)) ?? true;
640
+ // A denial with no visible lock badge is the ghost-lock window: the
641
+ // holder's presence dropped but their editor_locks row is still fresh
642
+ // (<45s). Tell the user instead of failing silently; no further
643
+ // blocking — the row goes stale within 45s and the save-time 409
644
+ // arbitration remains the safety net.
645
+ if (!ok) showRemoteNotice("Someone else is editing this section — try again in a moment.");
646
+ return ok;
647
+ };
648
+ cache.set(id, fn);
649
+ }
650
+ return fn;
651
+ }, [persistence, showRemoteNotice]);
652
+
653
+ const confirmTakeover = useCallback((id: string) => setPendingTakeoverId(id), []);
654
+ const performTakeover = useCallback(async () => {
655
+ const id = pendingTakeoverId;
656
+ setPendingTakeoverId(null);
657
+ if (!id) return;
658
+ const ok = await collabRef.current?.takeover(id);
659
+ if (!ok) return;
660
+ // The stolen section's latest broadcast content is already in local state
661
+ // (applied as remote-ephemeral). Mark it dirty so it persists and the
662
+ // lock-hold broadcast rule applies.
663
+ const local = sectionsRef.current.find((s) => s.section.id === id);
664
+ if (!local) return;
665
+ const { id: _omit, ...content } = local.section;
666
+ persistence.markSectionDirty(id, content as SectionContent);
667
+ setLocalChangesExist(true);
668
+ setDirtySectionIds((prev) => new Set(prev).add(id));
669
+ }, [pendingTakeoverId, persistence]);
670
+
235
671
  const { publishAction, publishFeedback, saveProgress, handleSave, handlePublish, handleSaveAndPublish } = useEditorPublish({
236
672
  flushNow: persistence.flushNow,
237
673
  cancelPendingFlush: persistence.cancelPendingFlush,
@@ -241,12 +677,37 @@ export default function EditorShell({
241
677
  siteConfig,
242
678
  sections,
243
679
  deletedSectionIds: Array.from(deletedSections),
244
- onSuccess: () => {
245
- setDirtySectionIds(new Set());
246
- setDeletedSections(new Set());
247
- setLocalChangesExist(false);
680
+ onSuccess: (savedSectionIds: string[], { editToken, sentDeletedIds }) => {
681
+ // Generation-guarded post-save clears: any edit made WHILE the save was
682
+ // in flight (generation > the token captured at save begin) was NOT
683
+ // carried by it and must survive every clear below — the in-memory
684
+ // mirror of discardSavedChanges' updatedAt compare-and-clear.
685
+ setDirtySectionIds((prev) =>
686
+ new Set([...prev].filter((id) => persistence.sectionDirtiedAfter(id, editToken))));
687
+ // Clear only the deletions this save actually sent; one scheduled
688
+ // mid-save must ride the next save.
689
+ const sentDeleted = new Set(sentDeletedIds);
690
+ setDeletedSections((prev) => new Set([...prev].filter((id) => !sentDeleted.has(id))));
691
+ // The committed index is no longer unsaved structure (mirrors
692
+ // discardSavedChanges clearing the Dexie siteIndex row just before this)
693
+ // — UNLESS a structural edit landed mid-save: then the clear no-ops and
694
+ // we re-mark, because discardSavedChanges wiped the Dexie siteIndex row
695
+ // wholesale and the surviving live structure must re-persist.
696
+ persistence.clearUnsavedIndex(editToken);
697
+ if (persistence.hasUnsavedIndex()) persistence.markIndexDirty();
698
+ const survivorsExist =
699
+ [...dirtySectionIdsRef.current].some((id) => persistence.sectionDirtiedAfter(id, editToken)) ||
700
+ persistence.hasUnsavedIndex();
701
+ setLocalChangesExist(survivorsExist);
248
702
  // Everything in the index now exists remotely (it was just saved).
249
703
  remoteSectionIdsRef.current = new Set(siteIndexRef.current.pages.flatMap((p) => p.order));
704
+ // Release the locks we held on the committed sections — EXCEPT any
705
+ // re-dirtied mid-save (they carry unsent work; keep holding). The server
706
+ // fans the saved-event out to peers (Task 10), so no client broadcast here.
707
+ collabRef.current?.releaseAfterSave(
708
+ savedSectionIds.filter((id) => !persistence.sectionDirtiedAfter(id, editToken)));
709
+ collabRef.current?.setFocus(null);
710
+ focusedSectionIdRef.current = null;
250
711
  },
251
712
  mediaManifest,
252
713
  manifestDirty: mediaPipeline.manifestDirty,
@@ -280,7 +741,7 @@ export default function EditorShell({
280
741
  for (const id of publishedDeletions) {
281
742
  delete images[id];
282
743
  }
283
- return { images };
744
+ return { ...prev, images };
284
745
  });
285
746
  },
286
747
  onShasUpdated: (newSavedSha, newMainSha) => {
@@ -289,6 +750,27 @@ export default function EditorShell({
289
750
  },
290
751
  onPublishComplete: buildStatus.startTracking,
291
752
  getBaseVersion: () => savedShaRef.current,
753
+ recoverConflict,
754
+ // C1: re-prime the clean content-cache baseline after every successful plain
755
+ // save (discardSavedChanges just cleared it). The hook hands us the COMMITTED
756
+ // snapshot (sent content + sent index); we add the diff bookkeeping: the
757
+ // just-saved sections now differ from main, so fold them into
758
+ // changedSectionIds (state + ref + cache stay consistent, and a reload that
759
+ // hits this cache shows the same badges the server would report).
760
+ onBaselineCommitted: async ({ sha, sections: baselineSections, index, savedIds }) => {
761
+ const changed = new Set(
762
+ [...changedSectionIdsRef.current, ...savedIds].filter((id) => id in index.sections),
763
+ );
764
+ changedSectionIdsRef.current = changed;
765
+ setChangedSectionIds(changed);
766
+ await cacheContent(sha, baselineSections, index, (siteConfig ?? {}) as Record<string, unknown>, {
767
+ savedBranchSha: sha,
768
+ changedSectionIds: [...changed],
769
+ mainIndex: mainIndexRef.current,
770
+ });
771
+ },
772
+ savingRef: inFlightSaveRef,
773
+ getEditGeneration: persistence.currentGeneration,
292
774
  });
293
775
 
294
776
  const { buttonState } = useContentLifecycle({
@@ -388,7 +870,7 @@ export default function EditorShell({
388
870
  let loadedSections: LoadedSection[];
389
871
  let loadedIndex: SiteIndex;
390
872
  let loadedConfig: SiteConfig;
391
- let loadedManifest: MediaManifest = { images: {} };
873
+ let loadedManifest: MediaManifest = { images: {}, folders: {} };
392
874
 
393
875
  // Key the content cache on the head of the branch we actually load (the
394
876
  // draft `saved` branch if it exists, else main), provided by the SSR edit
@@ -417,25 +899,23 @@ export default function EditorShell({
417
899
  setChangedSectionIds(new Set(cached.changedSectionIds ?? []));
418
900
  setMainIndex(cached.mainIndex ?? loadedIndex);
419
901
  } else {
420
- const response = await fetch("/api/content?branch=saved");
421
- if (!response.ok) throw new Error(`Failed to load content: ${response.status}`);
422
- const data = await response.json();
902
+ const data = await fetchSavedContent();
423
903
  loadedSections = data.sections;
424
904
  loadedIndex = data.index;
425
905
  loadedConfig = SiteConfigSchema.parse(data.siteConfig);
426
906
  await cacheContent(data.sha, data.sections, data.index, data.siteConfig, {
427
- savedBranchSha: data.savedBranchSha ?? null,
428
- changedSectionIds: data.changedSectionIds ?? [],
429
- mainIndex: data.mainIndex ?? data.index,
907
+ savedBranchSha: data.savedBranchSha,
908
+ changedSectionIds: data.changedSectionIds,
909
+ mainIndex: data.mainIndex,
430
910
  });
431
911
  if (data.mediaManifest) {
432
- loadedManifest = data.mediaManifest as MediaManifest;
912
+ loadedManifest = data.mediaManifest;
433
913
  await persistMediaManifest(loadedManifest);
434
914
  }
435
- setSavedSha(data.savedBranchSha ?? null);
915
+ setSavedSha(data.savedBranchSha);
436
916
  setMainSha(headSha);
437
- setChangedSectionIds(new Set(data.changedSectionIds ?? []));
438
- setMainIndex(data.mainIndex ?? data.index);
917
+ setChangedSectionIds(new Set(data.changedSectionIds));
918
+ setMainIndex(data.mainIndex);
439
919
  }
440
920
 
441
921
  if (cancelled) return;
@@ -490,7 +970,7 @@ export default function EditorShell({
490
970
  setShellState({ phase: "error", message: err instanceof Error ? err.message : "Failed to load content" });
491
971
  });
492
972
  return () => { cancelled = true; };
493
- }, [headSha, draftHeadSha, siteId, applySiteConfigPreview]);
973
+ }, [headSha, draftHeadSha, siteId, applySiteConfigPreview, fetchSavedContent]);
494
974
 
495
975
  // --- Recovery handlers ---
496
976
 
@@ -525,6 +1005,10 @@ export default function EditorShell({
525
1005
  [...remoteSectionIdsRef.current].filter((id) => !restoredIds.has(id)),
526
1006
  );
527
1007
  setDeletedSections(rediscoveredDeletions);
1008
+ // A restored Dexie siteIndex row IS unsaved structural work from the
1009
+ // prior session — prime the durable signal even though no markIndexDirty
1010
+ // ran this session.
1011
+ persistence.noteUnsavedIndex();
528
1012
  } else {
529
1013
  setSections((prev) =>
530
1014
  prev.map((loaded) => {
@@ -576,8 +1060,11 @@ export default function EditorShell({
576
1060
  }
577
1061
  }
578
1062
  const savedManifest = await getMediaManifest();
579
- setMediaManifest(savedManifest ?? { images: {} });
1063
+ setMediaManifest(savedManifest ?? { images: {}, folders: {} });
580
1064
  persistence.clearConfigDirty();
1065
+ // Discard wiped the Dexie siteIndex row — no unsaved structure remains.
1066
+ // Tokenless: discard is unconditional, unlike the save-success clear.
1067
+ persistence.clearUnsavedIndex();
581
1068
  setShellState({ phase: "ready" });
582
1069
  }, [applySiteConfigPreview, persistence]);
583
1070
 
@@ -623,6 +1110,12 @@ export default function EditorShell({
623
1110
  persistence.markIndexDirty();
624
1111
  setLocalChangesExist(true);
625
1112
  setDirtySectionIds((prev) => new Set(prev).add(id));
1113
+ // Spec: new sections are creator-locked. Acquiring the lock here (not on
1114
+ // first focus) protects the fresh id against concurrent MCP writes via
1115
+ // the locks table and lets its content ride the held-lock broadcast
1116
+ // (peers drop it until our save lands — live new-section appearance is
1117
+ // save-time sync by design).
1118
+ void collabRef.current?.requestEdit(id);
626
1119
  },
627
1120
  [persistence, resolvedActivePage],
628
1121
  );
@@ -902,6 +1395,9 @@ export default function EditorShell({
902
1395
  onOrderingClick={() => setShowOrderingModal(true)}
903
1396
  onPagesClick={() => setShowPagesModal(true)}
904
1397
  onChatbotClick={() => setShowChatbotConfig(true)}
1398
+ collabPeers={collab.peers}
1399
+ collabSelfId={collabBootstrap?.userId ?? null}
1400
+ collabStatus={collab.status}
905
1401
  />
906
1402
 
907
1403
  {/* Bug reports persist via Supabase. On the password-only (zero-Supabase)
@@ -932,6 +1428,15 @@ export default function EditorShell({
932
1428
  </div>
933
1429
  )}
934
1430
 
1431
+ {remoteNotice && (
1432
+ <div
1433
+ role="status"
1434
+ className="fixed left-1/2 top-14 z-50 -translate-x-1/2 rounded-md border border-base-200 bg-base px-4 py-2 text-xs font-medium text-base-content shadow-lg"
1435
+ >
1436
+ {remoteNotice}
1437
+ </div>
1438
+ )}
1439
+
935
1440
  <HistoryOrEditorContent
936
1441
  index={siteIndex}
937
1442
  activeSections={activeSections}
@@ -954,6 +1459,9 @@ export default function EditorShell({
954
1459
  mainIndex={mainIndex}
955
1460
  changedSectionIds={changedSectionIds}
956
1461
  viewSections={viewSections}
1462
+ locks={collab.locks}
1463
+ getEditIntent={getEditIntent}
1464
+ onTakeover={confirmTakeover}
957
1465
  />
958
1466
  </HistoryOrEditorContent>
959
1467
  <GlobalModal />
@@ -1018,6 +1526,9 @@ export default function EditorShell({
1018
1526
  }}
1019
1527
  title="Media Library"
1020
1528
  size="large"
1529
+ // Padding lives on the two scroll columns inside MediaLibraryModal
1530
+ // so their scrollbars sit at the pane edges.
1531
+ noPadding
1021
1532
  >
1022
1533
  <MediaLibraryModal
1023
1534
  mode={mediaModalMode}
@@ -1041,6 +1552,15 @@ export default function EditorShell({
1041
1552
  uploadProgress={mediaPipeline.uploadProgress}
1042
1553
  uploadError={uploadErrorMessage}
1043
1554
  onDismissUploadError={() => setUploadErrorMessage(null)}
1555
+ folders={mediaManifest.folders}
1556
+ onCreateFolder={mediaPipeline.handleFolderCreate}
1557
+ onRenameFolder={mediaPipeline.handleFolderRename}
1558
+ onMoveFolder={mediaPipeline.handleFolderMove}
1559
+ onDeleteFolder={mediaPipeline.handleFolderDelete}
1560
+ onMoveMedia={mediaPipeline.handleMediaMove}
1561
+ onFolderUpload={mediaPipeline.handleFolderUpload}
1562
+ uploadNotice={uploadNotice}
1563
+ onDismissUploadNotice={() => setUploadNotice(null)}
1044
1564
  />
1045
1565
  </EditorModal>
1046
1566
  <EditorModal
@@ -1084,6 +1604,27 @@ export default function EditorShell({
1084
1604
  isRestoring={isRestoring}
1085
1605
  onRestore={handleHistoryRestore}
1086
1606
  />
1607
+ {conflictState && (
1608
+ <ConflictDialog conflicts={conflictState.items} onResolve={conflictState.resolve} />
1609
+ )}
1610
+ <EditorModal
1611
+ isOpen={pendingTakeoverId !== null}
1612
+ onClose={() => setPendingTakeoverId(null)}
1613
+ title="Take over section"
1614
+ >
1615
+ <p className="mb-4 text-sm text-base-contrast-light">
1616
+ Another editor is currently editing this section. Take over to edit it yourself?
1617
+ Their unsaved edits will be safely stored.
1618
+ </p>
1619
+ <div className="flex justify-end gap-3">
1620
+ <Button variant="secondary" size="md" onClick={() => setPendingTakeoverId(null)}>
1621
+ Cancel
1622
+ </Button>
1623
+ <Button variant="primary" size="md" onClick={performTakeover}>
1624
+ Take over
1625
+ </Button>
1626
+ </div>
1627
+ </EditorModal>
1087
1628
  </div>
1088
1629
  </MediaLibraryContext.Provider>
1089
1630
  </EditorModalProvider>
@@ -1228,6 +1769,9 @@ function EditorContent({
1228
1769
  mainIndex,
1229
1770
  changedSectionIds,
1230
1771
  viewSections,
1772
+ locks,
1773
+ getEditIntent,
1774
+ onTakeover,
1231
1775
  }: {
1232
1776
  sections: LoadedSection[];
1233
1777
  activePageSectionIds: string[];
@@ -1245,6 +1789,11 @@ function EditorContent({
1245
1789
  mainIndex: SiteIndex | null;
1246
1790
  changedSectionIds: Set<string>;
1247
1791
  viewSections: LoadedSection[] | null;
1792
+ // Collaboration: peers' held locks (keyed by sectionId, self excluded), a
1793
+ // stable per-section edit-intent factory, and the takeover confirm trigger.
1794
+ locks: Map<string, CollabPeer>;
1795
+ getEditIntent: (id: string) => () => Promise<boolean>;
1796
+ onTakeover: (id: string) => void;
1248
1797
  }) {
1249
1798
  const { isEditMode, viewBranch } = useEditorContext();
1250
1799
  const { openModal, closeModal } = useEditorModal();
@@ -1351,6 +1900,9 @@ function EditorContent({
1351
1900
  mainStatus={mainIndex?.sections[section.id]?.status ?? null}
1352
1901
  contentDiffersFromMain={changedSectionIds.has(section.id) || dirtySectionIds.has(section.id)}
1353
1902
  isLocalOnly={dirtySectionIds.has(section.id) && !changedSectionIds.has(section.id)}
1903
+ lockedBy={locks.get(section.id) ? { email: locks.get(section.id)!.email } : null}
1904
+ onTakeover={() => onTakeover(section.id)}
1905
+ onEditIntent={getEditIntent(section.id)}
1354
1906
  options={{
1355
1907
  ...(section.content as Record<string, unknown>),
1356
1908
  ...("options" in section ? (section.options as Record<string, unknown>) : {}),
@@ -1568,6 +2120,9 @@ function EditorToolbar({
1568
2120
  onOrderingClick,
1569
2121
  onPagesClick,
1570
2122
  onChatbotClick,
2123
+ collabPeers,
2124
+ collabSelfId,
2125
+ collabStatus,
1571
2126
  }: {
1572
2127
  buttonState: "synced" | "publish" | "saveAndPublish";
1573
2128
  localChangesExist: boolean;
@@ -1588,6 +2143,9 @@ function EditorToolbar({
1588
2143
  onOrderingClick: () => void;
1589
2144
  onPagesClick: () => void;
1590
2145
  onChatbotClick: () => void;
2146
+ collabPeers: CollabPeer[];
2147
+ collabSelfId: string | null;
2148
+ collabStatus: CollabStatus;
1591
2149
  }) {
1592
2150
  const { isEditMode, viewBranch, setViewBranch, toggleEditMode, historyState, setHistoryState } = useEditorContext();
1593
2151
 
@@ -1640,6 +2198,7 @@ function EditorToolbar({
1640
2198
  Discard Changes
1641
2199
  </Button>
1642
2200
  )}
2201
+ <PresenceAvatars peers={collabPeers} selfUserId={collabSelfId} status={collabStatus} />
1643
2202
  </div>
1644
2203
  <div className="flex items-center justify-center">
1645
2204
  <StatusText