@drawnagency/primitives 0.1.99 → 0.1.101

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 (120) hide show
  1. package/dist/{chunk-FAZFADJK.js → chunk-3TO4RRHY.js} +3 -1
  2. package/dist/{chunk-HKHEAH5F.js → chunk-6SLGKRLW.js} +8 -60
  3. package/dist/chunk-BS5AQHZG.js +45 -0
  4. package/dist/{chunk-GYCBWGVW.js → chunk-LBHQWIVL.js} +79 -24
  5. package/dist/{chunk-KP4DWTPR.js → chunk-P3NNI3QI.js} +328 -4
  6. package/dist/components/chat/ChatPanel.d.ts.map +1 -1
  7. package/dist/components/docfile/DocFileOverlay.d.ts +12 -0
  8. package/dist/components/docfile/DocFileOverlay.d.ts.map +1 -0
  9. package/dist/components/docfile/DocFilePane.d.ts +17 -0
  10. package/dist/components/docfile/DocFilePane.d.ts.map +1 -0
  11. package/dist/components/docfile/doc-file-state.d.ts +38 -0
  12. package/dist/components/docfile/doc-file-state.d.ts.map +1 -0
  13. package/dist/components/editor/SettingsForm.d.ts.map +1 -1
  14. package/dist/components/editor/index.js +1 -1
  15. package/dist/components/primitives/DocumentDropZone.d.ts +18 -0
  16. package/dist/components/primitives/DocumentDropZone.d.ts.map +1 -0
  17. package/dist/components/sections/Document/index.d.ts +24 -0
  18. package/dist/components/sections/Document/index.d.ts.map +1 -0
  19. package/dist/components/sections/all-sections.d.ts +10 -0
  20. package/dist/components/sections/all-sections.d.ts.map +1 -1
  21. package/dist/components/sections/brand-guide.d.ts +10 -0
  22. package/dist/components/sections/brand-guide.d.ts.map +1 -1
  23. package/dist/components/sections/brand-guide.js +3 -1
  24. package/dist/components/sections/register-schemas.js +5 -3
  25. package/dist/components/sections/register.js +5 -3
  26. package/dist/components/shared/DocumentSourceField.d.ts +13 -0
  27. package/dist/components/shared/DocumentSourceField.d.ts.map +1 -0
  28. package/dist/components/shared/Navigation.d.ts.map +1 -1
  29. package/dist/components/shell/EditorModal.d.ts.map +1 -1
  30. package/dist/components/shell/EditorShell.d.ts.map +1 -1
  31. package/dist/components/shell/MediaBreadcrumb.d.ts.map +1 -1
  32. package/dist/components/shell/MediaFolderTile.d.ts +3 -1
  33. package/dist/components/shell/MediaFolderTile.d.ts.map +1 -1
  34. package/dist/components/shell/MediaFolderTree.d.ts +3 -0
  35. package/dist/components/shell/MediaFolderTree.d.ts.map +1 -1
  36. package/dist/components/shell/MediaLibraryContext.d.ts +24 -1
  37. package/dist/components/shell/MediaLibraryContext.d.ts.map +1 -1
  38. package/dist/components/shell/MediaLibraryModal.d.ts +21 -1
  39. package/dist/components/shell/MediaLibraryModal.d.ts.map +1 -1
  40. package/dist/hooks/useDocumentUploads.d.ts +32 -0
  41. package/dist/hooks/useDocumentUploads.d.ts.map +1 -0
  42. package/dist/hooks/useEditorPublish.d.ts +18 -1
  43. package/dist/hooks/useEditorPublish.d.ts.map +1 -1
  44. package/dist/hooks/useMediaPipeline.d.ts +14 -1
  45. package/dist/hooks/useMediaPipeline.d.ts.map +1 -1
  46. package/dist/hooks/useResolvedDocument.d.ts +12 -0
  47. package/dist/hooks/useResolvedDocument.d.ts.map +1 -0
  48. package/dist/index.js +56 -32
  49. package/dist/lib/dexie.d.ts +13 -0
  50. package/dist/lib/dexie.d.ts.map +1 -1
  51. package/dist/lib/dexie.js +39 -2
  52. package/dist/lib/document-refs.d.ts +22 -0
  53. package/dist/lib/document-refs.d.ts.map +1 -0
  54. package/dist/lib/document-uploads.d.ts +38 -0
  55. package/dist/lib/document-uploads.d.ts.map +1 -0
  56. package/dist/lib/documents.d.ts +65 -0
  57. package/dist/lib/documents.d.ts.map +1 -0
  58. package/dist/lib/index.js +4 -4
  59. package/dist/lib/media-folders.d.ts +8 -0
  60. package/dist/lib/media-folders.d.ts.map +1 -1
  61. package/dist/lib/media-folders.js +2 -0
  62. package/dist/lib/nav.d.ts +29 -1
  63. package/dist/lib/nav.d.ts.map +1 -1
  64. package/dist/lib/platform-broker.d.ts +12 -0
  65. package/dist/lib/platform-broker.d.ts.map +1 -1
  66. package/dist/lib/platform-broker.js +12 -3
  67. package/dist/lib/registry.d.ts +10 -0
  68. package/dist/lib/registry.d.ts.map +1 -1
  69. package/dist/lib/save-chunks.d.ts +15 -0
  70. package/dist/lib/save-chunks.d.ts.map +1 -1
  71. package/dist/lib/scroll-lock.d.ts +2 -0
  72. package/dist/lib/scroll-lock.d.ts.map +1 -0
  73. package/dist/schemas/documents.d.ts +80 -0
  74. package/dist/schemas/documents.d.ts.map +1 -0
  75. package/dist/schemas/documents.js +25 -0
  76. package/dist/schemas/index.d.ts +1 -0
  77. package/dist/schemas/index.d.ts.map +1 -1
  78. package/dist/schemas/index.js +23 -1
  79. package/dist/storage/index.d.ts +2 -2
  80. package/dist/storage/index.d.ts.map +1 -1
  81. package/dist/storage/index.js +3 -1
  82. package/dist/storage/types.d.ts +59 -3
  83. package/dist/storage/types.d.ts.map +1 -1
  84. package/package.json +5 -1
  85. package/src/components/chat/ChatPanel.tsx +5 -4
  86. package/src/components/docfile/DocFileOverlay.tsx +54 -0
  87. package/src/components/docfile/DocFilePane.tsx +133 -0
  88. package/src/components/docfile/doc-file-state.ts +208 -0
  89. package/src/components/editor/SettingsForm.tsx +10 -0
  90. package/src/components/primitives/DocumentDropZone.tsx +79 -0
  91. package/src/components/sections/Document/index.tsx +194 -0
  92. package/src/components/sections/brand-guide.ts +2 -1
  93. package/src/components/shared/DocumentSourceField.tsx +51 -0
  94. package/src/components/shared/Navigation.tsx +53 -10
  95. package/src/components/shell/EditorModal.tsx +2 -5
  96. package/src/components/shell/EditorShell.tsx +132 -8
  97. package/src/components/shell/MediaBreadcrumb.tsx +14 -1
  98. package/src/components/shell/MediaFolderTile.tsx +4 -2
  99. package/src/components/shell/MediaFolderTree.tsx +20 -3
  100. package/src/components/shell/MediaLibraryContext.tsx +24 -1
  101. package/src/components/shell/MediaLibraryModal.tsx +223 -45
  102. package/src/hooks/useDocumentUploads.ts +336 -0
  103. package/src/hooks/useEditorPublish.ts +180 -8
  104. package/src/hooks/useMediaPipeline.ts +141 -5
  105. package/src/hooks/useResolvedDocument.ts +25 -0
  106. package/src/lib/dexie.ts +66 -1
  107. package/src/lib/document-refs.ts +83 -0
  108. package/src/lib/document-uploads.ts +66 -0
  109. package/src/lib/documents.ts +138 -0
  110. package/src/lib/media-folders.ts +9 -0
  111. package/src/lib/nav.ts +46 -2
  112. package/src/lib/platform-broker.ts +18 -3
  113. package/src/lib/registry.ts +9 -0
  114. package/src/lib/save-chunks.ts +42 -0
  115. package/src/lib/scroll-lock.ts +27 -0
  116. package/src/schemas/documents.ts +82 -0
  117. package/src/schemas/index.ts +1 -0
  118. package/src/storage/index.ts +2 -2
  119. package/src/storage/types.ts +58 -4
  120. /package/dist/{chunk-FOY5TF7D.js → chunk-ZDQ3TT47.js} +0 -0
