@drawnagency/primitives 0.1.71 → 0.1.73
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-RTYJZAKR.js → chunk-7T2WQSWF.js} +12 -12
- package/dist/chunk-IFHRHV7S.js +117 -0
- package/dist/{chunk-2BHPWO7M.js → chunk-N6AE6NBB.js} +158 -32
- package/dist/{chunk-WXVKX6PY.js → chunk-NKVZGPR6.js} +41 -95
- package/dist/{chunk-3DCG4DNU.js → chunk-PZJL2S3Z.js} +8 -4
- package/dist/chunk-RBTSUR5V.js +37 -0
- package/dist/{chunk-6UDYJQL6.js → chunk-S55OZ7F2.js} +9 -1
- package/dist/{chunk-NR7UB6DZ.js → chunk-SFKYUKVN.js} +1 -1
- package/dist/{chunk-YMVYTYGQ.js → chunk-SWIHUIFT.js} +28 -16
- package/dist/{chunk-KWBM27UR.js → chunk-T7MP7ANP.js} +13 -64
- package/dist/{chunk-MU3CDERQ.js → chunk-XTRTK26E.js} +276 -96
- package/dist/{chunk-GKV77AA6.js → chunk-ZRWGV5O6.js} +3 -3
- package/dist/chunk-ZSPCE6MZ.js +70 -0
- package/dist/components/editor/SettingsForm.d.ts.map +1 -1
- package/dist/components/editor/index.js +6 -3
- package/dist/components/primitives/ResolvedMedia.d.ts.map +1 -1
- package/dist/components/primitives/index.js +7 -7
- package/dist/components/sections/Media/MediaBlock.d.ts.map +1 -1
- package/dist/components/sections/Video/VideoBlock.d.ts +31 -0
- package/dist/components/sections/Video/VideoBlock.d.ts.map +1 -0
- package/dist/components/sections/Video/index.d.ts +28 -0
- package/dist/components/sections/Video/index.d.ts.map +1 -0
- package/dist/components/sections/all-sections.d.ts +25 -0
- package/dist/components/sections/all-sections.d.ts.map +1 -1
- package/dist/components/sections/brand-guide.js +7 -7
- package/dist/components/sections/core-sections.d.ts +25 -0
- package/dist/components/sections/core-sections.d.ts.map +1 -1
- package/dist/components/sections/register-schemas.js +13 -12
- package/dist/components/sections/register.js +13 -12
- package/dist/components/shared/Tabs.d.ts +1 -1
- package/dist/components/shared/VideoSourceField.d.ts +8 -0
- package/dist/components/shared/VideoSourceField.d.ts.map +1 -0
- package/dist/components/shell/EditorShell.d.ts.map +1 -1
- package/dist/components/shell/MediaLibraryModal.d.ts +4 -1
- package/dist/components/shell/MediaLibraryModal.d.ts.map +1 -1
- package/dist/hooks/useEditorPublish.d.ts +4 -1
- package/dist/hooks/useEditorPublish.d.ts.map +1 -1
- package/dist/hooks/useMediaPipeline.d.ts +9 -1
- package/dist/hooks/useMediaPipeline.d.ts.map +1 -1
- package/dist/index.js +28 -24
- package/dist/lib/index.js +10 -10
- package/dist/lib/registry.d.ts +6 -0
- package/dist/lib/registry.d.ts.map +1 -1
- package/dist/lib/save-chunks.d.ts +28 -0
- package/dist/lib/save-chunks.d.ts.map +1 -0
- package/dist/lib/video-url.d.ts +7 -0
- package/dist/lib/video-url.d.ts.map +1 -0
- package/dist/lib/video-url.js +9 -0
- package/dist/media/index.js +6 -2
- package/dist/media/resolve.d.ts +14 -0
- package/dist/media/resolve.d.ts.map +1 -1
- package/dist/media/videoPoster.d.ts +1 -0
- package/dist/media/videoPoster.d.ts.map +1 -1
- package/dist/schemas/index.js +4 -4
- package/dist/schemas/link.d.ts.map +1 -1
- package/dist/schemas/link.js +1 -1
- package/dist/schemas/media.d.ts +13 -0
- package/dist/schemas/media.d.ts.map +1 -1
- package/dist/schemas/shared.d.ts.map +1 -1
- package/dist/schemas/site-config.d.ts +1 -0
- package/dist/schemas/site-config.d.ts.map +1 -1
- package/dist/storage/index.d.ts +1 -1
- package/dist/storage/index.d.ts.map +1 -1
- package/dist/storage/types.d.ts +26 -0
- package/dist/storage/types.d.ts.map +1 -1
- package/package.json +5 -1
- package/src/components/editor/SettingsForm.tsx +11 -0
- package/src/components/primitives/ResolvedMedia.tsx +10 -5
- package/src/components/sections/Media/MediaBlock.tsx +2 -1
- package/src/components/sections/Video/VideoBlock.tsx +149 -0
- package/src/components/sections/Video/index.tsx +111 -0
- package/src/components/sections/core-sections.ts +2 -0
- package/src/components/shared/Tabs.tsx +2 -2
- package/src/components/shared/VideoSourceField.tsx +144 -0
- package/src/components/shell/EditorShell.tsx +16 -2
- package/src/components/shell/MediaLibraryModal.tsx +47 -1
- package/src/hooks/useEditorPublish.ts +137 -50
- package/src/hooks/useMediaPipeline.ts +188 -25
- package/src/lib/registry.ts +7 -0
- package/src/lib/save-chunks.ts +53 -0
- package/src/lib/video-url.ts +39 -0
- package/src/media/resolve.ts +18 -0
- package/src/media/videoPoster.ts +2 -2
- package/src/schemas/link.ts +15 -4
- package/src/schemas/media.ts +12 -0
- package/src/schemas/shared.ts +23 -14
- package/src/storage/index.ts +1 -1
- package/src/storage/types.ts +23 -0
- package/dist/chunk-4E66N6UV.js +0 -10
- package/dist/chunk-I5737FJY.js +0 -62
|
@@ -3,6 +3,7 @@ import type { SiteIndex, SiteConfig } from "../schemas/site-config";
|
|
|
3
3
|
import type { LoadedSection } from "../lib/loader";
|
|
4
4
|
import type { MediaManifest, MediaItem } from "../media/types";
|
|
5
5
|
import { getDirtySectionRows, hasLocalChanges, discardSavedChanges, cacheContent, clearContentCache, getPendingMediaBlobs, clearPendingMediaByIds } from "../lib/dexie";
|
|
6
|
+
import { packUploads, type MediaUploadPayload } from "../lib/save-chunks";
|
|
6
7
|
|
|
7
8
|
function blobToBase64(blob: Blob): Promise<string> {
|
|
8
9
|
return new Promise((resolve, reject) => {
|
|
@@ -16,6 +17,35 @@ function blobToBase64(blob: Blob): Promise<string> {
|
|
|
16
17
|
});
|
|
17
18
|
}
|
|
18
19
|
|
|
20
|
+
/** One /api/save request with honest failure semantics: the server's message
|
|
21
|
+
* is preserved verbatim; non-JSON bodies (platform-level rejections) get a
|
|
22
|
+
* status-coded fallback — or a size hint when the request body itself was
|
|
23
|
+
* near Netlify's ~6MB limit (should be unreachable once saves are chunked). */
|
|
24
|
+
async function postSave(payload: object): Promise<{ sha: string }> {
|
|
25
|
+
const body = JSON.stringify(payload);
|
|
26
|
+
let response: Response;
|
|
27
|
+
try {
|
|
28
|
+
response = await fetch("/api/save", {
|
|
29
|
+
method: "POST",
|
|
30
|
+
headers: { "Content-Type": "application/json" },
|
|
31
|
+
body,
|
|
32
|
+
});
|
|
33
|
+
} catch {
|
|
34
|
+
throw new Error("Network error while saving — check your connection and try again.");
|
|
35
|
+
}
|
|
36
|
+
if (!response.ok) {
|
|
37
|
+
const errorBody = await response.json().catch(() => null) as { message?: string; error?: string } | null;
|
|
38
|
+
const serverMessage = errorBody?.message || errorBody?.error;
|
|
39
|
+
const fallback = !errorBody && body.length > 5_500_000
|
|
40
|
+
? "This save is too large to send in one request — remove some images from the media library and try again."
|
|
41
|
+
: `Save failed (HTTP ${response.status})`;
|
|
42
|
+
throw Object.assign(new Error(serverMessage || fallback), { status: response.status });
|
|
43
|
+
}
|
|
44
|
+
const data = await response.json().catch(() => null) as { sha?: string } | null;
|
|
45
|
+
if (!data?.sha) throw new Error("Save response missing SHA");
|
|
46
|
+
return data as { sha: string };
|
|
47
|
+
}
|
|
48
|
+
|
|
19
49
|
interface PublishDeps {
|
|
20
50
|
flushNow: () => Promise<void>;
|
|
21
51
|
cancelPendingFlush: () => void;
|
|
@@ -38,6 +68,8 @@ interface PublishDeps {
|
|
|
38
68
|
// no draft exists yet). Sent with saves so the server can 409 on a concurrent
|
|
39
69
|
// edit instead of silently overwriting it. Read at save time (latest value).
|
|
40
70
|
getBaseVersion: () => string | null;
|
|
71
|
+
/** Test hook: overrides the per-request payload budget (default 4MB). */
|
|
72
|
+
saveChunkBudget?: number;
|
|
41
73
|
}
|
|
42
74
|
|
|
43
75
|
interface GatheredMedia {
|
|
@@ -67,9 +99,11 @@ export function useEditorPublish({
|
|
|
67
99
|
onShasUpdated,
|
|
68
100
|
onPublishComplete,
|
|
69
101
|
getBaseVersion,
|
|
102
|
+
saveChunkBudget,
|
|
70
103
|
}: PublishDeps) {
|
|
71
104
|
const [publishAction, setPublishAction] = useState<PublishAction>("idle");
|
|
72
105
|
const [publishFeedback, setPublishFeedback] = useState<string | null>(null);
|
|
106
|
+
const [saveProgress, setSaveProgress] = useState<string | null>(null);
|
|
73
107
|
const feedbackTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
74
108
|
const inFlightRef = useRef(false);
|
|
75
109
|
|
|
@@ -107,6 +141,9 @@ export function useEditorPublish({
|
|
|
107
141
|
};
|
|
108
142
|
for (const [key, blob] of Object.entries(storedBlobs)) {
|
|
109
143
|
if (key === "primary" && item.kind === "image") continue;
|
|
144
|
+
// Assets-backed originals live in the bucket; only derived files (poster)
|
|
145
|
+
// ride the git save.
|
|
146
|
+
if (item.storage === "assets" && key === "primary") continue;
|
|
110
147
|
const base64 = await blobToBase64(blob);
|
|
111
148
|
if (key === "primary") {
|
|
112
149
|
const ext = mimeExt[item.mimeType] ?? "bin";
|
|
@@ -145,6 +182,7 @@ export function useEditorPublish({
|
|
|
145
182
|
pendingMediaDeletions: string[];
|
|
146
183
|
mediaManifest: MediaManifest;
|
|
147
184
|
manifestDirty: boolean;
|
|
185
|
+
saveChunkBudget?: number;
|
|
148
186
|
}): Promise<{ sha: string; savedSections: { sectionId: string; updatedAt: string }[]; uploadedItems: MediaItem[] }> {
|
|
149
187
|
const dirty = await getDirtySectionRows();
|
|
150
188
|
const gathered = await gatherMediaPayload(
|
|
@@ -154,6 +192,14 @@ export function useEditorPublish({
|
|
|
154
192
|
args.manifestDirty,
|
|
155
193
|
);
|
|
156
194
|
|
|
195
|
+
const { chunks, oversized } = packUploads(gathered.mediaUploads, args.saveChunkBudget);
|
|
196
|
+
if (oversized.length > 0) {
|
|
197
|
+
const names = oversized.map((u) => `"${u.item.originalName}"`).join(", ");
|
|
198
|
+
throw new Error(
|
|
199
|
+
`${names} ${oversized.length === 1 ? "is" : "are"} too large to save — resize or compress the file, re-add it to the media library, and try again.`,
|
|
200
|
+
);
|
|
201
|
+
}
|
|
202
|
+
|
|
157
203
|
let siteIndex = args.siteIndexRef.current;
|
|
158
204
|
if (args.deletedSectionIds?.length) {
|
|
159
205
|
const deleteSet = new Set(args.deletedSectionIds);
|
|
@@ -168,48 +214,71 @@ export function useEditorPublish({
|
|
|
168
214
|
};
|
|
169
215
|
}
|
|
170
216
|
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
const
|
|
198
|
-
|
|
199
|
-
|
|
217
|
+
let baseVersion = getBaseVersion();
|
|
218
|
+
const uploadedItems: MediaItem[] = [];
|
|
219
|
+
const totalUploads = gathered.mediaUploads.length;
|
|
220
|
+
|
|
221
|
+
// Lead chunks: media-only saves. The server skips the index write for
|
|
222
|
+
// these, so the draft never references section files that don't exist
|
|
223
|
+
// yet; a failure after chunk k leaves chunks 1..k committed AND in the
|
|
224
|
+
// committed manifest. The remainder stays pending in Dexie, but the
|
|
225
|
+
// caller's savedShaRef only advances on full success — so an IMMEDIATE
|
|
226
|
+
// retry may 409 against the moved draft head; after a reload the save
|
|
227
|
+
// resumes cleanly with only the uncommitted remainder.
|
|
228
|
+
const leadChunks = chunks.slice(0, -1); // empty when chunks.length <= 1
|
|
229
|
+
const finalUploads: MediaUploadPayload[] = chunks.length > 0 ? chunks[chunks.length - 1] : [];
|
|
230
|
+
const committedImages: Record<string, MediaItem> = {};
|
|
231
|
+
let sent = 0;
|
|
232
|
+
|
|
233
|
+
for (const chunk of leadChunks) {
|
|
234
|
+
setSaveProgress(`Saving images ${sent + chunk.length}/${totalUploads}…`);
|
|
235
|
+
for (const upload of chunk) committedImages[upload.item.id] = upload.item;
|
|
236
|
+
const manifest: MediaManifest = { images: { ...args.mediaManifest.images, ...committedImages } };
|
|
237
|
+
const data = await postSave({
|
|
238
|
+
baseVersion,
|
|
239
|
+
media: { uploads: chunk, deletions: [], manifest },
|
|
240
|
+
});
|
|
241
|
+
baseVersion = data.sha;
|
|
242
|
+
sent += chunk.length;
|
|
243
|
+
const chunkItems = chunk.map((u) => u.item);
|
|
244
|
+
uploadedItems.push(...chunkItems);
|
|
245
|
+
// Promote committed items immediately so a later failure can't re-send
|
|
246
|
+
// them. A failure HERE is tolerable: re-sending identical blobs on the
|
|
247
|
+
// next save is harmless (same content, same paths).
|
|
248
|
+
try {
|
|
249
|
+
await clearPendingMediaByIds(chunkItems.map((i) => i.id), []);
|
|
250
|
+
onMediaPublished(chunkItems, []);
|
|
251
|
+
} catch (promoteError) {
|
|
252
|
+
console.error("Failed to promote committed media chunk locally:", promoteError);
|
|
253
|
+
}
|
|
200
254
|
}
|
|
201
255
|
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
throw new Error("Save response missing SHA");
|
|
256
|
+
if (leadChunks.length > 0) {
|
|
257
|
+
setSaveProgress(finalUploads.length > 0 ? `Saving images ${totalUploads}/${totalUploads}…` : null);
|
|
205
258
|
}
|
|
259
|
+
|
|
260
|
+
const responseData = await postSave({
|
|
261
|
+
sections: dirty.map(({ sectionId, content }) => ({ id: sectionId, content })),
|
|
262
|
+
siteIndex,
|
|
263
|
+
baseVersion,
|
|
264
|
+
...(args.deletedSectionIds?.length ? { deletedSectionIds: args.deletedSectionIds } : {}),
|
|
265
|
+
...(args.isConfigDirty() ? { siteConfig: args.siteConfig } : {}),
|
|
266
|
+
...(gathered.hasMediaChanges ? {
|
|
267
|
+
media: {
|
|
268
|
+
uploads: finalUploads,
|
|
269
|
+
deletions: args.pendingMediaDeletions.map((id) => ({
|
|
270
|
+
id,
|
|
271
|
+
folder: args.mediaManifest.images[id]?.folder,
|
|
272
|
+
})).filter((d) => d.folder),
|
|
273
|
+
manifest: gathered.updatedManifest,
|
|
274
|
+
},
|
|
275
|
+
} : {}),
|
|
276
|
+
});
|
|
277
|
+
|
|
206
278
|
return {
|
|
207
|
-
sha: responseData.sha
|
|
279
|
+
sha: responseData.sha,
|
|
208
280
|
savedSections: dirty.map(({ sectionId, updatedAt }) => ({ sectionId, updatedAt })),
|
|
209
|
-
|
|
210
|
-
// by gatherMediaPayload (blobs not yet persisted) are excluded so the caller
|
|
211
|
-
// never promotes them into the manifest as orphans.
|
|
212
|
-
uploadedItems: gathered.mediaUploads.map(({ item }) => item),
|
|
281
|
+
uploadedItems: [...uploadedItems, ...finalUploads.map((u) => u.item)],
|
|
213
282
|
};
|
|
214
283
|
}
|
|
215
284
|
|
|
@@ -239,6 +308,7 @@ export function useEditorPublish({
|
|
|
239
308
|
siteConfig: siteConfig!,
|
|
240
309
|
siteIndexRef, deletedSectionIds, isConfigDirty,
|
|
241
310
|
pendingMediaItems, pendingMediaDeletions, mediaManifest, manifestDirty,
|
|
311
|
+
saveChunkBudget,
|
|
242
312
|
});
|
|
243
313
|
}
|
|
244
314
|
|
|
@@ -266,8 +336,9 @@ export function useEditorPublish({
|
|
|
266
336
|
|
|
267
337
|
function reportSaveError(error: unknown, failLabel: string): void {
|
|
268
338
|
console.error(`${failLabel} failed:`, error);
|
|
339
|
+
const message = error instanceof Error && error.message ? error.message : `${failLabel} failed`;
|
|
269
340
|
const isConflict = (error as { status?: number })?.status === 409;
|
|
270
|
-
showFeedback(
|
|
341
|
+
showFeedback(message, isConflict ? 8000 : 6000);
|
|
271
342
|
}
|
|
272
343
|
|
|
273
344
|
const handleSave = useCallback(async () => {
|
|
@@ -283,19 +354,26 @@ export function useEditorPublish({
|
|
|
283
354
|
}
|
|
284
355
|
|
|
285
356
|
const { sha, savedSections, uploadedItems } = await runPerformSave();
|
|
286
|
-
|
|
287
|
-
await commitSavedRows(savedSections, uploadedItems);
|
|
288
|
-
finishSave(uploadedItems);
|
|
289
357
|
onShasUpdated(sha, null);
|
|
290
358
|
|
|
359
|
+
try {
|
|
360
|
+
await commitSavedRows(savedSections, uploadedItems);
|
|
361
|
+
finishSave(uploadedItems);
|
|
362
|
+
} catch (cleanupError) {
|
|
363
|
+
console.error("Save succeeded but local cleanup failed:", cleanupError);
|
|
364
|
+
showFeedback("Saved, but the local cache couldn't be updated — reload the editor.", 8000);
|
|
365
|
+
return;
|
|
366
|
+
}
|
|
367
|
+
|
|
291
368
|
showFeedback("Saved", 3000);
|
|
292
369
|
} catch (error) {
|
|
293
370
|
reportSaveError(error, "Save");
|
|
294
371
|
} finally {
|
|
295
372
|
inFlightRef.current = false;
|
|
296
373
|
setPublishAction("idle");
|
|
374
|
+
setSaveProgress(null);
|
|
297
375
|
}
|
|
298
|
-
}, [flushNow, cancelPendingFlush, isConfigDirty, clearConfigDirty, siteIndexRef, siteConfig, deletedSectionIds, onSuccess, mediaManifest, manifestDirty, clearManifestDirty, pendingMediaItems, pendingMediaDeletions, onMediaPublished, onShasUpdated, showFeedback]);
|
|
376
|
+
}, [flushNow, cancelPendingFlush, isConfigDirty, clearConfigDirty, siteIndexRef, siteConfig, deletedSectionIds, onSuccess, mediaManifest, manifestDirty, clearManifestDirty, pendingMediaItems, pendingMediaDeletions, onMediaPublished, onShasUpdated, showFeedback, saveChunkBudget]);
|
|
299
377
|
|
|
300
378
|
const handlePublish = useCallback(async () => {
|
|
301
379
|
setPublishAction("publishing");
|
|
@@ -366,12 +444,20 @@ export function useEditorPublish({
|
|
|
366
444
|
const { sha } = publishData;
|
|
367
445
|
|
|
368
446
|
if (hasLocalEdits) {
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
447
|
+
try {
|
|
448
|
+
await commitSavedRows(savedSections, uploadedItems);
|
|
449
|
+
// Re-cache against the PUBLISHED sha (commitSavedRows just invalidated the
|
|
450
|
+
// cache); the draft branch is gone after publish, so this is the snapshot a
|
|
451
|
+
// later cache hit restores.
|
|
452
|
+
await cacheContent(sha, sections, siteIndexRef.current, siteConfig!);
|
|
453
|
+
finishSave(uploadedItems);
|
|
454
|
+
} catch (cleanupError) {
|
|
455
|
+
console.error("Publish succeeded but local cleanup failed:", cleanupError);
|
|
456
|
+
onShasUpdated(null, sha);
|
|
457
|
+
onPublishComplete?.();
|
|
458
|
+
showFeedback("Published, but the local cache couldn't be updated — reload the editor.", 8000);
|
|
459
|
+
return;
|
|
460
|
+
}
|
|
375
461
|
}
|
|
376
462
|
|
|
377
463
|
onShasUpdated(null, sha);
|
|
@@ -381,8 +467,9 @@ export function useEditorPublish({
|
|
|
381
467
|
} finally {
|
|
382
468
|
inFlightRef.current = false;
|
|
383
469
|
setPublishAction("idle");
|
|
470
|
+
setSaveProgress(null);
|
|
384
471
|
}
|
|
385
|
-
}, [flushNow, cancelPendingFlush, isConfigDirty, clearConfigDirty, siteIndexRef, siteConfig, sections, deletedSectionIds, onSuccess, mediaManifest, manifestDirty, clearManifestDirty, pendingMediaItems, pendingMediaDeletions, onMediaPublished, onShasUpdated, showFeedback, onPublishComplete]);
|
|
472
|
+
}, [flushNow, cancelPendingFlush, isConfigDirty, clearConfigDirty, siteIndexRef, siteConfig, sections, deletedSectionIds, onSuccess, mediaManifest, manifestDirty, clearManifestDirty, pendingMediaItems, pendingMediaDeletions, onMediaPublished, onShasUpdated, showFeedback, onPublishComplete, saveChunkBudget]);
|
|
386
473
|
|
|
387
|
-
return { publishAction, publishFeedback, handleSave, handlePublish, handleSaveAndPublish };
|
|
474
|
+
return { publishAction, publishFeedback, saveProgress, handleSave, handlePublish, handleSaveAndPublish };
|
|
388
475
|
}
|
|
@@ -24,6 +24,10 @@ interface UseMediaPipelineInput {
|
|
|
24
24
|
setLocalChangesExist: React.Dispatch<React.SetStateAction<boolean>>;
|
|
25
25
|
setDirtySectionIds: React.Dispatch<React.SetStateAction<Set<string>>>;
|
|
26
26
|
markSectionDirty: (sectionId: string, content: SectionContent) => void;
|
|
27
|
+
// Surfaces upload rejections/failures (oversized files, disallowed types,
|
|
28
|
+
// upload-intent errors, direct-to-bucket PUT failures) to the caller, which
|
|
29
|
+
// decides how to display them (e.g. the media modal's rejection banner).
|
|
30
|
+
onUploadError?: (message: string) => void;
|
|
27
31
|
}
|
|
28
32
|
|
|
29
33
|
export function useMediaPipeline({
|
|
@@ -35,11 +39,16 @@ export function useMediaPipeline({
|
|
|
35
39
|
setLocalChangesExist,
|
|
36
40
|
setDirtySectionIds,
|
|
37
41
|
markSectionDirty,
|
|
42
|
+
onUploadError,
|
|
38
43
|
}: UseMediaPipelineInput) {
|
|
39
44
|
const [processingItems, setProcessingItems] = useState<QueueItem[]>([]);
|
|
40
45
|
const [pendingMediaItems, setPendingMediaItems] = useState<LibraryMediaItem[]>([]);
|
|
41
46
|
const [pendingLocalUrls, setPendingLocalUrls] = useState<Record<string, string>>({});
|
|
42
47
|
const [pendingDeletions, setPendingDeletions] = useState<string[]>([]);
|
|
48
|
+
// Direct-to-bucket large-video PUT progress, keyed by content hash (0..1).
|
|
49
|
+
// Populated by uploadLargeVideo's XHR upload.onprogress, cleared on
|
|
50
|
+
// completion or failure.
|
|
51
|
+
const [uploadProgress, setUploadProgress] = useState<Record<string, number>>({});
|
|
43
52
|
// True when the manifest holds unsaved edits that produce no upload/deletion —
|
|
44
53
|
// e.g. alt text on an already-published image. Without this the save path's
|
|
45
54
|
// hasMediaChanges check (uploads/deletions only) skips the change and the edit
|
|
@@ -52,6 +61,45 @@ export function useMediaPipeline({
|
|
|
52
61
|
const uploadCallbacksRef = useRef<Map<string, (imageId: string) => void>>(new Map());
|
|
53
62
|
const destroyedRef = useRef(false);
|
|
54
63
|
|
|
64
|
+
// Shared persistence path for every pending media item — the processing
|
|
65
|
+
// queue's small-file finalize AND the direct-to-bucket large-video upload
|
|
66
|
+
// both funnel through this so a Dexie write, the pendingMediaItems/
|
|
67
|
+
// pendingLocalUrls state, and cleanup-on-failure can't drift between them.
|
|
68
|
+
const finalizePendingMedia = useCallback(
|
|
69
|
+
async (
|
|
70
|
+
item: LibraryMediaItem,
|
|
71
|
+
localUrls: Record<string, string>,
|
|
72
|
+
blobsMap: Record<string, Blob>,
|
|
73
|
+
): Promise<boolean> => {
|
|
74
|
+
if (destroyedRef.current) return false;
|
|
75
|
+
try {
|
|
76
|
+
// Persist blobs to IndexedDB BEFORE marking the item pending.
|
|
77
|
+
// Previously this was fire-and-forget: a Save firing before the
|
|
78
|
+
// write landed — or a failed write (e.g. storage quota) — left the
|
|
79
|
+
// item promoted into the manifest with no backing file, an orphan
|
|
80
|
+
// that dropped out on the next render and blocked re-adding it.
|
|
81
|
+
await addPendingMediaItem(item, localUrls, blobsMap);
|
|
82
|
+
} catch (err) {
|
|
83
|
+
console.error(`[useMediaPipeline] Failed to persist media "${item.originalName}":`, err);
|
|
84
|
+
for (const url of Object.values(localUrls)) URL.revokeObjectURL(url);
|
|
85
|
+
return false;
|
|
86
|
+
}
|
|
87
|
+
if (destroyedRef.current) return false;
|
|
88
|
+
removePendingMediaDeletion(item.id);
|
|
89
|
+
setPendingDeletions((prev) => prev.filter((d) => d !== item.id));
|
|
90
|
+
setPendingMediaItems((prev) => [...prev, item]);
|
|
91
|
+
const displayKey = item.kind === "video"
|
|
92
|
+
? "primary"
|
|
93
|
+
: Object.keys(localUrls).find((k) => k !== "primary" && k !== "poster") ?? "primary";
|
|
94
|
+
if (localUrls[displayKey]) {
|
|
95
|
+
setPendingLocalUrls((prev) => ({ ...prev, [item.id]: localUrls[displayKey] }));
|
|
96
|
+
}
|
|
97
|
+
setLocalChangesExist(true);
|
|
98
|
+
return true;
|
|
99
|
+
},
|
|
100
|
+
[setLocalChangesExist],
|
|
101
|
+
);
|
|
102
|
+
|
|
55
103
|
// --- Processing queue ---
|
|
56
104
|
|
|
57
105
|
const mediaConfigKey = siteConfig
|
|
@@ -103,31 +151,11 @@ export function useMediaPipeline({
|
|
|
103
151
|
size: v.size,
|
|
104
152
|
})),
|
|
105
153
|
alt: "",
|
|
154
|
+
storage: "github",
|
|
106
155
|
};
|
|
107
156
|
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
// Previously this was fire-and-forget: a Save firing before the
|
|
111
|
-
// write landed — or a failed write (e.g. storage quota) — left the
|
|
112
|
-
// item promoted into the manifest with no backing file, an orphan
|
|
113
|
-
// that dropped out on the next render and blocked re-adding it.
|
|
114
|
-
await addPendingMediaItem(item, localUrls, blobsMap);
|
|
115
|
-
} catch (err) {
|
|
116
|
-
console.error(`[useMediaPipeline] Failed to persist media "${item.originalName}":`, err);
|
|
117
|
-
for (const url of Object.values(localUrls)) URL.revokeObjectURL(url);
|
|
118
|
-
return;
|
|
119
|
-
}
|
|
120
|
-
if (destroyedRef.current) return;
|
|
121
|
-
removePendingMediaDeletion(item.id);
|
|
122
|
-
setPendingDeletions((prev) => prev.filter((d) => d !== item.id));
|
|
123
|
-
setPendingMediaItems((prev) => [...prev, item]);
|
|
124
|
-
const displayKey = kind === "video"
|
|
125
|
-
? "primary"
|
|
126
|
-
: Object.keys(localUrls).find((k) => k !== "primary" && k !== "poster") ?? "primary";
|
|
127
|
-
if (localUrls[displayKey]) {
|
|
128
|
-
setPendingLocalUrls((prev) => ({ ...prev, [item.id]: localUrls[displayKey] }));
|
|
129
|
-
}
|
|
130
|
-
setLocalChangesExist(true);
|
|
157
|
+
const persisted = await finalizePendingMedia(item, localUrls, blobsMap);
|
|
158
|
+
if (!persisted) return;
|
|
131
159
|
|
|
132
160
|
const callback = uploadCallbacksRef.current.get(event.item.hash);
|
|
133
161
|
if (callback) {
|
|
@@ -179,6 +207,121 @@ export function useMediaPipeline({
|
|
|
179
207
|
|
|
180
208
|
// --- Media handlers ---
|
|
181
209
|
|
|
210
|
+
// Direct-to-bucket path for videos over the git-path cap (maxFileSize): mint
|
|
211
|
+
// a signed URL, PUT the original straight to the bucket (with progress),
|
|
212
|
+
// then persist a pending item whose ONLY Dexie blob is the poster — the
|
|
213
|
+
// original never rides the base64 /api/save payload.
|
|
214
|
+
// Every exit path below must (1) never throw out to the caller — so the
|
|
215
|
+
// handleMediaUpload batch loop always advances to the next file — (2) call
|
|
216
|
+
// onUploadError exactly once on failure, and (3) leave no leaked
|
|
217
|
+
// uploadProgress[hash] entry. Each network/decode stage below gets its own
|
|
218
|
+
// try/catch so it can report a stage-specific, actionable message and
|
|
219
|
+
// `return` (never rethrow); the outer try/catch is a last-resort net for
|
|
220
|
+
// anything upstream of those stages (e.g. hashing), and the single outer
|
|
221
|
+
// `finally` is the one place progress gets cleared, regardless of which
|
|
222
|
+
// stage — or none — ran.
|
|
223
|
+
const uploadLargeVideo = useCallback(async (file: File, mediaConfig: SiteConfig["media"]) => {
|
|
224
|
+
let hash: string | undefined;
|
|
225
|
+
try {
|
|
226
|
+
const buffer = await file.arrayBuffer();
|
|
227
|
+
hash = await hashFileBuffer(buffer);
|
|
228
|
+
|
|
229
|
+
const isDeleted = pendingDeletions.includes(hash);
|
|
230
|
+
const existsInManifest = hash in mediaManifest.images;
|
|
231
|
+
const existsInPending = pendingMediaItems.some((i) => i.hash === hash);
|
|
232
|
+
if ((existsInManifest || existsInPending) && !isDeleted) return;
|
|
233
|
+
|
|
234
|
+
if (file.size > mediaConfig.maxAssetSize) {
|
|
235
|
+
onUploadError?.(`${file.name} exceeds the ${Math.round(mediaConfig.maxAssetSize / 1024 / 1024)}MB video limit.`);
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
let uploadUrl: string;
|
|
240
|
+
let publicUrl: string;
|
|
241
|
+
try {
|
|
242
|
+
const intentRes = await fetch("/api/media/upload-intent", {
|
|
243
|
+
method: "POST",
|
|
244
|
+
headers: { "Content-Type": "application/json" },
|
|
245
|
+
body: JSON.stringify({ hash, contentType: file.type, size: file.size }),
|
|
246
|
+
});
|
|
247
|
+
if (!intentRes.ok) {
|
|
248
|
+
const body = await intentRes.json().catch(() => ({}) as { message?: string });
|
|
249
|
+
onUploadError?.(body.message ?? "Video upload is not available on this site.");
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
({ uploadUrl, publicUrl } = await intentRes.json());
|
|
253
|
+
} catch (err) {
|
|
254
|
+
onUploadError?.(`${file.name}: couldn't start the upload — ${(err as Error).message}. Try again.`);
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
try {
|
|
259
|
+
// Direct browser -> bucket PUT with progress (fetch has no upload progress).
|
|
260
|
+
await new Promise<void>((resolvePut, rejectPut) => {
|
|
261
|
+
const xhr = new XMLHttpRequest();
|
|
262
|
+
xhr.open("PUT", uploadUrl);
|
|
263
|
+
xhr.setRequestHeader("Content-Type", file.type);
|
|
264
|
+
xhr.upload.onprogress = (e) => {
|
|
265
|
+
if (e.lengthComputable) setUploadProgress((prev) => ({ ...prev, [hash as string]: e.loaded / e.total }));
|
|
266
|
+
};
|
|
267
|
+
xhr.onload = () => (xhr.status >= 200 && xhr.status < 300 ? resolvePut() : rejectPut(new Error(`upload ${xhr.status}`)));
|
|
268
|
+
xhr.onerror = () => rejectPut(new Error("upload failed"));
|
|
269
|
+
xhr.send(file);
|
|
270
|
+
});
|
|
271
|
+
} catch (err) {
|
|
272
|
+
onUploadError?.(`${file.name}: upload failed — ${(err as Error).message}. Try again.`);
|
|
273
|
+
return;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
try {
|
|
277
|
+
const { posterBlob, width, height, duration } = await generateVideoPoster(file, mediaConfig.quality);
|
|
278
|
+
const sanitizedName = sanitizeMediaName(file.name);
|
|
279
|
+
const item: LibraryMediaItem = {
|
|
280
|
+
id: hash,
|
|
281
|
+
hash,
|
|
282
|
+
kind: "video",
|
|
283
|
+
originalName: sanitizedName,
|
|
284
|
+
width,
|
|
285
|
+
height,
|
|
286
|
+
mimeType: file.type,
|
|
287
|
+
size: file.size,
|
|
288
|
+
folder: `${sanitizedName}-${hash}`,
|
|
289
|
+
variants: [],
|
|
290
|
+
alt: "",
|
|
291
|
+
storage: "assets",
|
|
292
|
+
url: publicUrl,
|
|
293
|
+
// MediaRecorder-sourced webm/mp4 (screen recordings) commonly report
|
|
294
|
+
// duration as Infinity or NaN. JSON.stringify(Infinity) -> null,
|
|
295
|
+
// which ImageManifestSchema's z.number().positive().optional()
|
|
296
|
+
// rejects on the next save — omit the field entirely instead.
|
|
297
|
+
...(Number.isFinite(duration) && duration > 0 ? { duration } : {}),
|
|
298
|
+
};
|
|
299
|
+
// Only the poster blob is persisted — the original lives in the bucket, so
|
|
300
|
+
// save/publish never ships large base64 through the Netlify function.
|
|
301
|
+
const localUrls = { primary: URL.createObjectURL(file), poster: URL.createObjectURL(posterBlob) };
|
|
302
|
+
await finalizePendingMedia(item, localUrls, { poster: posterBlob });
|
|
303
|
+
} catch (err) {
|
|
304
|
+
// The PUT already succeeded, so the original is sitting in the bucket
|
|
305
|
+
// with no pending item pointing at it — an orphan. Task 11's GC prunes
|
|
306
|
+
// unreferenced bucket objects; we don't attempt to delete it here.
|
|
307
|
+
onUploadError?.(`${file.name}: uploaded but couldn't finish processing — try again.`);
|
|
308
|
+
}
|
|
309
|
+
} catch (err) {
|
|
310
|
+
console.error(`[useMediaPipeline] uploadLargeVideo failed unexpectedly for "${file.name}":`, err);
|
|
311
|
+
onUploadError?.(`${file.name}: upload failed unexpectedly. Try again.`);
|
|
312
|
+
} finally {
|
|
313
|
+
if (hash !== undefined) {
|
|
314
|
+
const h = hash;
|
|
315
|
+
setUploadProgress((prev) => {
|
|
316
|
+
if (!(h in prev)) return prev;
|
|
317
|
+
const next = { ...prev };
|
|
318
|
+
delete next[h];
|
|
319
|
+
return next;
|
|
320
|
+
});
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
}, [mediaManifest, pendingMediaItems, pendingDeletions, onUploadError, finalizePendingMedia]);
|
|
324
|
+
|
|
182
325
|
const handleMediaUpload = useCallback(async (files: File[]) => {
|
|
183
326
|
if (!siteConfig) return;
|
|
184
327
|
const mediaConfig = siteConfig.media;
|
|
@@ -186,7 +329,26 @@ export function useMediaPipeline({
|
|
|
186
329
|
if (!queue) return;
|
|
187
330
|
|
|
188
331
|
for (const file of files) {
|
|
189
|
-
if (file.size > mediaConfig.maxFileSize)
|
|
332
|
+
if (file.size > mediaConfig.maxFileSize) {
|
|
333
|
+
if (file.type === "video/mp4" || file.type === "video/webm") {
|
|
334
|
+
// uploadLargeVideo already guarantees it never throws (see its own
|
|
335
|
+
// try/catch/finally) and reports failures via onUploadError itself;
|
|
336
|
+
// this catch is defense-in-depth only, so it must NOT call
|
|
337
|
+
// onUploadError again — doing so would double-report.
|
|
338
|
+
try {
|
|
339
|
+
await uploadLargeVideo(file, mediaConfig);
|
|
340
|
+
} catch (err) {
|
|
341
|
+
console.error(`[useMediaPipeline] uploadLargeVideo threw unexpectedly for "${file.name}":`, err);
|
|
342
|
+
}
|
|
343
|
+
} else {
|
|
344
|
+
onUploadError?.(
|
|
345
|
+
file.type.startsWith("video/")
|
|
346
|
+
? `${file.name}: only MP4/WebM videos can be uploaded.`
|
|
347
|
+
: `${file.name} is larger than the ${Math.round(mediaConfig.maxFileSize / 1024 / 1024)}MB media limit.`,
|
|
348
|
+
);
|
|
349
|
+
}
|
|
350
|
+
continue;
|
|
351
|
+
}
|
|
190
352
|
|
|
191
353
|
const buffer = await file.arrayBuffer();
|
|
192
354
|
const hash = await hashFileBuffer(buffer);
|
|
@@ -202,7 +364,7 @@ export function useMediaPipeline({
|
|
|
202
364
|
|
|
203
365
|
queue.add({ buffer, originalName: file.name, mimeType: file.type, hash, kind });
|
|
204
366
|
}
|
|
205
|
-
}, [siteConfig, mediaManifest, pendingMediaItems, pendingDeletions]);
|
|
367
|
+
}, [siteConfig, mediaManifest, pendingMediaItems, pendingDeletions, onUploadError, uploadLargeVideo]);
|
|
206
368
|
|
|
207
369
|
const handleMediaDelete = useCallback(async (ids: string[]) => {
|
|
208
370
|
const idSet = new Set(ids);
|
|
@@ -348,6 +510,7 @@ export function useMediaPipeline({
|
|
|
348
510
|
setPendingDeletions,
|
|
349
511
|
manifestDirty,
|
|
350
512
|
setManifestDirty,
|
|
513
|
+
uploadProgress,
|
|
351
514
|
handleMediaUpload,
|
|
352
515
|
handleMediaDelete,
|
|
353
516
|
handleMediaAltChange,
|
package/src/lib/registry.ts
CHANGED
|
@@ -3,6 +3,7 @@ import type { ComponentType, ReactNode } from "react";
|
|
|
3
3
|
import type { Audience } from "../schemas/auth";
|
|
4
4
|
import type { LinkValue } from "../schemas/link";
|
|
5
5
|
import type { SectionContent } from "../schemas/sections";
|
|
6
|
+
import type { VideoSource } from "../components/sections/Video/VideoBlock";
|
|
6
7
|
|
|
7
8
|
// --- Settings field types ---
|
|
8
9
|
|
|
@@ -55,6 +56,12 @@ export type SettingsFieldDef =
|
|
|
55
56
|
label: string;
|
|
56
57
|
default: LinkValue;
|
|
57
58
|
target?: "content" | "options";
|
|
59
|
+
}
|
|
60
|
+
| {
|
|
61
|
+
type: "video-source";
|
|
62
|
+
label: string;
|
|
63
|
+
default: VideoSource;
|
|
64
|
+
target?: "content" | "options";
|
|
58
65
|
};
|
|
59
66
|
|
|
60
67
|
export type SettingsSchema = Record<string, SettingsFieldDef>;
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import type { MediaItem } from "../media/types";
|
|
2
|
+
|
|
3
|
+
export interface MediaUploadPayload {
|
|
4
|
+
item: MediaItem;
|
|
5
|
+
blobs: { path: string; base64: string }[];
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Serialized-payload budget per /api/save request. Netlify rejects function
|
|
10
|
+
* request bodies over ~6MB at the platform level (before the app runs); 4MB
|
|
11
|
+
* leaves headroom for the JSON envelope plus sections/index/config riding the
|
|
12
|
+
* final chunk.
|
|
13
|
+
*/
|
|
14
|
+
export const SAVE_CHUNK_BUDGET_BYTES = 4 * 1024 * 1024;
|
|
15
|
+
|
|
16
|
+
/** Approximate serialized size of one upload: base64 dominates; the fixed
|
|
17
|
+
* allowances cover JSON keys, quoting, and the item's manifest metadata. */
|
|
18
|
+
export function uploadSizeBytes(upload: MediaUploadPayload): number {
|
|
19
|
+
return upload.blobs.reduce((sum, b) => sum + b.base64.length + b.path.length + 64, 512);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface PackedUploads {
|
|
23
|
+
chunks: MediaUploadPayload[][];
|
|
24
|
+
oversized: MediaUploadPayload[];
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Greedy in-order packing of uploads into budget-sized chunks. An item whose
|
|
28
|
+
* blobs alone exceed the budget can never be sent — it lands in `oversized`
|
|
29
|
+
* for the caller to reject with a per-file error. Items are never split
|
|
30
|
+
* across chunks (the manifest may only reference fully-committed items). */
|
|
31
|
+
export function packUploads(uploads: MediaUploadPayload[], budget = SAVE_CHUNK_BUDGET_BYTES): PackedUploads {
|
|
32
|
+
const chunks: MediaUploadPayload[][] = [];
|
|
33
|
+
const oversized: MediaUploadPayload[] = [];
|
|
34
|
+
let current: MediaUploadPayload[] = [];
|
|
35
|
+
let currentBytes = 0;
|
|
36
|
+
|
|
37
|
+
for (const upload of uploads) {
|
|
38
|
+
const size = uploadSizeBytes(upload);
|
|
39
|
+
if (size > budget) {
|
|
40
|
+
oversized.push(upload);
|
|
41
|
+
continue;
|
|
42
|
+
}
|
|
43
|
+
if (current.length > 0 && currentBytes + size > budget) {
|
|
44
|
+
chunks.push(current);
|
|
45
|
+
current = [];
|
|
46
|
+
currentBytes = 0;
|
|
47
|
+
}
|
|
48
|
+
current.push(upload);
|
|
49
|
+
currentBytes += size;
|
|
50
|
+
}
|
|
51
|
+
if (current.length > 0) chunks.push(current);
|
|
52
|
+
return { chunks, oversized };
|
|
53
|
+
}
|