@drawnagency/primitives 0.1.98 → 0.1.100

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 (138) hide show
  1. package/dist/{chunk-EUSWEVOA.js → chunk-3TO4RRHY.js} +12 -1
  2. package/dist/{chunk-KP4DWTPR.js → chunk-72FMQQIA.js} +246 -3
  3. package/dist/chunk-BS5AQHZG.js +45 -0
  4. package/dist/{chunk-NA5EJM6Z.js → chunk-CZLYECHC.js} +19 -8
  5. package/dist/{chunk-HKHEAH5F.js → chunk-GOCFTXR3.js} +8 -60
  6. package/dist/{chunk-GYCBWGVW.js → chunk-LBHQWIVL.js} +79 -24
  7. package/dist/{chunk-HJSE6SK5.js → chunk-REDDTTGJ.js} +3 -3
  8. package/dist/collab/rebase.d.ts.map +1 -1
  9. package/dist/collab/rebase.js +3 -1
  10. package/dist/components/chat/ChatPanel.d.ts.map +1 -1
  11. package/dist/components/docfile/DocFileOverlay.d.ts +12 -0
  12. package/dist/components/docfile/DocFileOverlay.d.ts.map +1 -0
  13. package/dist/components/docfile/DocFilePane.d.ts +17 -0
  14. package/dist/components/docfile/DocFilePane.d.ts.map +1 -0
  15. package/dist/components/docfile/doc-file-state.d.ts +33 -0
  16. package/dist/components/docfile/doc-file-state.d.ts.map +1 -0
  17. package/dist/components/editor/SettingsForm.d.ts.map +1 -1
  18. package/dist/components/editor/index.js +1 -1
  19. package/dist/components/sections/Document/index.d.ts +18 -0
  20. package/dist/components/sections/Document/index.d.ts.map +1 -0
  21. package/dist/components/sections/all-sections.d.ts +10 -0
  22. package/dist/components/sections/all-sections.d.ts.map +1 -1
  23. package/dist/components/sections/brand-guide.d.ts +10 -0
  24. package/dist/components/sections/brand-guide.d.ts.map +1 -1
  25. package/dist/components/sections/brand-guide.js +3 -1
  26. package/dist/components/sections/register-schemas.js +5 -3
  27. package/dist/components/sections/register.js +5 -3
  28. package/dist/components/shared/DocumentSourceField.d.ts +13 -0
  29. package/dist/components/shared/DocumentSourceField.d.ts.map +1 -0
  30. package/dist/components/shared/Navigation.d.ts.map +1 -1
  31. package/dist/components/shell/EditorModal.d.ts.map +1 -1
  32. package/dist/components/shell/EditorShell.d.ts.map +1 -1
  33. package/dist/components/shell/MediaBreadcrumb.d.ts.map +1 -1
  34. package/dist/components/shell/MediaFolderTile.d.ts +3 -1
  35. package/dist/components/shell/MediaFolderTile.d.ts.map +1 -1
  36. package/dist/components/shell/MediaFolderTree.d.ts +3 -0
  37. package/dist/components/shell/MediaFolderTree.d.ts.map +1 -1
  38. package/dist/components/shell/MediaLibraryContext.d.ts +18 -1
  39. package/dist/components/shell/MediaLibraryContext.d.ts.map +1 -1
  40. package/dist/components/shell/MediaLibraryModal.d.ts +21 -1
  41. package/dist/components/shell/MediaLibraryModal.d.ts.map +1 -1
  42. package/dist/hooks/useDocumentUploads.d.ts +32 -0
  43. package/dist/hooks/useDocumentUploads.d.ts.map +1 -0
  44. package/dist/hooks/useEditorPublish.d.ts +23 -1
  45. package/dist/hooks/useEditorPublish.d.ts.map +1 -1
  46. package/dist/hooks/useMediaPipeline.d.ts +14 -1
  47. package/dist/hooks/useMediaPipeline.d.ts.map +1 -1
  48. package/dist/hooks/useResolvedDocument.d.ts +12 -0
  49. package/dist/hooks/useResolvedDocument.d.ts.map +1 -0
  50. package/dist/index.js +60 -34
  51. package/dist/lib/block-move.d.ts.map +1 -1
  52. package/dist/lib/dexie.d.ts +11 -0
  53. package/dist/lib/dexie.d.ts.map +1 -1
  54. package/dist/lib/dexie.js +32 -0
  55. package/dist/lib/document-refs.d.ts +22 -0
  56. package/dist/lib/document-refs.d.ts.map +1 -0
  57. package/dist/lib/document-uploads.d.ts +38 -0
  58. package/dist/lib/document-uploads.d.ts.map +1 -0
  59. package/dist/lib/documents.d.ts +65 -0
  60. package/dist/lib/documents.d.ts.map +1 -0
  61. package/dist/lib/index-ops.d.ts +1 -0
  62. package/dist/lib/index-ops.d.ts.map +1 -1
  63. package/dist/lib/index-ops.js +13 -1
  64. package/dist/lib/index.js +6 -6
  65. package/dist/lib/loader.d.ts +6 -0
  66. package/dist/lib/loader.d.ts.map +1 -1
  67. package/dist/lib/media-folders.d.ts +8 -0
  68. package/dist/lib/media-folders.d.ts.map +1 -1
  69. package/dist/lib/media-folders.js +2 -0
  70. package/dist/lib/nav.d.ts +29 -1
  71. package/dist/lib/nav.d.ts.map +1 -1
  72. package/dist/lib/pages.d.ts +18 -0
  73. package/dist/lib/pages.d.ts.map +1 -1
  74. package/dist/lib/platform-broker.d.ts +12 -0
  75. package/dist/lib/platform-broker.d.ts.map +1 -1
  76. package/dist/lib/platform-broker.js +12 -3
  77. package/dist/lib/registry.d.ts +10 -0
  78. package/dist/lib/registry.d.ts.map +1 -1
  79. package/dist/lib/save-chunks.d.ts +15 -0
  80. package/dist/lib/save-chunks.d.ts.map +1 -1
  81. package/dist/lib/scroll-lock.d.ts +2 -0
  82. package/dist/lib/scroll-lock.d.ts.map +1 -0
  83. package/dist/schemas/documents.d.ts +80 -0
  84. package/dist/schemas/documents.d.ts.map +1 -0
  85. package/dist/schemas/documents.js +25 -0
  86. package/dist/schemas/index.d.ts +1 -0
  87. package/dist/schemas/index.d.ts.map +1 -1
  88. package/dist/schemas/index.js +24 -2
  89. package/dist/storage/index.d.ts +2 -2
  90. package/dist/storage/index.d.ts.map +1 -1
  91. package/dist/storage/index.js +5 -1
  92. package/dist/storage/types.d.ts +69 -5
  93. package/dist/storage/types.d.ts.map +1 -1
  94. package/dist/types/database.d.ts +14 -0
  95. package/dist/types/database.d.ts.map +1 -1
  96. package/package.json +5 -1
  97. package/src/collab/rebase.ts +15 -0
  98. package/src/components/chat/ChatPanel.tsx +5 -4
  99. package/src/components/docfile/DocFileOverlay.tsx +54 -0
  100. package/src/components/docfile/DocFilePane.tsx +129 -0
  101. package/src/components/docfile/doc-file-state.ts +180 -0
  102. package/src/components/editor/SettingsForm.tsx +10 -0
  103. package/src/components/sections/Document/index.tsx +163 -0
  104. package/src/components/sections/brand-guide.ts +2 -1
  105. package/src/components/shared/DocumentSourceField.tsx +51 -0
  106. package/src/components/shared/Navigation.tsx +53 -10
  107. package/src/components/shell/EditorModal.tsx +2 -5
  108. package/src/components/shell/EditorShell.tsx +154 -10
  109. package/src/components/shell/MediaBreadcrumb.tsx +14 -1
  110. package/src/components/shell/MediaFolderTile.tsx +4 -2
  111. package/src/components/shell/MediaFolderTree.tsx +20 -3
  112. package/src/components/shell/MediaLibraryContext.tsx +18 -1
  113. package/src/components/shell/MediaLibraryModal.tsx +223 -45
  114. package/src/hooks/useDocumentUploads.ts +330 -0
  115. package/src/hooks/useEditorPublish.ts +197 -11
  116. package/src/hooks/useMediaPipeline.ts +136 -5
  117. package/src/hooks/useResolvedDocument.ts +25 -0
  118. package/src/lib/block-move.ts +15 -4
  119. package/src/lib/dexie.ts +54 -0
  120. package/src/lib/document-refs.ts +83 -0
  121. package/src/lib/document-uploads.ts +66 -0
  122. package/src/lib/documents.ts +138 -0
  123. package/src/lib/index-ops.ts +16 -0
  124. package/src/lib/loader.ts +32 -11
  125. package/src/lib/media-folders.ts +9 -0
  126. package/src/lib/nav.ts +46 -2
  127. package/src/lib/pages.ts +48 -0
  128. package/src/lib/platform-broker.ts +18 -3
  129. package/src/lib/registry.ts +9 -0
  130. package/src/lib/save-chunks.ts +42 -0
  131. package/src/lib/scroll-lock.ts +27 -0
  132. package/src/schemas/documents.ts +82 -0
  133. package/src/schemas/index.ts +1 -0
  134. package/src/schemas/nav.ts +3 -3
  135. package/src/storage/index.ts +2 -2
  136. package/src/storage/types.ts +71 -5
  137. package/src/types/database.ts +14 -0
  138. /package/dist/{chunk-FOY5TF7D.js → chunk-ZDQ3TT47.js} +0 -0
