@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.
- package/dist/{chunk-FAZFADJK.js → chunk-3TO4RRHY.js} +3 -1
- package/dist/{chunk-HKHEAH5F.js → chunk-6SLGKRLW.js} +8 -60
- package/dist/chunk-BS5AQHZG.js +45 -0
- package/dist/{chunk-GYCBWGVW.js → chunk-LBHQWIVL.js} +79 -24
- package/dist/{chunk-KP4DWTPR.js → chunk-P3NNI3QI.js} +328 -4
- package/dist/components/chat/ChatPanel.d.ts.map +1 -1
- package/dist/components/docfile/DocFileOverlay.d.ts +12 -0
- package/dist/components/docfile/DocFileOverlay.d.ts.map +1 -0
- package/dist/components/docfile/DocFilePane.d.ts +17 -0
- package/dist/components/docfile/DocFilePane.d.ts.map +1 -0
- package/dist/components/docfile/doc-file-state.d.ts +38 -0
- package/dist/components/docfile/doc-file-state.d.ts.map +1 -0
- package/dist/components/editor/SettingsForm.d.ts.map +1 -1
- package/dist/components/editor/index.js +1 -1
- package/dist/components/primitives/DocumentDropZone.d.ts +18 -0
- package/dist/components/primitives/DocumentDropZone.d.ts.map +1 -0
- package/dist/components/sections/Document/index.d.ts +24 -0
- package/dist/components/sections/Document/index.d.ts.map +1 -0
- package/dist/components/sections/all-sections.d.ts +10 -0
- package/dist/components/sections/all-sections.d.ts.map +1 -1
- package/dist/components/sections/brand-guide.d.ts +10 -0
- package/dist/components/sections/brand-guide.d.ts.map +1 -1
- package/dist/components/sections/brand-guide.js +3 -1
- package/dist/components/sections/register-schemas.js +5 -3
- package/dist/components/sections/register.js +5 -3
- package/dist/components/shared/DocumentSourceField.d.ts +13 -0
- package/dist/components/shared/DocumentSourceField.d.ts.map +1 -0
- package/dist/components/shared/Navigation.d.ts.map +1 -1
- package/dist/components/shell/EditorModal.d.ts.map +1 -1
- package/dist/components/shell/EditorShell.d.ts.map +1 -1
- package/dist/components/shell/MediaBreadcrumb.d.ts.map +1 -1
- package/dist/components/shell/MediaFolderTile.d.ts +3 -1
- package/dist/components/shell/MediaFolderTile.d.ts.map +1 -1
- package/dist/components/shell/MediaFolderTree.d.ts +3 -0
- package/dist/components/shell/MediaFolderTree.d.ts.map +1 -1
- package/dist/components/shell/MediaLibraryContext.d.ts +24 -1
- package/dist/components/shell/MediaLibraryContext.d.ts.map +1 -1
- package/dist/components/shell/MediaLibraryModal.d.ts +21 -1
- package/dist/components/shell/MediaLibraryModal.d.ts.map +1 -1
- package/dist/hooks/useDocumentUploads.d.ts +32 -0
- package/dist/hooks/useDocumentUploads.d.ts.map +1 -0
- package/dist/hooks/useEditorPublish.d.ts +18 -1
- package/dist/hooks/useEditorPublish.d.ts.map +1 -1
- package/dist/hooks/useMediaPipeline.d.ts +14 -1
- package/dist/hooks/useMediaPipeline.d.ts.map +1 -1
- package/dist/hooks/useResolvedDocument.d.ts +12 -0
- package/dist/hooks/useResolvedDocument.d.ts.map +1 -0
- package/dist/index.js +56 -32
- package/dist/lib/dexie.d.ts +13 -0
- package/dist/lib/dexie.d.ts.map +1 -1
- package/dist/lib/dexie.js +39 -2
- package/dist/lib/document-refs.d.ts +22 -0
- package/dist/lib/document-refs.d.ts.map +1 -0
- package/dist/lib/document-uploads.d.ts +38 -0
- package/dist/lib/document-uploads.d.ts.map +1 -0
- package/dist/lib/documents.d.ts +65 -0
- package/dist/lib/documents.d.ts.map +1 -0
- package/dist/lib/index.js +4 -4
- package/dist/lib/media-folders.d.ts +8 -0
- package/dist/lib/media-folders.d.ts.map +1 -1
- package/dist/lib/media-folders.js +2 -0
- package/dist/lib/nav.d.ts +29 -1
- package/dist/lib/nav.d.ts.map +1 -1
- package/dist/lib/platform-broker.d.ts +12 -0
- package/dist/lib/platform-broker.d.ts.map +1 -1
- package/dist/lib/platform-broker.js +12 -3
- package/dist/lib/registry.d.ts +10 -0
- package/dist/lib/registry.d.ts.map +1 -1
- package/dist/lib/save-chunks.d.ts +15 -0
- package/dist/lib/save-chunks.d.ts.map +1 -1
- package/dist/lib/scroll-lock.d.ts +2 -0
- package/dist/lib/scroll-lock.d.ts.map +1 -0
- package/dist/schemas/documents.d.ts +80 -0
- package/dist/schemas/documents.d.ts.map +1 -0
- package/dist/schemas/documents.js +25 -0
- package/dist/schemas/index.d.ts +1 -0
- package/dist/schemas/index.d.ts.map +1 -1
- package/dist/schemas/index.js +23 -1
- package/dist/storage/index.d.ts +2 -2
- package/dist/storage/index.d.ts.map +1 -1
- package/dist/storage/index.js +3 -1
- package/dist/storage/types.d.ts +59 -3
- package/dist/storage/types.d.ts.map +1 -1
- package/package.json +5 -1
- package/src/components/chat/ChatPanel.tsx +5 -4
- package/src/components/docfile/DocFileOverlay.tsx +54 -0
- package/src/components/docfile/DocFilePane.tsx +133 -0
- package/src/components/docfile/doc-file-state.ts +208 -0
- package/src/components/editor/SettingsForm.tsx +10 -0
- package/src/components/primitives/DocumentDropZone.tsx +79 -0
- package/src/components/sections/Document/index.tsx +194 -0
- package/src/components/sections/brand-guide.ts +2 -1
- package/src/components/shared/DocumentSourceField.tsx +51 -0
- package/src/components/shared/Navigation.tsx +53 -10
- package/src/components/shell/EditorModal.tsx +2 -5
- package/src/components/shell/EditorShell.tsx +132 -8
- package/src/components/shell/MediaBreadcrumb.tsx +14 -1
- package/src/components/shell/MediaFolderTile.tsx +4 -2
- package/src/components/shell/MediaFolderTree.tsx +20 -3
- package/src/components/shell/MediaLibraryContext.tsx +24 -1
- package/src/components/shell/MediaLibraryModal.tsx +223 -45
- package/src/hooks/useDocumentUploads.ts +336 -0
- package/src/hooks/useEditorPublish.ts +180 -8
- package/src/hooks/useMediaPipeline.ts +141 -5
- package/src/hooks/useResolvedDocument.ts +25 -0
- package/src/lib/dexie.ts +66 -1
- package/src/lib/document-refs.ts +83 -0
- package/src/lib/document-uploads.ts +66 -0
- package/src/lib/documents.ts +138 -0
- package/src/lib/media-folders.ts +9 -0
- package/src/lib/nav.ts +46 -2
- package/src/lib/platform-broker.ts +18 -3
- package/src/lib/registry.ts +9 -0
- package/src/lib/save-chunks.ts +42 -0
- package/src/lib/scroll-lock.ts +27 -0
- package/src/schemas/documents.ts +82 -0
- package/src/schemas/index.ts +1 -0
- package/src/storage/index.ts +2 -2
- package/src/storage/types.ts +58 -4
- /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
|
}
|