@@ -0,0 +1,336 @@
1
+ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
2
+ import { DOCUMENT_CACHE_CONTROL } from "../storage/types";
3
+ import { hashFileBuffer } from "../media/utils";
4
+ import type { DocumentManifest, DocumentManifestEntry } from "../schemas/documents";
5
+ import {
6
+ planDocumentUpload,
7
+ documentRejectionMessage,
8
+ documentGitPath,
9
+ documentBucketPath,
10
+ type DocumentRejectionReason,
11
+ } from "../lib/document-uploads";
12
+ import {
13
+ addPendingDocument,
14
+ clearPendingDocumentsByIds,
15
+ getPendingDocuments,
16
+ } from "../lib/dexie";
17
+ import type { PendingDocument } from "../components/shell/MediaLibraryContext";
18
+
19
+ const REJECTION_CODES: ReadonlySet<string> = new Set([
20
+ "unsupported_type",
21
+ "too_large",
22
+ "too_small",
23
+ "no_documents_store",
24
+ ]);
25
+
26
+ interface UseDocumentUploadsInput {
27
+ documentManifest: DocumentManifest;
28
+ setDocumentManifest: React.Dispatch<React.SetStateAction<DocumentManifest>>;
29
+ setLocalChangesExist: React.Dispatch<React.SetStateAction<boolean>>;
30
+ /** Drops a committed-but-locally-deleted id from the pipeline's
31
+ * `pendingDocumentDeletions`. That list lives in useMediaPipeline and is
32
+ * invisible here, so without this the dedup below cannot tell a live
33
+ * document from a doomed one. No-op for an id that isn't doomed. */
34
+ undeleteDocument?: (id: string) => void;
35
+ /** Same channel the media pipeline uses — surfaces in UploadRejectionAlert. */
36
+ onUploadError?: (message: string) => void;
37
+ }
38
+
39
+ /**
40
+ * Document uploads. Deliberately a separate hook from useMediaPipeline: bytes
41
+ * pass through untouched (no rasterizer worker, no variants, no poster), the
42
+ * manifest is a sidecar, and every rejection is typed. Both tiers must (1) never
43
+ * throw to the caller, (2) call onUploadError exactly once on failure, and (3)
44
+ * leave no leaked progress entry.
45
+ */
46
+ export function useDocumentUploads({
47
+ documentManifest,
48
+ setDocumentManifest,
49
+ setLocalChangesExist,
50
+ undeleteDocument,
51
+ onUploadError,
52
+ }: UseDocumentUploadsInput) {
53
+ const [pendingDocuments, setPendingDocuments] = useState<PendingDocument[]>([]);
54
+ const [pendingDocumentUrls, setPendingDocumentUrls] = useState<Record<string, string>>({});
55
+ const [documentUploadProgress, setDocumentUploadProgress] = useState<Record<string, number>>({});
56
+
57
+ // Read inside uploadDocument's async body, where a state snapshot would be
58
+ // stale across an await.
59
+ const pendingRef = useRef<PendingDocument[]>(pendingDocuments);
60
+ useEffect(() => { pendingRef.current = pendingDocuments; }, [pendingDocuments]);
61
+ const manifestRef = useRef<DocumentManifest>(documentManifest);
62
+ useEffect(() => { manifestRef.current = documentManifest; }, [documentManifest]);
63
+
64
+ /** Restore documents staged in a previous session (Dexie survives reload). */
65
+ const hydratePendingDocuments = useCallback(async () => {
66
+ // Never throws: it runs from a mount effect, before/independently of the
67
+ // editor store being initialized, and a failed restore must not take the
68
+ // shell down.
69
+ let rows: Awaited<ReturnType<typeof getPendingDocuments>>;
70
+ try {
71
+ rows = await getPendingDocuments();
72
+ } catch (err) {
73
+ console.error("[useDocumentUploads] Failed to restore staged documents:", err);
74
+ return;
75
+ }
76
+ if (rows.length === 0) return;
77
+ setPendingDocuments(rows.map(({ id, entry }) => ({ id, entry })));
78
+ // The persisted `blob:` URL died with the session that minted it, so a
79
+ // git-tier row (which carries its bytes) gets a fresh object URL here.
80
+ // Bucket-tier rows have no blob and keep their stored URL — theirs is a real
81
+ // remote one. The fallback keeps this function's never-throws contract if a
82
+ // round-tripped row comes back as something createObjectURL rejects.
83
+ setPendingDocumentUrls(
84
+ Object.fromEntries(
85
+ rows.map(({ id, localUrl, blob }) => {
86
+ if (!blob) return [id, localUrl];
87
+ try {
88
+ return [id, URL.createObjectURL(blob)];
89
+ } catch (err) {
90
+ console.error(`[useDocumentUploads] Failed to re-mint a URL for "${id}":`, err);
91
+ return [id, localUrl];
92
+ }
93
+ }),
94
+ ),
95
+ );
96
+ }, []);
97
+
98
+ const stage = useCallback(
99
+ async (id: string, entry: DocumentManifestEntry, blob: Blob | null, localUrl: string, filename: string): Promise<boolean> => {
100
+ try {
101
+ await addPendingDocument({ id, entry, blob, localUrl });
102
+ } catch (err) {
103
+ console.error(`[useDocumentUploads] Failed to stage "${filename}":`, err);
104
+ URL.revokeObjectURL(localUrl);
105
+ onUploadError?.(`"${filename}" couldn't be stored locally — try again.`);
106
+ return false;
107
+ }
108
+ setPendingDocuments((prev) => [...prev, { id, entry }]);
109
+ setPendingDocumentUrls((prev) => ({ ...prev, [id]: localUrl }));
110
+ setLocalChangesExist(true);
111
+ return true;
112
+ },
113
+ [onUploadError, setLocalChangesExist],
114
+ );
115
+
116
+ const uploadDocument = useCallback(
117
+ async (file: File, onComplete?: (documentId: string) => void): Promise<void> => {
118
+ const plan = planDocumentUpload({ name: file.name, size: file.size, type: file.type });
119
+ if (plan.tier === "rejected") {
120
+ onUploadError?.(documentRejectionMessage(plan.reason, file.name));
121
+ return;
122
+ }
123
+
124
+ let hash: string;
125
+ try {
126
+ hash = await hashFileBuffer(await file.arrayBuffer());
127
+ } catch (err) {
128
+ console.error(`[useDocumentUploads] Failed to read "${file.name}":`, err);
129
+ onUploadError?.(`"${file.name}" couldn't be read — try again.`);
130
+ return;
131
+ }
132
+
133
+ // Same bytes already known: reuse the id rather than staging a duplicate.
134
+ // Only ever hits on the GIT tier, where the content hash IS the id —
135
+ // bucket-tier ids are server-minted, so re-picking a large file stages a
136
+ // second entry. Accepted: the manifest is correct either way, and the
137
+ // publish-time docs GC collects whichever object stops being referenced.
138
+ if (manifestRef.current[hash] || pendingRef.current.some((d) => d.id === hash)) {
139
+ // A committed document deleted but not yet saved is only DOOMED, never
140
+ // gone: its bytes and its manifest entry are still on the branch, and
141
+ // deletion deliberately leaves documentManifest alone. So re-adding one
142
+ // un-dooms it — otherwise the drop zone swallows the file silently, and
143
+ // the picker points a section at an id the save then deletes. Nothing to
144
+ // re-upload either way. Tier-agnostic on purpose: a bucket-tier id can
145
+ // only reach this branch already staged (its ids are server-minted, so
146
+ // re-picking the file mints a new one), and un-dooming is right there too.
147
+ undeleteDocument?.(hash);
148
+ onComplete?.(hash);
149
+ return;
150
+ }
151
+
152
+ if (plan.tier === "git") {
153
+ const entry: DocumentManifestEntry = {
154
+ filename: file.name,
155
+ size: file.size,
156
+ kind: plan.kind,
157
+ storage: "git",
158
+ path: documentGitPath(file.name, hash, plan.ext),
159
+ };
160
+ const localUrl = URL.createObjectURL(file);
161
+ if (await stage(hash, entry, file, localUrl, file.name)) onComplete?.(hash);
162
+ return;
163
+ }
164
+
165
+ // The id for a BUCKET-tier document is minted server-side and comes back
166
+ // from upload-intent — never the client hash (decision 4). It keys both
167
+ // the object and the manifest entry, so staging under anything else would
168
+ // point the manifest at bytes that are not there. Declared out here so
169
+ // the `finally` can clear the progress entry it keys.
170
+ let documentId = "";
171
+ try {
172
+ let uploadUrl: string;
173
+ let filename: string;
174
+ try {
175
+ const res = await fetch("/api/documents/upload-intent", {
176
+ method: "POST",
177
+ headers: { "Content-Type": "application/json" },
178
+ body: JSON.stringify({ name: file.name, size: file.size, contentType: file.type }),
179
+ });
180
+ if (!res.ok) {
181
+ const body = (await res.json().catch(() => ({}))) as { error?: string };
182
+ const reason: DocumentRejectionReason =
183
+ body.error && REJECTION_CODES.has(body.error)
184
+ ? (body.error as DocumentRejectionReason)
185
+ : "no_documents_store";
186
+ onUploadError?.(documentRejectionMessage(reason, file.name));
187
+ return;
188
+ }
189
+ const intent = (await res.json()) as {
190
+ uploadUrl?: unknown; path?: unknown; documentId?: unknown; filename?: unknown; storage?: unknown;
191
+ };
192
+ if (
193
+ typeof intent.uploadUrl !== "string" ||
194
+ typeof intent.path !== "string" ||
195
+ typeof intent.documentId !== "string" ||
196
+ !/^[a-f0-9]{32}$/.test(intent.documentId)
197
+ ) {
198
+ throw new Error("malformed upload-intent response");
199
+ }
200
+ uploadUrl = intent.uploadUrl;
201
+ documentId = intent.documentId;
202
+ filename = typeof intent.filename === "string" && intent.filename ? intent.filename : file.name;
203
+ } catch (err) {
204
+ onUploadError?.(`"${file.name}": couldn't start the upload — ${(err as Error).message}. Try again.`);
205
+ return;
206
+ }
207
+
208
+ try {
209
+ await new Promise<void>((resolvePut, rejectPut) => {
210
+ const xhr = new XMLHttpRequest();
211
+ xhr.open("PUT", uploadUrl);
212
+ xhr.setRequestHeader("Content-Type", file.type);
213
+ // The presign signs cache-control;content-type;host — BOTH headers
214
+ // must be sent byte-exact or R2 403s the PUT, and R2 omits CORS
215
+ // headers on errors, so the browser misreports it as a CORS block.
216
+ // (The video path sets ASSET_CACHE_CONTROL the same way.)
217
+ xhr.setRequestHeader("Cache-Control", DOCUMENT_CACHE_CONTROL);
218
+ xhr.upload.onprogress = (e) => {
219
+ if (e.lengthComputable) {
220
+ setDocumentUploadProgress((prev) => ({ ...prev, [documentId]: e.loaded / e.total }));
221
+ }
222
+ };
223
+ xhr.onload = () =>
224
+ xhr.status >= 200 && xhr.status < 300 ? resolvePut() : rejectPut(new Error(`upload ${xhr.status}`));
225
+ xhr.onerror = () => rejectPut(new Error("the storage request was blocked or interrupted"));
226
+ xhr.send(file);
227
+ });
228
+ } catch (err) {
229
+ onUploadError?.(`"${file.name}": upload failed — ${(err as Error).message}. Try again.`);
230
+ return;
231
+ }
232
+
233
+ const entry: DocumentManifestEntry = {
234
+ filename,
235
+ size: file.size,
236
+ kind: plan.kind,
237
+ storage: "bucket",
238
+ // SITE-RELATIVE, derived from the server's id — deliberately NOT the
239
+ // intent's `path`, which is the tenant-prefixed object key
240
+ // (sites/{siteId}/…). DocumentStore prepends that prefix itself, and
241
+ // the GC and the delete broker both match on the site-relative form.
242
+ path: documentBucketPath(documentId, plan.ext),
243
+ };
244
+ // Manifest-only: the bytes are in the bucket, never in Dexie or /api/save.
245
+ const localUrl = URL.createObjectURL(file);
246
+ if (await stage(documentId, entry, null, localUrl, file.name)) onComplete?.(documentId);
247
+ } finally {
248
+ setDocumentUploadProgress((prev) => {
249
+ if (!documentId || !(documentId in prev)) return prev;
250
+ const next = { ...prev };
251
+ delete next[documentId];
252
+ return next;
253
+ });
254
+ }
255
+ },
256
+ [onUploadError, stage, undeleteDocument],
257
+ );
258
+
259
+ /** Called after a save commits these ids: promote them into the manifest,
260
+ * drop the staged rows, release the blob URLs. */
261
+ const publishDocuments = useCallback(
262
+ async (ids: string[]) => {
263
+ if (ids.length === 0) return;
264
+ const idSet = new Set(ids);
265
+ const promoted = pendingRef.current.filter((d) => idSet.has(d.id));
266
+ if (promoted.length > 0) {
267
+ setDocumentManifest((prev) => {
268
+ const next = { ...prev };
269
+ for (const { id, entry } of promoted) next[id] = entry;
270
+ return next;
271
+ });
272
+ }
273
+ setPendingDocuments((prev) => prev.filter((d) => !idSet.has(d.id)));
274
+ setPendingDocumentUrls((prev) => {
275
+ const next = { ...prev };
276
+ for (const id of ids) {
277
+ if (next[id]) {
278
+ URL.revokeObjectURL(next[id]);
279
+ delete next[id];
280
+ }
281
+ }
282
+ return next;
283
+ });
284
+ try {
285
+ await clearPendingDocumentsByIds(ids);
286
+ } catch (err) {
287
+ console.error("[useDocumentUploads] Failed to clear staged documents:", err);
288
+ }
289
+ },
290
+ [setDocumentManifest],
291
+ );
292
+
293
+ /** Drop staged-but-never-committed documents (the local-delete path): release
294
+ * the blob URLs and the Dexie rows. Deliberately manifest-free — nothing was
295
+ * ever committed, so a save has nothing to remove. A bucket-tier row's
296
+ * already-PUT object is left where it is; the publish-time docs GC collects
297
+ * whichever object stops being referenced (same acceptance as the re-pick
298
+ * dedup case above). */
299
+ const unstageDocuments = useCallback(async (ids: string[]) => {
300
+ if (ids.length === 0) return;
301
+ const idSet = new Set(ids);
302
+ setPendingDocuments((prev) => prev.filter((d) => !idSet.has(d.id)));
303
+ setPendingDocumentUrls((prev) => {
304
+ const next = { ...prev };
305
+ for (const id of ids) {
306
+ if (next[id]) {
307
+ URL.revokeObjectURL(next[id]);
308
+ delete next[id];
309
+ }
310
+ }
311
+ return next;
312
+ });
313
+ try {
314
+ await clearPendingDocumentsByIds(ids);
315
+ } catch (err) {
316
+ console.error("[useDocumentUploads] Failed to clear staged documents:", err);
317
+ }
318
+ }, []);
319
+
320
+ // Memoized: useMediaPipeline lists this object in handleMediaUpload's and
321
+ // enqueueFile's dependency arrays, and those two callbacks sit in
322
+ // contextValue's deps — a fresh literal every render would defeat all three
323
+ // memos and re-render every MediaLibraryContext consumer on every keystroke.
324
+ return useMemo(
325
+ () => ({
326
+ pendingDocuments,
327
+ pendingDocumentUrls,
328
+ documentUploadProgress,
329
+ uploadDocument,
330
+ publishDocuments,
331
+ unstageDocuments,
332
+ hydratePendingDocuments,
333
+ }),
334
+ [pendingDocuments, pendingDocumentUrls, documentUploadProgress, uploadDocument, publishDocuments, unstageDocuments, hydratePendingDocuments],
335
+ );
336
+ }
@@ -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
 