@@ -3,8 +3,9 @@ import type { SiteIndex, SiteConfig } from "../schemas/site-config";
3
3
  import type { SectionContent } from "../schemas/sections";
4
4
  import type { LoadedSection } from "../lib/loader";
5
5
  import type { MediaManifest, MediaItem } from "../media/types";
6
- import { getDirtySectionRows, hasLocalChanges, discardSavedChanges, cacheContent, clearContentCache, getCachedContent, getPendingMediaBlobs, clearPendingMediaByIds } from "../lib/dexie";
7
- import { packUploads, type MediaUploadPayload } from "../lib/save-chunks";
6
+ import { getDirtySectionRows, hasLocalChanges, discardSavedChanges, cacheContent, clearContentCache, getCachedContent, getPendingMediaBlobs, clearPendingMediaByIds, getPendingDocumentBlob, clearPendingDocumentsByIds } from "../lib/dexie";
7
+ import { packUploads, packDocumentUploads, type MediaUploadPayload, type DocumentUploadPayload } from "../lib/save-chunks";
8
+ import type { DocumentManifest, DocumentManifestEntry } from "../schemas/documents";
8
9
  import { assembleSavedManifest } from "../lib/media-folders";
9
10
  import { buildCommittedBaseline } from "../collab/recovery";
10
11
 
