@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
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
import { useState, useCallback, useRef, useEffect } from "react";
|
|
2
2
|
import type { SiteIndex, SiteConfig } from "../schemas/site-config";
|
|
3
|
+
import type { SectionContent } from "../schemas/sections";
|
|
3
4
|
import type { LoadedSection } from "../lib/loader";
|
|
4
5
|
import type { MediaManifest, MediaItem } from "../media/types";
|
|
5
|
-
import { getDirtySectionRows, hasLocalChanges, discardSavedChanges, cacheContent, clearContentCache, getPendingMediaBlobs, clearPendingMediaByIds } from "../lib/dexie";
|
|
6
|
+
import { getDirtySectionRows, hasLocalChanges, discardSavedChanges, cacheContent, clearContentCache, getCachedContent, getPendingMediaBlobs, clearPendingMediaByIds } from "../lib/dexie";
|
|
6
7
|
import { packUploads, type MediaUploadPayload } from "../lib/save-chunks";
|
|
8
|
+
import { assembleSavedManifest } from "../lib/media-folders";
|
|
9
|
+
import { buildCommittedBaseline } from "../collab/recovery";
|
|
7
10
|
|
|
8
11
|
function blobToBase64(blob: Blob): Promise<string> {
|
|
9
12
|
return new Promise((resolve, reject) => {
|
|
@@ -58,7 +61,14 @@ interface PublishDeps {
|
|
|
58
61
|
siteConfig: SiteConfig | null;
|
|
59
62
|
sections: LoadedSection[];
|
|
60
63
|
deletedSectionIds?: string[];
|
|
61
|
-
|
|
64
|
+
/** Receives the ids that were actually committed this save (so the caller can
|
|
65
|
+
* release exactly those sections' locks), plus:
|
|
66
|
+
* - editToken: the edit-generation captured at save BEGIN. The caller's
|
|
67
|
+
* post-save clears must be guarded on it — any edit with a newer generation
|
|
68
|
+
* happened while this save was in flight and was NOT carried by it.
|
|
69
|
+
* - sentDeletedIds: the deletion ids this save actually sent, so the caller
|
|
70
|
+
* clears only those (a deletion scheduled mid-save must stay scheduled). */
|
|
71
|
+
onSuccess: (savedSectionIds: string[], meta: { editToken: number; sentDeletedIds: string[] }) => void;
|
|
62
72
|
mediaManifest: MediaManifest;
|
|
63
73
|
manifestDirty: boolean;
|
|
64
74
|
clearManifestDirty: () => void;
|
|
@@ -71,8 +81,30 @@ interface PublishDeps {
|
|
|
71
81
|
// no draft exists yet). Sent with saves so the server can 409 on a concurrent
|
|
72
82
|
// edit instead of silently overwriting it. Read at save time (latest value).
|
|
73
83
|
getBaseVersion: () => string | null;
|
|
84
|
+
/** Injected by EditorShell. On a 409 (concurrent edit) the save calls this to
|
|
85
|
+
* re-fetch the fresh draft, rebase local unsaved work on top, and refresh the
|
|
86
|
+
* optimistic-concurrency base (savedShaRef). Returns true when recovery
|
|
87
|
+
* succeeded and the save should be retried once; false (or undefined) means
|
|
88
|
+
* fall through to the honest conflict toast. */
|
|
89
|
+
recoverConflict?: () => Promise<boolean>;
|
|
90
|
+
/** Injected by EditorShell. After every successful plain save, receives the
|
|
91
|
+
* COMMITTED snapshot (sent content substituted into the previous clean
|
|
92
|
+
* baseline, plus the sent index) so the shell can re-prime the content-cache
|
|
93
|
+
* baseline that recoverConflict diffs against. Without this, the baseline
|
|
94
|
+
* dies at the first save (discardSavedChanges clears it) and later 409
|
|
95
|
+
* recoveries would silently merge local-wins. */
|
|
96
|
+
onBaselineCommitted?: (args: { sha: string; sections: LoadedSection[]; index: SiteIndex; savedIds: string[] }) => Promise<void>;
|
|
74
97
|
/** Test hook: overrides the per-request payload budget (default 4MB). */
|
|
75
98
|
saveChunkBudget?: number;
|
|
99
|
+
/** Injected by EditorShell. Mirrors this hook's in-flight-save state out to the
|
|
100
|
+
* shell so background collaboration handlers (e.g. the remote saved-event
|
|
101
|
+
* rebase) can defer while our own save's response path owns the state. */
|
|
102
|
+
savingRef?: React.MutableRefObject<boolean>;
|
|
103
|
+
/** Injected by EditorShell (persistence.currentGeneration). Read at save begin
|
|
104
|
+
* to mint the editToken passed back through onSuccess. Absent (tests without
|
|
105
|
+
* collab wiring): the token degrades to MAX_SAFE_INTEGER, i.e. "nothing was
|
|
106
|
+
* edited after capture" — the pre-generation wholesale-clear behavior. */
|
|
107
|
+
getEditGeneration?: () => number;
|
|
76
108
|
}
|
|
77
109
|
|
|
78
110
|
interface GatheredMedia {
|
|
@@ -102,7 +134,11 @@ export function useEditorPublish({
|
|
|
102
134
|
onShasUpdated,
|
|
103
135
|
onPublishComplete,
|
|
104
136
|
getBaseVersion,
|
|
137
|
+
recoverConflict,
|
|
138
|
+
onBaselineCommitted,
|
|
105
139
|
saveChunkBudget,
|
|
140
|
+
savingRef,
|
|
141
|
+
getEditGeneration,
|
|
106
142
|
}: PublishDeps) {
|
|
107
143
|
const [publishAction, setPublishAction] = useState<PublishAction>("idle");
|
|
108
144
|
const [publishFeedback, setPublishFeedback] = useState<string | null>(null);
|
|
@@ -163,14 +199,18 @@ export function useEditorPublish({
|
|
|
163
199
|
|
|
164
200
|
let updatedManifest: MediaManifest | undefined;
|
|
165
201
|
if (hasMediaChanges) {
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
updatedManifest =
|
|
202
|
+
// Preserves folders (and any future manifest keys) and heals dangling
|
|
203
|
+
// folder refs — never rebuild the manifest as a bare { images }.
|
|
204
|
+
// KNOWN LIMITATION (deferred): the assembly starts from THIS editor's
|
|
205
|
+
// view of the manifest, so concurrent media changes by another editor
|
|
206
|
+
// are last-writer-wins — a manifest entry they added since our load is
|
|
207
|
+
// dropped from the write (their files remain; the entry orphans). Fixing
|
|
208
|
+
// this needs a merge against the remote manifest at save time.
|
|
209
|
+
updatedManifest = assembleSavedManifest(
|
|
210
|
+
manifest,
|
|
211
|
+
mediaUploads.map((u) => u.item),
|
|
212
|
+
pendingDeletions,
|
|
213
|
+
);
|
|
174
214
|
}
|
|
175
215
|
|
|
176
216
|
return { mediaUploads, updatedManifest, hasMediaChanges };
|
|
@@ -186,7 +226,16 @@ export function useEditorPublish({
|
|
|
186
226
|
mediaManifest: MediaManifest;
|
|
187
227
|
manifestDirty: boolean;
|
|
188
228
|
saveChunkBudget?: number;
|
|
189
|
-
}): Promise<{
|
|
229
|
+
}): Promise<{
|
|
230
|
+
sha: string;
|
|
231
|
+
// sectionId+updatedAt for compare-and-clear cleanup, plus the SENT content —
|
|
232
|
+
// the committed-baseline re-prime needs exactly what went to the server (not
|
|
233
|
+
// the current view state, which may carry mid-flight edits).
|
|
234
|
+
savedSections: { sectionId: string; updatedAt: string; content: SectionContent }[];
|
|
235
|
+
uploadedItems: MediaItem[];
|
|
236
|
+
// The index actually sent (deletions applied) — the committed index state.
|
|
237
|
+
sentIndex: SiteIndex;
|
|
238
|
+
}> {
|
|
190
239
|
const dirty = await getDirtySectionRows();
|
|
191
240
|
const gathered = await gatherMediaPayload(
|
|
192
241
|
args.pendingMediaItems,
|
|
@@ -233,56 +282,72 @@ export function useEditorPublish({
|
|
|
233
282
|
const committedImages: Record<string, MediaItem> = {};
|
|
234
283
|
let sent = 0;
|
|
235
284
|
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
285
|
+
try {
|
|
286
|
+
for (const chunk of leadChunks) {
|
|
287
|
+
setSaveProgress(`Saving images ${sent + chunk.length}/${totalUploads}…`);
|
|
288
|
+
for (const upload of chunk) committedImages[upload.item.id] = upload.item;
|
|
289
|
+
const manifest: MediaManifest = {
|
|
290
|
+
images: { ...args.mediaManifest.images, ...committedImages },
|
|
291
|
+
folders: args.mediaManifest.folders,
|
|
292
|
+
};
|
|
293
|
+
const data = await postSave({
|
|
294
|
+
baseVersion,
|
|
295
|
+
media: { uploads: chunk, deletions: [], manifest },
|
|
296
|
+
});
|
|
297
|
+
baseVersion = data.sha;
|
|
298
|
+
sent += chunk.length;
|
|
299
|
+
const chunkItems = chunk.map((u) => u.item);
|
|
300
|
+
uploadedItems.push(...chunkItems);
|
|
301
|
+
// Promote committed items immediately so a later failure can't re-send
|
|
302
|
+
// them. A failure HERE is tolerable: re-sending identical blobs on the
|
|
303
|
+
// next save is harmless (same content, same paths).
|
|
304
|
+
try {
|
|
305
|
+
await clearPendingMediaByIds(chunkItems.map((i) => i.id), []);
|
|
306
|
+
onMediaPublished(chunkItems, []);
|
|
307
|
+
} catch (promoteError) {
|
|
308
|
+
console.error("Failed to promote committed media chunk locally:", promoteError);
|
|
309
|
+
}
|
|
256
310
|
}
|
|
257
|
-
}
|
|
258
311
|
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
312
|
+
if (leadChunks.length > 0) {
|
|
313
|
+
setSaveProgress(finalUploads.length > 0 ? `Saving images ${totalUploads}/${totalUploads}…` : null);
|
|
314
|
+
}
|
|
262
315
|
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
316
|
+
const responseData = await postSave({
|
|
317
|
+
sections: dirty.map(({ sectionId, content }) => ({ id: sectionId, content })),
|
|
318
|
+
siteIndex,
|
|
319
|
+
baseVersion,
|
|
320
|
+
...(args.deletedSectionIds?.length ? { deletedSectionIds: args.deletedSectionIds } : {}),
|
|
321
|
+
...(args.isConfigDirty() ? { siteConfig: args.siteConfig } : {}),
|
|
322
|
+
...(gathered.hasMediaChanges ? {
|
|
323
|
+
media: {
|
|
324
|
+
uploads: finalUploads,
|
|
325
|
+
deletions: args.pendingMediaDeletions.map((id) => ({
|
|
326
|
+
id,
|
|
327
|
+
folder: args.mediaManifest.images[id]?.folder,
|
|
328
|
+
})).filter((d) => d.folder),
|
|
329
|
+
manifest: gathered.updatedManifest,
|
|
330
|
+
},
|
|
331
|
+
} : {}),
|
|
332
|
+
});
|
|
280
333
|
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
334
|
+
return {
|
|
335
|
+
sha: responseData.sha,
|
|
336
|
+
savedSections: dirty.map(({ sectionId, updatedAt, content }) => ({ sectionId, updatedAt, content })),
|
|
337
|
+
uploadedItems: [...uploadedItems, ...finalUploads.map((u) => u.item)],
|
|
338
|
+
sentIndex: siteIndex,
|
|
339
|
+
};
|
|
340
|
+
} catch (error) {
|
|
341
|
+
// I2: once a lead chunk has committed (advancing the draft head), a 409
|
|
342
|
+
// must NOT trigger auto-recovery — the retry would rebuild the manifest
|
|
343
|
+
// from this invocation's stale closure, dropping the just-promoted items
|
|
344
|
+
// and orphaning their committed files. Mark the error so the recovery
|
|
345
|
+
// wrapper falls through to the honest toast instead.
|
|
346
|
+
if (sent > 0 && error && typeof error === "object") {
|
|
347
|
+
(error as { leadChunksCommitted?: boolean }).leadChunksCommitted = true;
|
|
348
|
+
}
|
|
349
|
+
throw error;
|
|
350
|
+
}
|
|
286
351
|
}
|
|
287
352
|
|
|
288
353
|
// --- Shared save orchestration ---
|
|
@@ -290,9 +355,24 @@ export function useEditorPublish({
|
|
|
290
355
|
// change-detection, the /api/save request, and the post-save cleanup stay in
|
|
291
356
|
// one place and can't drift between the two paths.
|
|
292
357
|
|
|
358
|
+
// Whether THIS save invocation's recoverConflict actually rebased the view —
|
|
359
|
+
// gates the 409 toast copy: "your view has been updated" must only be claimed
|
|
360
|
+
// when it is true. Reset at the start of every save.
|
|
361
|
+
const recoveredRef = useRef(false);
|
|
362
|
+
|
|
363
|
+
// Edit-generation captured at save begin. Edits with a newer generation
|
|
364
|
+
// happened mid-save (after the payload snapshot) — onSuccess receives this
|
|
365
|
+
// token so the shell's post-save clears can spare them. Captured BEFORE the
|
|
366
|
+
// flush: an edit landing during the flush itself then reads as "after the
|
|
367
|
+
// token" and is kept — erring toward a stale dirty badge, never lost work.
|
|
368
|
+
const editTokenRef = useRef(Number.MAX_SAFE_INTEGER);
|
|
369
|
+
|
|
293
370
|
function tryBeginSave(): boolean {
|
|
294
371
|
if (!siteConfig || inFlightRef.current) return false;
|
|
295
372
|
inFlightRef.current = true;
|
|
373
|
+
if (savingRef) savingRef.current = true;
|
|
374
|
+
recoveredRef.current = false;
|
|
375
|
+
editTokenRef.current = getEditGeneration?.() ?? Number.MAX_SAFE_INTEGER;
|
|
296
376
|
setPublishAction("saving");
|
|
297
377
|
setPublishFeedback(null);
|
|
298
378
|
return true;
|
|
@@ -315,6 +395,30 @@ export function useEditorPublish({
|
|
|
315
395
|
});
|
|
316
396
|
}
|
|
317
397
|
|
|
398
|
+
// Wraps a save attempt with single-shot 409 auto-recovery: when the server
|
|
399
|
+
// rejects the save because another editor moved the draft head, ask
|
|
400
|
+
// recoverConflict() to re-fetch + rebase local work and refresh savedShaRef,
|
|
401
|
+
// then retry exactly once. Any other error (or a declined/absent recovery)
|
|
402
|
+
// propagates to reportSaveError. At most one retry per save invocation — never
|
|
403
|
+
// a loop, since the retry runs runPerformSave() directly, not this wrapper.
|
|
404
|
+
async function saveWithConflictRecovery(): ReturnType<typeof runPerformSave> {
|
|
405
|
+
try {
|
|
406
|
+
return await runPerformSave();
|
|
407
|
+
} catch (error) {
|
|
408
|
+
const status = (error as { status?: number })?.status;
|
|
409
|
+
// I2: no recovery once lead media chunks committed this invocation — see
|
|
410
|
+
// the flag's source in performSave.
|
|
411
|
+
const leadCommitted = (error as { leadChunksCommitted?: boolean })?.leadChunksCommitted === true;
|
|
412
|
+
if (status === 409 && !leadCommitted && recoverConflict && (await recoverConflict())) {
|
|
413
|
+
// recoverConflict rebased local state and refreshed savedShaRef —
|
|
414
|
+
// getBaseVersion() now returns the fresh draft head. One retry only.
|
|
415
|
+
recoveredRef.current = true;
|
|
416
|
+
return await runPerformSave();
|
|
417
|
+
}
|
|
418
|
+
throw error;
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
|
|
318
422
|
// Clear the Dexie rows + pending media for what was actually committed. In the
|
|
319
423
|
// publish path this MUST run before cacheContent: discardSavedChanges clears
|
|
320
424
|
// only the rows we sent (preserving edits typed during the request) AND
|
|
@@ -330,17 +434,27 @@ export function useEditorPublish({
|
|
|
330
434
|
await clearPendingMediaByIds(uploadedItems.map((i) => i.id), pendingMediaDeletions);
|
|
331
435
|
}
|
|
332
436
|
|
|
333
|
-
function finishSave(uploadedItems: MediaItem[]): void {
|
|
437
|
+
function finishSave(uploadedItems: MediaItem[], savedSectionIds: string[]): void {
|
|
334
438
|
clearConfigDirty();
|
|
335
439
|
clearManifestDirty();
|
|
336
|
-
onSuccess(
|
|
440
|
+
onSuccess(savedSectionIds, {
|
|
441
|
+
editToken: editTokenRef.current,
|
|
442
|
+
sentDeletedIds: deletedSectionIds ?? [],
|
|
443
|
+
});
|
|
337
444
|
onMediaPublished(uploadedItems, pendingMediaDeletions);
|
|
338
445
|
}
|
|
339
446
|
|
|
340
447
|
function reportSaveError(error: unknown, failLabel: string): void {
|
|
341
448
|
console.error(`${failLabel} failed:`, error);
|
|
342
|
-
const message = error instanceof Error && error.message ? error.message : `${failLabel} failed`;
|
|
343
449
|
const isConflict = (error as { status?: number })?.status === 409;
|
|
450
|
+
// M1 honesty: only claim the view was updated when recovery actually
|
|
451
|
+
// rebased it this invocation (i.e. the RETRY 409'd). A failed/absent/
|
|
452
|
+
// skipped recovery left the view untouched — say so.
|
|
453
|
+
const message = isConflict
|
|
454
|
+
? recoveredRef.current
|
|
455
|
+
? "Someone else saved while you were editing — your view has been updated; try saving again."
|
|
456
|
+
: "This site was changed elsewhere — reload to get the latest, then re-apply your edits."
|
|
457
|
+
: error instanceof Error && error.message ? error.message : `${failLabel} failed`;
|
|
344
458
|
showFeedback(message, isConflict ? 8000 : 6000);
|
|
345
459
|
}
|
|
346
460
|
|
|
@@ -356,12 +470,31 @@ export function useEditorPublish({
|
|
|
356
470
|
return;
|
|
357
471
|
}
|
|
358
472
|
|
|
359
|
-
const { sha, savedSections, uploadedItems } = await
|
|
473
|
+
const { sha, savedSections, uploadedItems, sentIndex } = await saveWithConflictRecovery();
|
|
360
474
|
onShasUpdated(sha, null);
|
|
361
475
|
|
|
362
476
|
try {
|
|
477
|
+
// C1: read the previous clean baseline BEFORE commitSavedRows clears the
|
|
478
|
+
// content cache, then re-prime it with the COMMITTED snapshot keyed at
|
|
479
|
+
// the new sha. Post-recovery the cache holds the fetched remote snapshot,
|
|
480
|
+
// so this correctly layers only the retried dirty rows on top of it.
|
|
481
|
+
const prevBaseline = onBaselineCommitted ? await getCachedContent() : null;
|
|
363
482
|
await commitSavedRows(savedSections, uploadedItems);
|
|
364
|
-
|
|
483
|
+
if (onBaselineCommitted) {
|
|
484
|
+
const baselineSections = buildCommittedBaseline({
|
|
485
|
+
baselineSections: prevBaseline?.sections ?? null,
|
|
486
|
+
fallbackSections: sections,
|
|
487
|
+
savedRows: savedSections,
|
|
488
|
+
sentIndex,
|
|
489
|
+
});
|
|
490
|
+
await onBaselineCommitted({
|
|
491
|
+
sha,
|
|
492
|
+
sections: baselineSections,
|
|
493
|
+
index: sentIndex,
|
|
494
|
+
savedIds: savedSections.map((s) => s.sectionId),
|
|
495
|
+
});
|
|
496
|
+
}
|
|
497
|
+
finishSave(uploadedItems, savedSections.map((s) => s.sectionId));
|
|
365
498
|
} catch (cleanupError) {
|
|
366
499
|
console.error("Save succeeded but local cleanup failed:", cleanupError);
|
|
367
500
|
showFeedback("Saved, but the local cache couldn't be updated — reload the editor.", 8000);
|
|
@@ -373,10 +506,11 @@ export function useEditorPublish({
|
|
|
373
506
|
reportSaveError(error, "Save");
|
|
374
507
|
} finally {
|
|
375
508
|
inFlightRef.current = false;
|
|
509
|
+
if (savingRef) savingRef.current = false;
|
|
376
510
|
setPublishAction("idle");
|
|
377
511
|
setSaveProgress(null);
|
|
378
512
|
}
|
|
379
|
-
}, [flushNow, cancelPendingFlush, isConfigDirty, clearConfigDirty, siteIndexRef, siteConfig, deletedSectionIds, onSuccess, mediaManifest, manifestDirty, clearManifestDirty, pendingMediaItems, pendingMediaDeletions, onMediaPublished, onShasUpdated, showFeedback, saveChunkBudget]);
|
|
513
|
+
}, [flushNow, cancelPendingFlush, isConfigDirty, clearConfigDirty, siteIndexRef, siteConfig, sections, deletedSectionIds, onSuccess, mediaManifest, manifestDirty, clearManifestDirty, pendingMediaItems, pendingMediaDeletions, onMediaPublished, onShasUpdated, showFeedback, recoverConflict, onBaselineCommitted, saveChunkBudget, getEditGeneration]);
|
|
380
514
|
|
|
381
515
|
const handlePublish = useCallback(async () => {
|
|
382
516
|
setPublishAction("publishing");
|
|
@@ -422,10 +556,11 @@ export function useEditorPublish({
|
|
|
422
556
|
|
|
423
557
|
const hasLocalEdits = await hasPendingEdits();
|
|
424
558
|
|
|
425
|
-
let savedSections: { sectionId: string; updatedAt: string }[] = [];
|
|
559
|
+
let savedSections: { sectionId: string; updatedAt: string; content: SectionContent }[] = [];
|
|
426
560
|
let uploadedItems: MediaItem[] = [];
|
|
561
|
+
let sentIndex: SiteIndex | null = null;
|
|
427
562
|
if (hasLocalEdits) {
|
|
428
|
-
({ savedSections, uploadedItems } = await
|
|
563
|
+
({ savedSections, uploadedItems, sentIndex } = await saveWithConflictRecovery());
|
|
429
564
|
}
|
|
430
565
|
|
|
431
566
|
setPublishAction("publishing");
|
|
@@ -448,12 +583,21 @@ export function useEditorPublish({
|
|
|
448
583
|
|
|
449
584
|
if (hasLocalEdits) {
|
|
450
585
|
try {
|
|
586
|
+
// C1 parity with handleSave: read the previous clean baseline before
|
|
587
|
+
// the invalidation, then re-cache the COMMITTED snapshot (sent content
|
|
588
|
+
// + sent index — never the live view state, which may carry mid-flight
|
|
589
|
+
// edits) against the PUBLISHED sha; the draft branch is gone after
|
|
590
|
+
// publish, so this is the snapshot a later cache hit restores.
|
|
591
|
+
const prevBaseline = await getCachedContent();
|
|
451
592
|
await commitSavedRows(savedSections, uploadedItems);
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
593
|
+
const baselineSections = buildCommittedBaseline({
|
|
594
|
+
baselineSections: prevBaseline?.sections ?? null,
|
|
595
|
+
fallbackSections: sections,
|
|
596
|
+
savedRows: savedSections,
|
|
597
|
+
sentIndex: sentIndex!,
|
|
598
|
+
});
|
|
599
|
+
await cacheContent(sha, baselineSections, sentIndex!, siteConfig!);
|
|
600
|
+
finishSave(uploadedItems, savedSections.map((s) => s.sectionId));
|
|
457
601
|
} catch (cleanupError) {
|
|
458
602
|
console.error("Publish succeeded but local cleanup failed:", cleanupError);
|
|
459
603
|
onShasUpdated(null, sha);
|
|
@@ -469,10 +613,11 @@ export function useEditorPublish({
|
|
|
469
613
|
reportSaveError(error, "Publish");
|
|
470
614
|
} finally {
|
|
471
615
|
inFlightRef.current = false;
|
|
616
|
+
if (savingRef) savingRef.current = false;
|
|
472
617
|
setPublishAction("idle");
|
|
473
618
|
setSaveProgress(null);
|
|
474
619
|
}
|
|
475
|
-
}, [flushNow, cancelPendingFlush, isConfigDirty, clearConfigDirty, siteIndexRef, siteConfig, sections, deletedSectionIds, onSuccess, mediaManifest, manifestDirty, clearManifestDirty, pendingMediaItems, pendingMediaDeletions, onMediaPublished, onShasUpdated, showFeedback, onPublishComplete, saveChunkBudget]);
|
|
620
|
+
}, [flushNow, cancelPendingFlush, isConfigDirty, clearConfigDirty, siteIndexRef, siteConfig, sections, deletedSectionIds, onSuccess, mediaManifest, manifestDirty, clearManifestDirty, pendingMediaItems, pendingMediaDeletions, onMediaPublished, onShasUpdated, showFeedback, onPublishComplete, recoverConflict, saveChunkBudget, getEditGeneration]);
|
|
476
621
|
|
|
477
622
|
return { publishAction, publishFeedback, saveProgress, handleSave, handlePublish, handleSaveAndPublish };
|
|
478
623
|
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { useEffect, useRef, useState } from "react";
|
|
2
|
+
import type { MediaFolder, MediaItem } from "../media/types";
|
|
3
|
+
import {
|
|
4
|
+
buildFolderDropData, canDropFolderInto, canDropMediaInto,
|
|
5
|
+
isFolderDragData, isMediaDragData,
|
|
6
|
+
type MediaDragData, type MediaFolderDragData,
|
|
7
|
+
} from "../lib/media-dnd";
|
|
8
|
+
|
|
9
|
+
/** Delay before a hovered collapsed tree node auto-expands during a drag. */
|
|
10
|
+
const HOVER_EXPAND_MS = 600;
|
|
11
|
+
|
|
12
|
+
interface UseFolderDropTargetArgs {
|
|
13
|
+
targetFolderId: string | null;
|
|
14
|
+
enabled: boolean;
|
|
15
|
+
/** Refs (not values) so drops read fresh state without re-registering targets. */
|
|
16
|
+
foldersRef: React.RefObject<Record<string, MediaFolder>>;
|
|
17
|
+
itemsByIdRef: React.RefObject<Record<string, MediaItem>>;
|
|
18
|
+
onMoveMedia: (ids: string[], target: string | null) => void;
|
|
19
|
+
onMoveFolder: (id: string, target: string | null) => void;
|
|
20
|
+
/** Fired after a sustained drag hover (tree hover-to-expand). */
|
|
21
|
+
onDragHover?: () => void;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function useFolderDropTarget<E extends HTMLElement>({
|
|
25
|
+
targetFolderId, enabled, foldersRef, itemsByIdRef, onMoveMedia, onMoveFolder, onDragHover,
|
|
26
|
+
}: UseFolderDropTargetArgs) {
|
|
27
|
+
const ref = useRef<E>(null);
|
|
28
|
+
const [isDropTarget, setIsDropTarget] = useState(false);
|
|
29
|
+
const callbacksRef = useRef({ onMoveMedia, onMoveFolder, onDragHover });
|
|
30
|
+
callbacksRef.current = { onMoveMedia, onMoveFolder, onDragHover };
|
|
31
|
+
|
|
32
|
+
useEffect(() => {
|
|
33
|
+
if (!enabled) return;
|
|
34
|
+
const element = ref.current;
|
|
35
|
+
if (!element) return;
|
|
36
|
+
|
|
37
|
+
let cleanup: (() => void) | undefined;
|
|
38
|
+
let cancelled = false;
|
|
39
|
+
let hoverTimer: ReturnType<typeof setTimeout> | undefined;
|
|
40
|
+
const clearHover = () => {
|
|
41
|
+
if (hoverTimer !== undefined) clearTimeout(hoverTimer);
|
|
42
|
+
hoverTimer = undefined;
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
import("@atlaskit/pragmatic-drag-and-drop/element/adapter").then(({ dropTargetForElements }) => {
|
|
46
|
+
if (cancelled) return;
|
|
47
|
+
cleanup = dropTargetForElements({
|
|
48
|
+
element,
|
|
49
|
+
getData: () => buildFolderDropData(targetFolderId),
|
|
50
|
+
canDrop: ({ source }) => {
|
|
51
|
+
const folders = foldersRef.current ?? {};
|
|
52
|
+
if (isMediaDragData(source.data)) {
|
|
53
|
+
return canDropMediaInto(source.data, targetFolderId, folders, itemsByIdRef.current ?? {});
|
|
54
|
+
}
|
|
55
|
+
if (isFolderDragData(source.data)) {
|
|
56
|
+
return canDropFolderInto(source.data, targetFolderId, folders);
|
|
57
|
+
}
|
|
58
|
+
return false;
|
|
59
|
+
},
|
|
60
|
+
onDragEnter: () => {
|
|
61
|
+
setIsDropTarget(true);
|
|
62
|
+
if (callbacksRef.current.onDragHover) {
|
|
63
|
+
clearHover();
|
|
64
|
+
hoverTimer = setTimeout(() => callbacksRef.current.onDragHover?.(), HOVER_EXPAND_MS);
|
|
65
|
+
}
|
|
66
|
+
},
|
|
67
|
+
onDragLeave: () => {
|
|
68
|
+
setIsDropTarget(false);
|
|
69
|
+
clearHover();
|
|
70
|
+
},
|
|
71
|
+
onDrop: ({ source }) => {
|
|
72
|
+
setIsDropTarget(false);
|
|
73
|
+
clearHover();
|
|
74
|
+
if (isMediaDragData(source.data)) {
|
|
75
|
+
callbacksRef.current.onMoveMedia(source.data.mediaIds, targetFolderId);
|
|
76
|
+
} else if (isFolderDragData(source.data)) {
|
|
77
|
+
callbacksRef.current.onMoveFolder(source.data.folderId, targetFolderId);
|
|
78
|
+
}
|
|
79
|
+
},
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
return () => {
|
|
84
|
+
cancelled = true;
|
|
85
|
+
clearHover();
|
|
86
|
+
cleanup?.();
|
|
87
|
+
};
|
|
88
|
+
}, [enabled, targetFolderId, foldersRef, itemsByIdRef]);
|
|
89
|
+
|
|
90
|
+
return { ref, isDropTarget };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
interface UseMediaDraggableArgs {
|
|
94
|
+
enabled: boolean;
|
|
95
|
+
getDragData: () => MediaDragData | MediaFolderDragData;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function useMediaDraggable<E extends HTMLElement>({ enabled, getDragData }: UseMediaDraggableArgs) {
|
|
99
|
+
const ref = useRef<E>(null);
|
|
100
|
+
const [isDragging, setIsDragging] = useState(false);
|
|
101
|
+
const getDragDataRef = useRef(getDragData);
|
|
102
|
+
getDragDataRef.current = getDragData;
|
|
103
|
+
|
|
104
|
+
useEffect(() => {
|
|
105
|
+
if (!enabled) return;
|
|
106
|
+
const element = ref.current;
|
|
107
|
+
if (!element) return;
|
|
108
|
+
|
|
109
|
+
let cleanup: (() => void) | undefined;
|
|
110
|
+
let cancelled = false;
|
|
111
|
+
|
|
112
|
+
import("@atlaskit/pragmatic-drag-and-drop/element/adapter").then(({ draggable }) => {
|
|
113
|
+
if (cancelled) return;
|
|
114
|
+
cleanup = draggable({
|
|
115
|
+
element,
|
|
116
|
+
getInitialData: () => getDragDataRef.current(),
|
|
117
|
+
onDragStart: () => setIsDragging(true),
|
|
118
|
+
onDrop: () => setIsDragging(false),
|
|
119
|
+
});
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
return () => {
|
|
123
|
+
cancelled = true;
|
|
124
|
+
cleanup?.();
|
|
125
|
+
};
|
|
126
|
+
}, [enabled]);
|
|
127
|
+
|
|
128
|
+
return { ref, isDragging };
|
|
129
|
+
}
|