@@ -114,6 +115,19 @@ interface PublishDeps {
114
115
  * collab wiring): the token degrades to MAX_SAFE_INTEGER, i.e. "nothing was
115
116
  * edited after capture" — the pre-generation wholesale-clear behavior. */
116
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;
117
131
  }
118
132
 
119
133
  interface GatheredMedia {
@@ -150,6 +164,11 @@ export function useEditorPublish({
150
164
  saveChunkBudget,
151
165
  savingRef,
152
166
  getEditGeneration,
167
+ documentManifest = {},
168
+ pendingDocuments = [],
169
+ pendingDocumentDeletions = [],
170
+ onDocumentsPublished,
171
+ onDocumentsDeleted,
153
172
  }: PublishDeps) {
154
173
  const [publishAction, setPublishAction] = useState<PublishAction>("idle");
155
174
  const [publishFeedback, setPublishFeedback] = useState<string | null>(null);
@@ -228,6 +247,27 @@ export function useEditorPublish({
228
247
  return { mediaUploads, updatedManifest, hasMediaChanges };
229
248
  }
230
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
+
231
271
  async function performSave(args: {
232
272
  siteConfig: SiteConfig;
233
273
  siteIndexRef: React.RefObject<SiteIndex>;
@@ -237,6 +277,9 @@ export function useEditorPublish({
237
277
  pendingMediaDeletions: string[];
238
278
  mediaManifest: MediaManifest;
239
279
  manifestDirty: boolean;
280
+ documentManifest: DocumentManifest;
281
+ pendingDocuments: { id: string; entry: DocumentManifestEntry }[];
282
+ pendingDocumentDeletions: string[];
240
283
  saveChunkBudget?: number;
241
284
  onSaveWarnings?: (warnings: string[]) => void;
242
285
  }): Promise<{
@@ -248,6 +291,10 @@ export function useEditorPublish({
248
291
  uploadedItems: MediaItem[];
249
292
  // The index actually sent (deletions applied) — the committed index state.
250
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[];
251
298
  }> {
252
299
  const dirty = await getDirtySectionRows();
253
300
  const gathered = await gatherMediaPayload(
@@ -265,6 +312,17 @@ export function useEditorPublish({
265
312
  );
266
313
  }
267
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
+
268
326
  let siteIndex = args.siteIndexRef.current;
269
327
  if (args.deletedSectionIds?.length) {
270
328
  const deleteSet = new Set(args.deletedSectionIds);
@@ -294,8 +352,86 @@ export function useEditorPublish({
294
352
  const finalUploads: MediaUploadPayload[] = chunks.length > 0 ? chunks[chunks.length - 1] : [];
295
353
  const committedImages: Record<string, MediaItem> = {};
296
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
+ };
297
372
 
298
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
+
299
435
  for (const chunk of leadChunks) {
300
436
  setSaveProgress(`Saving images ${sent + chunk.length}/${totalUploads}…`);
301
437
  for (const upload of chunk) committedImages[upload.item.id] = upload.item;
@@ -342,6 +478,14 @@ export function useEditorPublish({
342
478
  manifest: gathered.updatedManifest,
343
479
  },
344
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
+ } : {}),
345
489
  });
346
490
 
347
491
  // Surface server-side warnings (e.g. the degraded-nav guard declining to
@@ -355,6 +499,8 @@ export function useEditorPublish({
355
499
  savedSections: dirty.map(({ sectionId, updatedAt, content }) => ({ sectionId, updatedAt, content })),
356
500
  uploadedItems: [...uploadedItems, ...finalUploads.map((u) => u.item)],
357
501
  sentIndex: siteIndex,
502
+ committedDocumentIds,
503
+ committedDocumentDeletions: args.pendingDocumentDeletions,
358
504
  };
359
505
  } catch (error) {
360
506
  // I2: once a lead chunk has committed (advancing the draft head), a 409
@@ -362,9 +508,25 @@ export function useEditorPublish({
362
508
  // from this invocation's stale closure, dropping the just-promoted items
363
509
  // and orphaning their committed files. Mark the error so the recovery
364
510
  // wrapper falls through to the honest toast instead.
365
- if (sent > 0 && error && typeof error === "object") {
511
+ if ((sent > 0 || committedDocumentIds.length > 0) && error && typeof error === "object") {
366
512
  (error as { leadChunksCommitted?: boolean }).leadChunksCommitted = true;
367
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
+ }
368
530
  throw error;
369
531
  }
370
532
  }
@@ -400,8 +562,9 @@ export function useEditorPublish({
400
562
  async function hasPendingEdits(): Promise<boolean> {
401
563
  const hasChanges = await hasLocalChanges();
402
564
  const hasMediaChanges = pendingMediaItems.length > 0 || pendingMediaDeletions.length > 0 || manifestDirty;
565
+ const hasDocumentChanges = pendingDocuments.length > 0 || pendingDocumentDeletions.length > 0;
403
566
  const hasDeletedSections = (deletedSectionIds?.length ?? 0) > 0;
404
- return hasChanges || isConfigDirty() || hasMediaChanges || hasDeletedSections;
567
+ return hasChanges || isConfigDirty() || hasMediaChanges || hasDocumentChanges || hasDeletedSections;
405
568
  }
406
569
 
407
570
  function runPerformSave() {
@@ -413,6 +576,9 @@ export function useEditorPublish({
413
576
  // Read through the ref (when the shell supplies one) so the 409 retry
414
577
  // below sees recoverConflict's rebased manifest, not this render's copy.
415
578
  mediaManifest: mediaManifestRef?.current ?? mediaManifest,
579
+ documentManifest,
580
+ pendingDocuments,
581
+ pendingDocumentDeletions,
416
582
  saveChunkBudget,
417
583
  onSaveWarnings,
418
584
  });
@@ -493,7 +659,7 @@ export function useEditorPublish({
493
659
  return;
494
660
  }
495
661
 
496
- const { sha, savedSections, uploadedItems, sentIndex } = await saveWithConflictRecovery();
662
+ const { sha, savedSections, uploadedItems, sentIndex, committedDocumentIds, committedDocumentDeletions } = await saveWithConflictRecovery();
497
663
  onShasUpdated(sha, null);
498
664
 
499
665
  try {
@@ -518,6 +684,8 @@ export function useEditorPublish({
518
684
  });
519
685
  }
520
686
  finishSave(uploadedItems, savedSections.map((s) => s.sectionId));
687
+ if (committedDocumentIds.length > 0) onDocumentsPublished?.(committedDocumentIds);
688
+ if (committedDocumentDeletions.length > 0) onDocumentsDeleted?.(committedDocumentDeletions);
521
689
  } catch (cleanupError) {
522
690
  console.error("Save succeeded but local cleanup failed:", cleanupError);
523
691
  showFeedback("Saved, but the local cache couldn't be updated — reload the editor.", 8000);
@@ -533,7 +701,7 @@ export function useEditorPublish({
533
701
  setPublishAction("idle");
534
702
  setSaveProgress(null);
535
703
  }
536
- }, [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]);
537
705
 
538
706
  const handlePublish = useCallback(async () => {
539
707
  setPublishAction("publishing");
@@ -582,8 +750,10 @@ export function useEditorPublish({
582
750
  let savedSections: { sectionId: string; updatedAt: string; content: SectionContent }[] = [];
583
751
  let uploadedItems: MediaItem[] = [];
584
752
  let sentIndex: SiteIndex | null = null;
753
+ let committedDocumentIds: string[] = [];
754
+ let committedDocumentDeletions: string[] = [];
585
755
  if (hasLocalEdits) {
586
- ({ savedSections, uploadedItems, sentIndex } = await saveWithConflictRecovery());
756
+ ({ savedSections, uploadedItems, sentIndex, committedDocumentIds, committedDocumentDeletions } = await saveWithConflictRecovery());
587
757
  }
588
758
 
589
759
  setPublishAction("publishing");
@@ -621,6 +791,8 @@ export function useEditorPublish({
621
791
  });
622
792
  await cacheContent(sha, baselineSections, sentIndex!, siteConfig!);
623
793
  finishSave(uploadedItems, savedSections.map((s) => s.sectionId));
794
+ if (committedDocumentIds.length > 0) onDocumentsPublished?.(committedDocumentIds);
795
+ if (committedDocumentDeletions.length > 0) onDocumentsDeleted?.(committedDocumentDeletions);
624
796
  } catch (cleanupError) {
625
797
  console.error("Publish succeeded but local cleanup failed:", cleanupError);
626
798
  onShasUpdated(null, sha);
@@ -640,7 +812,7 @@ export function useEditorPublish({
640
812
  setPublishAction("idle");
641
813
  setSaveProgress(null);
642
814
  }
643
- }, [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]);
644
816
 
645
817
  return { publishAction, publishFeedback, saveProgress, handleSave, handlePublish, handleSaveAndPublish };
646
818
  }