@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
@@ -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
- onSuccess: () => void;
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
- const images = { ...manifest.images };
167
- for (const upload of mediaUploads) {
168
- images[upload.item.id] = upload.item;
169
- }
170
- for (const id of pendingDeletions) {
171
- delete images[id];
172
- }
173
- updatedManifest = { images };
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<{ sha: string; savedSections: { sectionId: string; updatedAt: string }[]; uploadedItems: MediaItem[] }> {
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
- for (const chunk of leadChunks) {
237
- setSaveProgress(`Saving images ${sent + chunk.length}/${totalUploads}…`);
238
- for (const upload of chunk) committedImages[upload.item.id] = upload.item;
239
- const manifest: MediaManifest = { images: { ...args.mediaManifest.images, ...committedImages } };
240
- const data = await postSave({
241
- baseVersion,
242
- media: { uploads: chunk, deletions: [], manifest },
243
- });
244
- baseVersion = data.sha;
245
- sent += chunk.length;
246
- const chunkItems = chunk.map((u) => u.item);
247
- uploadedItems.push(...chunkItems);
248
- // Promote committed items immediately so a later failure can't re-send
249
- // them. A failure HERE is tolerable: re-sending identical blobs on the
250
- // next save is harmless (same content, same paths).
251
- try {
252
- await clearPendingMediaByIds(chunkItems.map((i) => i.id), []);
253
- onMediaPublished(chunkItems, []);
254
- } catch (promoteError) {
255
- console.error("Failed to promote committed media chunk locally:", promoteError);
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
- if (leadChunks.length > 0) {
260
- setSaveProgress(finalUploads.length > 0 ? `Saving images ${totalUploads}/${totalUploads}…` : null);
261
- }
312
+ if (leadChunks.length > 0) {
313
+ setSaveProgress(finalUploads.length > 0 ? `Saving images ${totalUploads}/${totalUploads}…` : null);
314
+ }
262
315
 
263
- const responseData = await postSave({
264
- sections: dirty.map(({ sectionId, content }) => ({ id: sectionId, content })),
265
- siteIndex,
266
- baseVersion,
267
- ...(args.deletedSectionIds?.length ? { deletedSectionIds: args.deletedSectionIds } : {}),
268
- ...(args.isConfigDirty() ? { siteConfig: args.siteConfig } : {}),
269
- ...(gathered.hasMediaChanges ? {
270
- media: {
271
- uploads: finalUploads,
272
- deletions: args.pendingMediaDeletions.map((id) => ({
273
- id,
274
- folder: args.mediaManifest.images[id]?.folder,
275
- })).filter((d) => d.folder),
276
- manifest: gathered.updatedManifest,
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
- return {
282
- sha: responseData.sha,
283
- savedSections: dirty.map(({ sectionId, updatedAt }) => ({ sectionId, updatedAt })),
284
- uploadedItems: [...uploadedItems, ...finalUploads.map((u) => u.item)],
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 runPerformSave();
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
- finishSave(uploadedItems);
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 runPerformSave());
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
- // Re-cache against the PUBLISHED sha (commitSavedRows just invalidated the
453
- // cache); the draft branch is gone after publish, so this is the snapshot a
454
- // later cache hit restores.
455
- await cacheContent(sha, sections, siteIndexRef.current, siteConfig!);
456
- finishSave(uploadedItems);
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
+ }