@drawnagency/primitives 0.1.76 → 0.1.78
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/{chunk-Q3T23HVW.js → chunk-2DDWWTRX.js} +5 -5
- package/dist/chunk-2G2S36SY.js +13 -0
- package/dist/{chunk-S55OZ7F2.js → chunk-3LNKOLZP.js} +4 -0
- package/dist/{chunk-SWIHUIFT.js → chunk-4SIWBWKZ.js} +12 -2
- package/dist/{chunk-SWEVWC5K.js → chunk-BAQYZSFF.js} +22 -22
- package/dist/{chunk-IFHRHV7S.js → chunk-EMGLZQCM.js} +1 -1
- package/dist/{chunk-NKVZGPR6.js → chunk-K4JD6FD3.js} +1 -1
- package/dist/{chunk-SFKYUKVN.js → chunk-MK4POMLZ.js} +1 -1
- package/dist/{chunk-7T2WQSWF.js → chunk-NS3UTJ3E.js} +11 -11
- package/dist/chunk-REM2NH7K.js +44 -0
- package/dist/{chunk-T7MP7ANP.js → chunk-UA6OCJJK.js} +2 -2
- package/dist/{chunk-ZRWGV5O6.js → chunk-Z5IYWUW3.js} +3 -3
- package/dist/collab/provider-registry.d.ts +4 -0
- package/dist/collab/provider-registry.d.ts.map +1 -0
- package/dist/collab/provider-registry.js +9 -0
- package/dist/collab/rebase.d.ts +37 -0
- package/dist/collab/rebase.d.ts.map +1 -0
- package/dist/collab/rebase.js +64 -0
- package/dist/collab/recovery.d.ts +89 -0
- package/dist/collab/recovery.d.ts.map +1 -0
- package/dist/collab/types.d.ts +93 -0
- package/dist/collab/types.d.ts.map +1 -0
- package/dist/collab/types.js +19 -0
- package/dist/components/editor/AudienceIndicator.d.ts +3 -1
- package/dist/components/editor/AudienceIndicator.d.ts.map +1 -1
- package/dist/components/editor/ConflictDialog.d.ts +14 -0
- package/dist/components/editor/ConflictDialog.d.ts.map +1 -0
- package/dist/components/editor/IndicatorPill.d.ts +4 -1
- package/dist/components/editor/IndicatorPill.d.ts.map +1 -1
- package/dist/components/editor/LockBadge.d.ts +7 -0
- package/dist/components/editor/LockBadge.d.ts.map +1 -0
- package/dist/components/editor/PresenceAvatars.d.ts +9 -0
- package/dist/components/editor/PresenceAvatars.d.ts.map +1 -0
- package/dist/components/editor/SectionWrapper.d.ts +1 -1
- package/dist/components/editor/SectionWrapper.d.ts.map +1 -1
- package/dist/components/editor/StatusIndicator.d.ts +3 -1
- package/dist/components/editor/StatusIndicator.d.ts.map +1 -1
- package/dist/components/editor/index.js +95 -47
- package/dist/components/primitives/index.js +5 -5
- package/dist/components/sections/brand-guide.js +5 -5
- package/dist/components/sections/register-schemas.js +11 -11
- package/dist/components/sections/register.js +11 -11
- package/dist/components/shell/EditorShell.d.ts +3 -1
- package/dist/components/shell/EditorShell.d.ts.map +1 -1
- package/dist/components/shell/MediaBreadcrumb.d.ts +10 -0
- package/dist/components/shell/MediaBreadcrumb.d.ts.map +1 -0
- package/dist/components/shell/MediaFolderTile.d.ts +15 -0
- package/dist/components/shell/MediaFolderTile.d.ts.map +1 -0
- package/dist/components/shell/MediaFolderTree.d.ts +20 -0
- package/dist/components/shell/MediaFolderTree.d.ts.map +1 -0
- package/dist/components/shell/MediaLibraryModal.d.ts +15 -3
- package/dist/components/shell/MediaLibraryModal.d.ts.map +1 -1
- package/dist/components/shell/MoveToFolderDialog.d.ts +16 -0
- package/dist/components/shell/MoveToFolderDialog.d.ts.map +1 -0
- package/dist/hooks/useCollabSession.d.ts +23 -0
- package/dist/hooks/useCollabSession.d.ts.map +1 -0
- package/dist/hooks/useEditorPersistence.d.ts +21 -1
- package/dist/hooks/useEditorPersistence.d.ts.map +1 -1
- package/dist/hooks/useEditorPublish.d.ts +39 -2
- package/dist/hooks/useEditorPublish.d.ts.map +1 -1
- package/dist/hooks/useMediaFolderDnd.d.ts +27 -0
- package/dist/hooks/useMediaFolderDnd.d.ts.map +1 -0
- package/dist/hooks/useMediaPipeline.d.ts +14 -2
- package/dist/hooks/useMediaPipeline.d.ts.map +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +51 -25
- package/dist/lib/dexie.d.ts +12 -0
- package/dist/lib/dexie.d.ts.map +1 -1
- package/dist/lib/dexie.js +27 -0
- package/dist/lib/index.js +12 -12
- package/dist/lib/media-dnd.d.ts +26 -0
- package/dist/lib/media-dnd.d.ts.map +1 -0
- package/dist/lib/media-folders.d.ts +59 -0
- package/dist/lib/media-folders.d.ts.map +1 -0
- package/dist/lib/registry.d.ts +10 -0
- package/dist/lib/registry.d.ts.map +1 -1
- package/dist/media/folder-drop.d.ts +22 -0
- package/dist/media/folder-drop.d.ts.map +1 -0
- package/dist/media/index.js +6 -2
- package/dist/media/types.d.ts +1 -1
- package/dist/media/types.d.ts.map +1 -1
- package/dist/media/utils.d.ts +7 -0
- package/dist/media/utils.d.ts.map +1 -1
- package/dist/schemas/index.js +5 -3
- package/dist/schemas/media.d.ts +11 -0
- package/dist/schemas/media.d.ts.map +1 -1
- package/dist/types/database.d.ts +70 -0
- package/dist/types/database.d.ts.map +1 -1
- package/package.json +13 -1
- package/src/collab/provider-registry.ts +14 -0
- package/src/collab/rebase.ts +140 -0
- package/src/collab/recovery.ts +149 -0
- package/src/collab/types.ts +96 -0
- package/src/components/editor/AudienceIndicator.tsx +4 -1
- package/src/components/editor/ConflictDialog.tsx +61 -0
- package/src/components/editor/IndicatorPill.tsx +12 -6
- package/src/components/editor/LockBadge.tsx +31 -0
- package/src/components/editor/PresenceAvatars.tsx +53 -0
- package/src/components/editor/SectionWrapper.tsx +60 -29
- package/src/components/editor/StatusIndicator.tsx +4 -0
- package/src/components/shell/EditorShell.tsx +581 -22
- package/src/components/shell/MediaBreadcrumb.tsx +77 -0
- package/src/components/shell/MediaFolderTile.tsx +141 -0
- package/src/components/shell/MediaFolderTree.tsx +127 -0
- package/src/components/shell/MediaLibraryModal.tsx +611 -229
- package/src/components/shell/MoveToFolderDialog.tsx +78 -0
- package/src/hooks/useCollabSession.ts +220 -0
- package/src/hooks/useEditorPersistence.ts +104 -1
- package/src/hooks/useEditorPublish.ts +216 -71
- package/src/hooks/useMediaFolderDnd.ts +129 -0
- package/src/hooks/useMediaPipeline.ts +162 -6
- package/src/index.ts +2 -0
- package/src/lib/dexie.ts +44 -0
- package/src/lib/media-dnd.ts +62 -0
- package/src/lib/media-folders.ts +232 -0
- package/src/lib/registry.ts +8 -0
- package/src/media/folder-drop.ts +62 -0
- package/src/media/types.ts +2 -0
- package/src/media/utils.ts +10 -0
- package/src/schemas/media.ts +13 -0
- package/src/types/database.ts +55 -0
|
@@ -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
|
-
|
|
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
|
-
|
|
246
|
-
|
|
247
|
-
|
|
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
|
|
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
|
|
428
|
-
changedSectionIds: data.changedSectionIds
|
|
429
|
-
mainIndex: data.mainIndex
|
|
907
|
+
savedBranchSha: data.savedBranchSha,
|
|
908
|
+
changedSectionIds: data.changedSectionIds,
|
|
909
|
+
mainIndex: data.mainIndex,
|
|
430
910
|
});
|
|
431
911
|
if (data.mediaManifest) {
|
|
432
|
-
loadedManifest = data.mediaManifest
|
|
912
|
+
loadedManifest = data.mediaManifest;
|
|
433
913
|
await persistMediaManifest(loadedManifest);
|
|
434
914
|
}
|
|
435
|
-
setSavedSha(data.savedBranchSha
|
|
915
|
+
setSavedSha(data.savedBranchSha);
|
|
436
916
|
setMainSha(headSha);
|
|
437
|
-
setChangedSectionIds(new Set(data.changedSectionIds
|
|
438
|
-
setMainIndex(data.mainIndex
|
|
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
|