@@ -24,7 +25,7 @@ function blobToBase64(blob: Blob): Promise<string> {
24
25
  * is preserved verbatim; non-JSON bodies (platform-level rejections) get a
25
26
  * status-coded fallback — or a size hint when the request body itself was
26
27
  * near Netlify's ~6MB limit (should be unreachable once saves are chunked). */
27
- async function postSave(payload: object): Promise<{ sha: string }> {
28
+ async function postSave(payload: object): Promise<{ sha: string; warnings?: string[] }> {
28
29
  const body = JSON.stringify(payload);
29
30
  let response: Response;
30
31
  try {
@@ -47,9 +48,9 @@ async function postSave(payload: object): Promise<{ sha: string }> {
47
48
  : `Save failed (HTTP ${response.status})`;
48
49
  throw Object.assign(new Error(serverMessage || fallback), { status: response.status });
49
50
  }
50
- const data = await response.json().catch(() => null) as { sha?: string } | null;
51
+ const data = await response.json().catch(() => null) as { sha?: string; warnings?: string[] } | null;
51
52
  if (!data?.sha) throw new Error("Save response missing SHA");
52
- return data as { sha: string };
53
+ return data as { sha: string; warnings?: string[] };
53
54
  }
54
55
 
55
56
  interface PublishDeps {
@@ -98,6 +99,11 @@ interface PublishDeps {
98
99
  * dies at the first save (discardSavedChanges clears it) and later 409
99
100
  * recoveries would silently merge local-wins. */
100
101
  onBaselineCommitted?: (args: { sha: string; sections: LoadedSection[]; index: SiteIndex; savedIds: string[] }) => Promise<void>;
102
+ /** Injected by EditorShell. Receives non-fatal server warnings from a
103
+ * successful save (e.g. the nav.json replacement was declined by the
104
+ * degraded-nav guard) so the shell can surface them instead of the save
105
+ * reading as fully landed. */
106
+ onSaveWarnings?: (warnings: string[]) => void;
101
107
  /** Test hook: overrides the per-request payload budget (default 4MB). */
102
108
  saveChunkBudget?: number;
103
109
  /** Injected by EditorShell. Mirrors this hook's in-flight-save state out to the
@@ -109,6 +115,19 @@ interface PublishDeps {
109
115
  * collab wiring): the token degrades to MAX_SAFE_INTEGER, i.e. "nothing was
110
116
  * edited after capture" — the pre-generation wholesale-clear behavior. */
111
117
  getEditGeneration?: () => number;
118
+ /** Committed document manifest, merged with the staged entries this save
119
+ * commits. Optional so pre-documents callers keep compiling. */
120
+ documentManifest?: DocumentManifest;
121
+ pendingDocuments?: { id: string; entry: DocumentManifestEntry }[];
122
+ /** Ids of COMMITTED documents the editor has deleted but not yet saved. They
123
+ * ride the final request as `deletedDocumentIds`; a staged-but-uncommitted
124
+ * document deleted locally never appears here. */
125
+ pendingDocumentDeletions?: string[];
126
+ /** Receives the document ids this save actually committed. */
127
+ onDocumentsPublished?: (ids: string[]) => void;
128
+ /** Receives the deleted document ids the successful commit actually carried,
129
+ * so the caller can clear exactly those from its pending list. */
130
+ onDocumentsDeleted?: (ids: string[]) => void;
112
131
  }
113
132
 
114
133
  interface GatheredMedia {
@@ -141,9 +160,15 @@ export function useEditorPublish({
141
160
  getBaseVersion,
142
161
  recoverConflict,
143
162
  onBaselineCommitted,
163
+ onSaveWarnings,
144
164
  saveChunkBudget,
145
165
  savingRef,
146
166
  getEditGeneration,
167
+ documentManifest = {},
168
+ pendingDocuments = [],
169
+ pendingDocumentDeletions = [],
170
+ onDocumentsPublished,
171
+ onDocumentsDeleted,
147
172
  }: PublishDeps) {
148
173
  const [publishAction, setPublishAction] = useState<PublishAction>("idle");
149
174
  const [publishFeedback, setPublishFeedback] = useState<string | null>(null);
@@ -222,6 +247,27 @@ export function useEditorPublish({
222
247
  return { mediaUploads, updatedManifest, hasMediaChanges };
223
248
  }
224
249
 
250
+ /** Read staged document bytes out of Dexie and merge their entries onto the
251
+ * committed manifest. A row whose blob is gone (cleared storage) is skipped,
252
+ * never written to the manifest — the same no-orphan rule as media. */
253
+ async function gatherDocumentPayload(
254
+ pending: { id: string; entry: DocumentManifestEntry }[],
255
+ manifest: DocumentManifest,
256
+ ): Promise<{ uploads: DocumentUploadPayload[]; entries: { id: string; entry: DocumentManifestEntry }[] }> {
257
+ const uploads: DocumentUploadPayload[] = [];
258
+ const entries: { id: string; entry: DocumentManifestEntry }[] = [];
259
+ for (const { id, entry } of pending) {
260
+ if (manifest[id]) continue; // already committed by another writer
261
+ if (entry.storage === "git") {
262
+ const blob = await getPendingDocumentBlob(id);
263
+ if (!blob) continue;
264
+ uploads.push({ id, path: entry.path, base64: await blobToBase64(blob) });
265
+ }
266
+ entries.push({ id, entry });
267
+ }
268
+ return { uploads, entries };
269
+ }
270
+
225
271
  async function performSave(args: {
226
272
  siteConfig: SiteConfig;
227
273
  siteIndexRef: React.RefObject<SiteIndex>;
@@ -231,7 +277,11 @@ export function useEditorPublish({
231
277
  pendingMediaDeletions: string[];
232
278
  mediaManifest: MediaManifest;
233
279
  manifestDirty: boolean;
280
+ documentManifest: DocumentManifest;
281
+ pendingDocuments: { id: string; entry: DocumentManifestEntry }[];
282
+ pendingDocumentDeletions: string[];
234
283
  saveChunkBudget?: number;
284
+ onSaveWarnings?: (warnings: string[]) => void;
235
285
  }): Promise<{
236
286
  sha: string;
237
287
  // sectionId+updatedAt for compare-and-clear cleanup, plus the SENT content —
@@ -241,6 +291,10 @@ export function useEditorPublish({
241
291
  uploadedItems: MediaItem[];
242
292
  // The index actually sent (deletions applied) — the committed index state.
243
293
  sentIndex: SiteIndex;
294
+ committedDocumentIds: string[];
295
+ // The deleted ids the final request actually carried — empty when nothing
296
+ // was staged, and never populated on a failure (see the catch below).
297
+ committedDocumentDeletions: string[];
244
298
  }> {
245
299
  const dirty = await getDirtySectionRows();
246
300
  const gathered = await gatherMediaPayload(
@@ -258,6 +312,17 @@ export function useEditorPublish({
258
312
  );
259
313
  }
260
314
 
315
+ const gatheredDocs = await gatherDocumentPayload(args.pendingDocuments, args.documentManifest);
316
+ const { chunks: docChunks, oversized: oversizedDocs } = packDocumentUploads(gatheredDocs.uploads);
317
+ if (oversizedDocs.length > 0) {
318
+ const names = oversizedDocs
319
+ .map((u) => `"${args.pendingDocuments.find((d) => d.id === u.id)?.entry.filename ?? u.path}"`)
320
+ .join(", ");
321
+ throw new Error(
322
+ `${names} ${oversizedDocs.length === 1 ? "is" : "are"} too large to save — remove the document and try again.`,
323
+ );
324
+ }
325
+
261
326
  let siteIndex = args.siteIndexRef.current;
262
327
  if (args.deletedSectionIds?.length) {
263
328
  const deleteSet = new Set(args.deletedSectionIds);
@@ -287,8 +352,86 @@ export function useEditorPublish({
287
352
  const finalUploads: MediaUploadPayload[] = chunks.length > 0 ? chunks[chunks.length - 1] : [];
288
353
  const committedImages: Record<string, MediaItem> = {};
289
354
  let sent = 0;
355
+ // Hoisted out of the try so the catch's leadChunksCommitted guard can read
356
+ // what the document phase already committed.
357
+ const committedDocuments: Record<string, DocumentManifestEntry> = {};
358
+ const committedDocumentIds: string[] = [];
359
+
360
+ // Every request's sidecar payload: the committed view plus what THIS save
361
+ // has committed so far, minus the ids it is deleting. The subtraction is
362
+ // what makes a mixed upload+delete save safe — content-ops merges the
363
+ // sidecar anti-clobber, and the deletion only rides the FINAL request, so a
364
+ // doomed entry re-asserted by an intermediate document chunk would be
365
+ // re-merged onto the branch and outlive the save.
366
+ const doomedDocumentIds = new Set(args.pendingDocumentDeletions);
367
+ const documentManifestToSend = (): DocumentManifest => {
368
+ const merged: DocumentManifest = { ...args.documentManifest, ...committedDocuments };
369
+ for (const id of doomedDocumentIds) delete merged[id];
370
+ return merged;
371
+ };
290
372
 
291
373
  try {
374
+ // Documents commit FIRST, in their own requests: one 2.5MiB document
375
+ // base64s to ~3.4MB, so it can never share a request with sections/index
376
+ // (or with another document) inside Netlify's body limit.
377
+ //
378
+ // WIRE SHAPE (reconciliation decision 1): bytes ride the existing
379
+ // `media.uploads[].blobs` array — content-ops' path guard was widened to
380
+ // accept assets/documents/ exactly so this works with no second code path
381
+ // — and the sidecar travels as the TOP-LEVEL `documentManifest` key.
382
+ // There is no `documents:` key. `media.manifest` is deliberately omitted:
383
+ // a document chunk must not rewrite image-manifest.json from a stale copy.
384
+ // Each request's manifest carries only entries whose bytes it commits;
385
+ // content-ops merges anti-clobber, so repeated merges are idempotent.
386
+ const uploadedIds = new Set(gatheredDocs.uploads.map((u) => u.id));
387
+ const entryById = new Map(gatheredDocs.entries.map((e) => [e.id, e.entry]));
388
+
389
+ for (const chunk of docChunks) {
390
+ for (const upload of chunk) {
391
+ const entry = entryById.get(upload.id);
392
+ if (entry) committedDocuments[upload.id] = entry;
393
+ }
394
+ const data = await postSave({
395
+ baseVersion,
396
+ media: {
397
+ uploads: chunk.map(({ id, path, base64 }) => ({
398
+ // content-ops ignores `item` for blob emission; the folder segment
399
+ // keeps the payload legible in a request log.
400
+ item: { id, folder: path.split("/")[2] },
401
+ blobs: [{ path, base64 }],
402
+ })),
403
+ deletions: [],
404
+ },
405
+ documentManifest: documentManifestToSend(),
406
+ });
407
+ baseVersion = data.sha;
408
+ committedDocumentIds.push(...chunk.map((u) => u.id));
409
+ try {
410
+ await clearPendingDocumentsByIds(chunk.map((u) => u.id));
411
+ } catch (promoteError) {
412
+ console.error("Failed to clear committed documents locally:", promoteError);
413
+ }
414
+ }
415
+
416
+ // Bucket-tier entries have no bytes to ship; they ride one manifest-only
417
+ // request (no `media` key at all) so the manifest never references an
418
+ // uncommitted object.
419
+ const manifestOnly = gatheredDocs.entries.filter((e) => !uploadedIds.has(e.id));
420
+ if (manifestOnly.length > 0) {
421
+ for (const { id, entry } of manifestOnly) committedDocuments[id] = entry;
422
+ const data = await postSave({
423
+ baseVersion,
424
+ documentManifest: documentManifestToSend(),
425
+ });
426
+ baseVersion = data.sha;
427
+ committedDocumentIds.push(...manifestOnly.map((e) => e.id));
428
+ try {
429
+ await clearPendingDocumentsByIds(manifestOnly.map((e) => e.id));
430
+ } catch (promoteError) {
431
+ console.error("Failed to clear committed documents locally:", promoteError);
432
+ }
433
+ }
434
+
292
435
  for (const chunk of leadChunks) {
293
436
  setSaveProgress(`Saving images ${sent + chunk.length}/${totalUploads}…`);
294
437
  for (const upload of chunk) committedImages[upload.item.id] = upload.item;
@@ -335,13 +478,29 @@ export function useEditorPublish({
335
478
  manifest: gathered.updatedManifest,
336
479
  },
337
480
  } : {}),
481
+ // Deletions ride ONLY this request — never a document chunk — so a
482
+ // failure anywhere leaves them entirely uncommitted and the caller's
483
+ // pending list intact for a retry. That is also why, unlike
484
+ // committedDocumentIds, they need no catch-path promotion below.
485
+ ...(args.pendingDocumentDeletions.length > 0 ? {
486
+ deletedDocumentIds: args.pendingDocumentDeletions,
487
+ documentManifest: documentManifestToSend(),
488
+ } : {}),
338
489
  });
339
490
 
491
+ // Surface server-side warnings (e.g. the degraded-nav guard declining to
492
+ // replace nav.json) — the save succeeded, but part of it was refused, and
493
+ // the editor believing it landed is exactly the silence the guard exists
494
+ // to break.
495
+ if (responseData.warnings?.length) args.onSaveWarnings?.(responseData.warnings);
496
+
340
497
  return {
341
498
  sha: responseData.sha,
342
499
  savedSections: dirty.map(({ sectionId, updatedAt, content }) => ({ sectionId, updatedAt, content })),
343
500
  uploadedItems: [...uploadedItems, ...finalUploads.map((u) => u.item)],
344
501
  sentIndex: siteIndex,
502
+ committedDocumentIds,
503
+ committedDocumentDeletions: args.pendingDocumentDeletions,
345
504
  };
346
505
  } catch (error) {
347
506
  // I2: once a lead chunk has committed (advancing the draft head), a 409
@@ -349,9 +508,25 @@ export function useEditorPublish({
349
508
  // from this invocation's stale closure, dropping the just-promoted items
350
509
  // and orphaning their committed files. Mark the error so the recovery
351
510
  // wrapper falls through to the honest toast instead.
352
- if (sent > 0 && error && typeof error === "object") {
511
+ if ((sent > 0 || committedDocumentIds.length > 0) && error && typeof error === "object") {
353
512
  (error as { leadChunksCommitted?: boolean }).leadChunksCommitted = true;
354
513
  }
514
+ // Documents that DID commit are on the server, and their Dexie rows are
515
+ // already cleared — so promote them regardless of what failed afterwards.
516
+ // Without this their pendingDocuments entries never clear and
517
+ // hasPendingEdits() stays true forever: the retry skips the committed doc
518
+ // (gatherDocumentPayload finds no row), so nothing ever promotes them.
519
+ // Cannot double-promote: committedDocumentIds being non-empty sets
520
+ // leadChunksCommitted, which suppresses the 409 retry entirely — so at
521
+ // most one attempt per save ever reaches here with documents committed.
522
+ if (committedDocumentIds.length > 0) {
523
+ try {
524
+ onDocumentsPublished?.(committedDocumentIds);
525
+ } catch (promoteError) {
526
+ // Never let the promotion callback mask the real failure.
527
+ console.error("Failed to promote committed documents after a failed save:", promoteError);
528
+ }
529
+ }
355
530
  throw error;
356
531
  }
357
532
  }
@@ -387,8 +562,9 @@ export function useEditorPublish({
387
562
  async function hasPendingEdits(): Promise<boolean> {
388
563
  const hasChanges = await hasLocalChanges();
389
564
  const hasMediaChanges = pendingMediaItems.length > 0 || pendingMediaDeletions.length > 0 || manifestDirty;
565
+ const hasDocumentChanges = pendingDocuments.length > 0 || pendingDocumentDeletions.length > 0;
390
566
  const hasDeletedSections = (deletedSectionIds?.length ?? 0) > 0;
391
- return hasChanges || isConfigDirty() || hasMediaChanges || hasDeletedSections;
567
+ return hasChanges || isConfigDirty() || hasMediaChanges || hasDocumentChanges || hasDeletedSections;
392
568
  }
393
569
 
394
570
  function runPerformSave() {
@@ -400,7 +576,11 @@ export function useEditorPublish({
400
576
  // Read through the ref (when the shell supplies one) so the 409 retry
401
577
  // below sees recoverConflict's rebased manifest, not this render's copy.
402
578
  mediaManifest: mediaManifestRef?.current ?? mediaManifest,
579
+ documentManifest,
580
+ pendingDocuments,
581
+ pendingDocumentDeletions,
403
582
  saveChunkBudget,
583
+ onSaveWarnings,
404
584
  });
405
585
  }
406
586
 
@@ -479,7 +659,7 @@ export function useEditorPublish({
479
659
  return;
480
660
  }
481
661
 
482
- const { sha, savedSections, uploadedItems, sentIndex } = await saveWithConflictRecovery();
662
+ const { sha, savedSections, uploadedItems, sentIndex, committedDocumentIds, committedDocumentDeletions } = await saveWithConflictRecovery();
483
663
  onShasUpdated(sha, null);
484
664
 
485
665
  try {
@@ -504,6 +684,8 @@ export function useEditorPublish({
504
684
  });
505
685
  }
506
686
  finishSave(uploadedItems, savedSections.map((s) => s.sectionId));
687
+ if (committedDocumentIds.length > 0) onDocumentsPublished?.(committedDocumentIds);
688
+ if (committedDocumentDeletions.length > 0) onDocumentsDeleted?.(committedDocumentDeletions);
507
689
  } catch (cleanupError) {
508
690
  console.error("Save succeeded but local cleanup failed:", cleanupError);
509
691
  showFeedback("Saved, but the local cache couldn't be updated — reload the editor.", 8000);
@@ -519,7 +701,7 @@ export function useEditorPublish({
519
701
  setPublishAction("idle");
520
702
  setSaveProgress(null);
521
703
  }
522
- }, [flushNow, cancelPendingFlush, isConfigDirty, clearConfigDirty, siteIndexRef, siteConfig, sections, deletedSectionIds, onSuccess, mediaManifest, manifestDirty, clearManifestDirty, pendingMediaItems, pendingMediaDeletions, onMediaPublished, onShasUpdated, showFeedback, recoverConflict, onBaselineCommitted, saveChunkBudget, getEditGeneration]);
704
+ }, [flushNow, cancelPendingFlush, isConfigDirty, clearConfigDirty, siteIndexRef, siteConfig, sections, deletedSectionIds, onSuccess, mediaManifest, manifestDirty, clearManifestDirty, pendingMediaItems, pendingMediaDeletions, onMediaPublished, onShasUpdated, showFeedback, recoverConflict, onBaselineCommitted, saveChunkBudget, getEditGeneration, pendingDocumentDeletions]);
523
705
 
524
706
  const handlePublish = useCallback(async () => {
525
707
  setPublishAction("publishing");
@@ -568,8 +750,10 @@ export function useEditorPublish({
568
750
  let savedSections: { sectionId: string; updatedAt: string; content: SectionContent }[] = [];
569
751
  let uploadedItems: MediaItem[] = [];
570
752
  let sentIndex: SiteIndex | null = null;
753
+ let committedDocumentIds: string[] = [];
754
+ let committedDocumentDeletions: string[] = [];
571
755
  if (hasLocalEdits) {
572
- ({ savedSections, uploadedItems, sentIndex } = await saveWithConflictRecovery());
756
+ ({ savedSections, uploadedItems, sentIndex, committedDocumentIds, committedDocumentDeletions } = await saveWithConflictRecovery());
573
757
  }
574
758
 
575
759
  setPublishAction("publishing");
@@ -607,6 +791,8 @@ export function useEditorPublish({
607
791
  });
608
792
  await cacheContent(sha, baselineSections, sentIndex!, siteConfig!);
609
793
  finishSave(uploadedItems, savedSections.map((s) => s.sectionId));
794
+ if (committedDocumentIds.length > 0) onDocumentsPublished?.(committedDocumentIds);
795
+ if (committedDocumentDeletions.length > 0) onDocumentsDeleted?.(committedDocumentDeletions);
610
796
  } catch (cleanupError) {
611
797
  console.error("Publish succeeded but local cleanup failed:", cleanupError);
612
798
  onShasUpdated(null, sha);
@@ -626,7 +812,7 @@ export function useEditorPublish({
626
812
  setPublishAction("idle");
627
813
  setSaveProgress(null);
628
814
  }
629
- }, [flushNow, cancelPendingFlush, isConfigDirty, clearConfigDirty, siteIndexRef, siteConfig, sections, deletedSectionIds, onSuccess, mediaManifest, manifestDirty, clearManifestDirty, pendingMediaItems, pendingMediaDeletions, onMediaPublished, onShasUpdated, showFeedback, onPublishComplete, recoverConflict, saveChunkBudget, getEditGeneration]);
815
+ }, [flushNow, cancelPendingFlush, isConfigDirty, clearConfigDirty, siteIndexRef, siteConfig, sections, deletedSectionIds, onSuccess, mediaManifest, manifestDirty, clearManifestDirty, pendingMediaItems, pendingMediaDeletions, onMediaPublished, onShasUpdated, showFeedback, onPublishComplete, recoverConflict, saveChunkBudget, getEditGeneration, pendingDocumentDeletions]);
630
816
 
631
817
  return { publishAction, publishFeedback, saveProgress, handleSave, handlePublish, handleSaveAndPublish };
632
818
  }
@@ -15,16 +15,21 @@ import {
15
15
  import { generateVideoPoster } from "../media/videoPoster";
16
16
  import { ASSET_CACHE_CONTROL } from "../storage/types";
17
17
  import { tallyImageIds, collectImageIds, clearImageIds, applyLogoDeletion } from "../lib/image-refs";
18
+ import { clearDocumentRefs, tallyDocumentIds } from "../lib/document-refs";
18
19
  import {
19
20
  createFolderId, uniqueFolderName, effectiveParentId, canMoveFolder,
20
21
  descendantFolderIds, itemFolderId, planFolderUpload,
21
22
  } from "../lib/media-folders";
22
23
  import type { MediaFolder } from "../media/types";
24
+ import { useDocumentUploads } from "./useDocumentUploads";
25
+ import { DOCUMENT_MIME_TO_EXT, type DocumentManifest } from "../schemas/documents";
23
26
 
24
27
  interface UseMediaPipelineInput {
25
28
  siteConfig: SiteConfig | null;
26
29
  mediaManifest: MediaManifest;
27
30
  setMediaManifest: React.Dispatch<React.SetStateAction<MediaManifest>>;
31
+ documentManifest: DocumentManifest;
32
+ setDocumentManifest: React.Dispatch<React.SetStateAction<DocumentManifest>>;
28
33
  sections: LoadedSection[];
29
34
  setSections: React.Dispatch<React.SetStateAction<LoadedSection[]>>;
30
35
  setLocalChangesExist: React.Dispatch<React.SetStateAction<boolean>>;
@@ -47,6 +52,8 @@ export function useMediaPipeline({
47
52
  siteConfig,
48
53
  mediaManifest,
49
54
  setMediaManifest,
55
+ documentManifest,
56
+ setDocumentManifest,
50
57
  sections,
51
58
  setSections,
52
59
  setLocalChangesExist,
@@ -60,6 +67,10 @@ export function useMediaPipeline({
60
67
  const [pendingMediaItems, setPendingMediaItems] = useState<LibraryMediaItem[]>([]);
61
68
  const [pendingLocalUrls, setPendingLocalUrls] = useState<Record<string, string>>({});
62
69
  const [pendingDeletions, setPendingDeletions] = useState<string[]>([]);
70
+ // Committed documents deleted locally, awaiting the save that carries them as
71
+ // `deletedDocumentIds`. Staged-but-uncommitted documents never land here —
72
+ // they are simply unstaged.
73
+ const [pendingDocumentDeletions, setPendingDocumentDeletions] = useState<string[]>([]);
63
74
  // Direct-to-bucket large-video PUT progress, keyed by content hash (0..1).
64
75
  // Populated by uploadLargeVideo's XHR upload.onprogress, cleared on
65
76
  // completion or failure.
@@ -72,6 +83,34 @@ export function useMediaPipeline({
72
83
  // dropped when the editor DOES save in the same session.
73
84
  const [manifestDirty, setManifestDirty] = useState(false);
74
85
 
86
+ // Read inside the queue effect, whose deps are deliberately narrow — without
87
+ // the ref the error branch below would close over the first render's handler.
88
+ const onUploadErrorRef = useRef(onUploadError);
89
+ onUploadErrorRef.current = onUploadError;
90
+
91
+ // Read inside uploadDocument's async body, where a state snapshot taken at
92
+ // render time would be stale across the hash await.
93
+ const pendingDocumentDeletionsRef = useRef<string[]>(pendingDocumentDeletions);
94
+ pendingDocumentDeletionsRef.current = pendingDocumentDeletions;
95
+
96
+ /** Re-adding a document that is deleted-but-unsaved un-dooms it rather than
97
+ * letting the upload dedup swallow it — the twin of the image pipeline's
98
+ * `isDeleted` escape below. The ref is updated eagerly so two re-adds in one
99
+ * tick can't both see the id as doomed. */
100
+ const undeleteDocument = useCallback((id: string) => {
101
+ if (!pendingDocumentDeletionsRef.current.includes(id)) return;
102
+ pendingDocumentDeletionsRef.current = pendingDocumentDeletionsRef.current.filter((d) => d !== id);
103
+ setPendingDocumentDeletions((prev) => prev.filter((d) => d !== id));
104
+ }, []);
105
+
106
+ const documents = useDocumentUploads({
107
+ documentManifest,
108
+ setDocumentManifest,
109
+ setLocalChangesExist,
110
+ undeleteDocument,
111
+ onUploadError,
112
+ });
113
+
75
114
  const queueRef = useRef<ProcessingQueue | null>(null);
76
115
  const uploadCallbacksRef = useRef<Map<string, (imageId: string) => void>>(new Map());
77
116
  // Display-folder target per in-flight upload, keyed by content hash. Set at
@@ -138,10 +177,22 @@ export function useMediaPipeline({
138
177
  onEvent: (event: QueueEvent) => {
139
178
  setProcessingItems((prev) => {
140
179
  const next = prev.filter((i) => i.id !== event.item.id);
141
- if (event.item.state !== "complete") next.push(event.item);
180
+ // "error" must LEAVE the list too. Keeping it (the old `!== "complete"`
181
+ // test) is what left a failed item spinning forever in the toolbar.
182
+ if (event.item.state !== "complete" && event.item.state !== "error") next.push(event.item);
142
183
  return next;
143
184
  });
144
185
 
186
+ if (event.type === "error") {
187
+ uploadTargetsRef.current.delete(event.item.hash);
188
+ hiddenUploadsRef.current.delete(event.item.hash);
189
+ uploadCallbacksRef.current.delete(event.item.hash);
190
+ onUploadErrorRef.current?.(
191
+ `"${event.item.originalName}" couldn't be processed${event.item.error ? ` — ${event.item.error}` : ""}.`,
192
+ );
193
+ return;
194
+ }
195
+
145
196
  if (event.type === "complete" && event.item.result) {
146
197
  const result = event.item.result;
147
198
  const kind = event.item.kind;
@@ -359,6 +410,14 @@ export function useMediaPipeline({
359
410
 
360
411
  let alreadyExisting = 0;
361
412
  for (const file of files) {
413
+ // Documents never touch the rasterizer: their bytes must reach the repo
414
+ // or the bucket untransformed. Branch before every image-shaped gate
415
+ // below (size cap, hashing, queue.add).
416
+ if (file.type in DOCUMENT_MIME_TO_EXT) {
417
+ await documents.uploadDocument(file);
418
+ continue;
419
+ }
420
+
362
421
  if (file.size > mediaConfig.maxFileSize) {
363
422
  if (file.type === "video/mp4" || file.type === "video/webm") {
364
423
  // uploadLargeVideo already guarantees it never throws (see its own
@@ -406,7 +465,7 @@ export function useMediaPipeline({
406
465
  : `${alreadyExisting} files already exist in the library and were left where they are.`,
407
466
  );
408
467
  }
409
- }, [siteConfig, mediaManifest, pendingMediaItems, pendingDeletions, onUploadError, onUploadNotice, uploadLargeVideo]);
468
+ }, [siteConfig, mediaManifest, pendingMediaItems, pendingDeletions, onUploadError, onUploadNotice, uploadLargeVideo, documents]);
410
469
 
411
470
  const handleMediaDelete = useCallback(async (ids: string[]) => {
412
471
  const idSet = new Set(ids);
@@ -462,6 +521,41 @@ export function useMediaPipeline({
462
521
  setLocalChangesExist(true);
463
522
  }, [pendingMediaItems, markSectionDirty, setSections, setDirtySectionIds, setLocalChangesExist, siteConfig, onSiteConfigChange]);
464
523
 
524
+ // Document deletion, the twin of handleMediaDelete. It lives here rather than
525
+ // in useDocumentUploads because the reference-repair pass needs the section
526
+ // state that hook cannot see; useDocumentUploads owns only the staged-row
527
+ // cleanup (unstageDocuments).
528
+ const handleDocumentDelete = useCallback(async (ids: string[]) => {
529
+ if (ids.length === 0) return;
530
+ const idSet = new Set(ids);
531
+ const stagedIds = documents.pendingDocuments.filter((d) => idSet.has(d.id)).map((d) => d.id);
532
+ // Only a COMMITTED document produces a deletion for the save to carry: a
533
+ // staged one has no manifest entry on the branch, so recording it would ask
534
+ // the server to remove something that was never written.
535
+ const committedIds = ids.filter((id) => id in documentManifest);
536
+
537
+ if (stagedIds.length > 0) await documents.unstageDocuments(stagedIds);
538
+ if (committedIds.length > 0) {
539
+ // No optimistic documentManifest mutation: the consumption points filter
540
+ // on this list, and the save co-sends the post-deletion manifest.
541
+ setPendingDocumentDeletions((prev) => [...prev, ...committedIds.filter((id) => !prev.includes(id))]);
542
+ }
543
+
544
+ setSections((prev) =>
545
+ prev.map((loaded) => {
546
+ const { changed, section } = clearDocumentRefs(loaded.section, idSet);
547
+ if (!changed) return loaded;
548
+ // A nested hit returns the rewritten CONTAINER — the section that owns
549
+ // the child's bytes, and so the one that gets dirtied and saved.
550
+ markSectionDirty(section.id, section as SectionContent);
551
+ setDirtySectionIds((dirty) => new Set(dirty).add(section.id));
552
+ return { ...loaded, section };
553
+ }),
554
+ );
555
+
556
+ setLocalChangesExist(true);
557
+ }, [documents, documentManifest, markSectionDirty, setSections, setDirtySectionIds, setLocalChangesExist]);
558
+
465
559
  const handleMediaAltChange = useCallback((id: string, alt: string) => {
466
560
  setMediaManifest((prev) => {
467
561
  const item = prev.images[id];
@@ -608,12 +702,33 @@ export function useMediaPipeline({
608
702
  return Object.fromEntries(counts);
609
703
  }, [sections, siteConfig]);
610
704
 
705
+ // The document twin of referenceCountMap, feeding the media modal's document
706
+ // usage pills and its delete confirmation. Unlike images, documents are never
707
+ // referenced by siteConfig — only by `document` sections, nested ones
708
+ // included (tallyDocumentIds recurses).
709
+ const documentReferenceCountMap = useMemo((): Record<string, number> => {
710
+ const counts = new Map<string, number>();
711
+ for (const { section } of sections) tallyDocumentIds(section, counts);
712
+ return Object.fromEntries(counts);
713
+ }, [sections]);
714
+
611
715
  // --- Unified enqueue ---
612
716
 
613
717
  const enqueueFile = useCallback((file: File, onComplete?: (imageId: string) => void, opts?: { hidden?: boolean }) => {
614
718
  if (!siteConfig) return;
615
719
  const mediaConfig = siteConfig.media;
616
- if (file.size > mediaConfig.maxFileSize) return;
720
+ if (file.type in DOCUMENT_MIME_TO_EXT) {
721
+ void documents.uploadDocument(file, onComplete);
722
+ return;
723
+ }
724
+ if (file.size > mediaConfig.maxFileSize) {
725
+ // Previously a bare `return` — the file vanished with no spinner, no
726
+ // error, and no explanation.
727
+ onUploadError?.(
728
+ `"${file.name}" is larger than the ${Math.round(mediaConfig.maxFileSize / 1024 / 1024)}MB media limit.`,
729
+ );
730
+ return;
731
+ }
617
732
 
618
733
  const currentQueue = queueRef.current;
619
734
  (async () => {
@@ -645,7 +760,7 @@ export function useMediaPipeline({
645
760
  console.error(`[useMediaPipeline] Failed to enqueue file "${file.name}":`, err);
646
761
  }
647
762
  })();
648
- }, [siteConfig, mediaManifest, pendingMediaItems, pendingDeletions]);
763
+ }, [siteConfig, mediaManifest, pendingMediaItems, pendingDeletions, onUploadError, documents]);
649
764
 
650
765
  // --- Context value ---
651
766
 
@@ -669,7 +784,13 @@ export function useMediaPipeline({
669
784
  pendingItems: pendingMediaItems,
670
785
  pendingLocalUrls: pendingLocalUrls,
671
786
  siteConfig,
672
- }), [handleMediaUpload, enqueueFile, mediaManifest, pendingMediaItems, pendingLocalUrls, siteConfig]);
787
+ // Read by useResolvedDocument (DocumentSourceField's entry/missing lookup).
788
+ // Document blob URLs deliberately do NOT merge into pendingLocalUrls: their
789
+ // one consumer is the doc-file registry, which U6 feeds straight from the
790
+ // pipeline's pendingDocumentUrls — a context copy would be dead surface.
791
+ documentManifest,
792
+ pendingDocuments: documents.pendingDocuments,
793
+ }), [handleMediaUpload, enqueueFile, mediaManifest, pendingMediaItems, pendingLocalUrls, siteConfig, documentManifest, documents.pendingDocuments]);
673
794
 
674
795
  return {
675
796
  processingItems,
@@ -684,6 +805,9 @@ export function useMediaPipeline({
684
805
  uploadProgress,
685
806
  handleMediaUpload,
686
807
  handleMediaDelete,
808
+ handleDocumentDelete,
809
+ pendingDocumentDeletions,
810
+ setPendingDocumentDeletions,
687
811
  handleMediaAltChange,
688
812
  handleFolderCreate,
689
813
  handleFolderRename,
@@ -692,7 +816,14 @@ export function useMediaPipeline({
692
816
  handleMediaMove,
693
817
  handleFolderUpload,
694
818
  referenceCountMap,
819
+ documentReferenceCountMap,
695
820
  contextValue,
696
821
  enqueueFile,
822
+ pendingDocuments: documents.pendingDocuments,
823
+ pendingDocumentUrls: documents.pendingDocumentUrls,
824
+ documentUploadProgress: documents.documentUploadProgress,
825
+ uploadDocument: documents.uploadDocument,
826
+ publishDocuments: documents.publishDocuments,
827
+ hydratePendingDocuments: documents.hydratePendingDocuments,
697
828
  };
698
829
  }
@@ -0,0 +1,25 @@
1
+ import { useMemo } from "react";
2
+ import { useMediaLibrary } from "../components/shell/MediaLibraryContext";
3
+ import type { DocumentManifestEntry } from "../schemas/documents";
4
+
5
+ export interface ResolvedDocument {
6
+ entry: DocumentManifestEntry | null;
7
+ /** True only when an id WAS requested and nothing backs it — the editor-side
8
+ * "file missing" state. No id (unconfigured section) is not missing. */
9
+ missing: boolean;
10
+ }
11
+
12
+ /** Document lookup. Deliberately separate from useResolvedMedia: documents are
13
+ * zero-variant and dimensionless, never ride resolveManifestReferences, and
14
+ * live in the sidecar manifest. */
15
+ export function useResolvedDocument(documentId: string | undefined): ResolvedDocument {
16
+ const ctx = useMediaLibrary();
17
+
18
+ return useMemo(() => {
19
+ if (!documentId) return { entry: null, missing: false };
20
+ const committed = ctx?.documentManifest?.[documentId];
21
+ const pending = ctx?.pendingDocuments?.find((d) => d.id === documentId)?.entry;
22
+ const entry = committed ?? pending ?? null;
23
+ return { entry, missing: entry === null };
24
+ }, [documentId, ctx]);
25
+ }