@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.
- package/dist/{chunk-EUSWEVOA.js → chunk-3TO4RRHY.js} +12 -1
- package/dist/{chunk-KP4DWTPR.js → chunk-72FMQQIA.js} +246 -3
- package/dist/chunk-BS5AQHZG.js +45 -0
- package/dist/{chunk-NA5EJM6Z.js → chunk-CZLYECHC.js} +19 -8
- package/dist/{chunk-HKHEAH5F.js → chunk-GOCFTXR3.js} +8 -60
- package/dist/{chunk-GYCBWGVW.js → chunk-LBHQWIVL.js} +79 -24
- package/dist/{chunk-HJSE6SK5.js → chunk-REDDTTGJ.js} +3 -3
- package/dist/collab/rebase.d.ts.map +1 -1
- package/dist/collab/rebase.js +3 -1
- 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 +33 -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/sections/Document/index.d.ts +18 -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 +18 -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 +23 -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 +60 -34
- package/dist/lib/block-move.d.ts.map +1 -1
- package/dist/lib/dexie.d.ts +11 -0
- package/dist/lib/dexie.d.ts.map +1 -1
- package/dist/lib/dexie.js +32 -0
- 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-ops.d.ts +1 -0
- package/dist/lib/index-ops.d.ts.map +1 -1
- package/dist/lib/index-ops.js +13 -1
- package/dist/lib/index.js +6 -6
- package/dist/lib/loader.d.ts +6 -0
- package/dist/lib/loader.d.ts.map +1 -1
- 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/pages.d.ts +18 -0
- package/dist/lib/pages.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 +24 -2
- package/dist/storage/index.d.ts +2 -2
- package/dist/storage/index.d.ts.map +1 -1
- package/dist/storage/index.js +5 -1
- package/dist/storage/types.d.ts +69 -5
- package/dist/storage/types.d.ts.map +1 -1
- package/dist/types/database.d.ts +14 -0
- package/dist/types/database.d.ts.map +1 -1
- package/package.json +5 -1
- package/src/collab/rebase.ts +15 -0
- package/src/components/chat/ChatPanel.tsx +5 -4
- package/src/components/docfile/DocFileOverlay.tsx +54 -0
- package/src/components/docfile/DocFilePane.tsx +129 -0
- package/src/components/docfile/doc-file-state.ts +180 -0
- package/src/components/editor/SettingsForm.tsx +10 -0
- package/src/components/sections/Document/index.tsx +163 -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 +154 -10
- 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 +18 -1
- package/src/components/shell/MediaLibraryModal.tsx +223 -45
- package/src/hooks/useDocumentUploads.ts +330 -0
- package/src/hooks/useEditorPublish.ts +197 -11
- package/src/hooks/useMediaPipeline.ts +136 -5
- package/src/hooks/useResolvedDocument.ts +25 -0
- package/src/lib/block-move.ts +15 -4
- package/src/lib/dexie.ts +54 -0
- 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/index-ops.ts +16 -0
- package/src/lib/loader.ts +32 -11
- package/src/lib/media-folders.ts +9 -0
- package/src/lib/nav.ts +46 -2
- package/src/lib/pages.ts +48 -0
- 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/schemas/nav.ts +3 -3
- package/src/storage/index.ts +2 -2
- package/src/storage/types.ts +71 -5
- package/src/types/database.ts +14 -0
- /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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
+
}
|