@drawnagency/primitives 0.1.72 → 0.1.74

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/dist/{chunk-2HZB675H.js → chunk-7T2WQSWF.js} +2 -2
  2. package/dist/{chunk-7SIZ2LV5.js → chunk-IFHRHV7S.js} +1 -1
  3. package/dist/{chunk-VHINNGIF.js → chunk-NKVZGPR6.js} +1 -1
  4. package/dist/{chunk-BTGHF3SW.js → chunk-RIVBCL6U.js} +29 -3
  5. package/dist/{chunk-TOBPMMRM.js → chunk-S55OZ7F2.js} +8 -0
  6. package/dist/{chunk-WCALKWPO.js → chunk-T7MP7ANP.js} +11 -7
  7. package/dist/{chunk-RBTSUR5V.js → chunk-Y4JQGO6G.js} +10 -1
  8. package/dist/{chunk-UBK2IZ6G.js → chunk-YYDQGA2N.js} +13 -12
  9. package/dist/components/editor/index.js +3 -3
  10. package/dist/components/primitives/ResolvedMedia.d.ts.map +1 -1
  11. package/dist/components/primitives/index.js +4 -4
  12. package/dist/components/sections/Media/MediaBlock.d.ts.map +1 -1
  13. package/dist/components/sections/Video/VideoBlock.d.ts +2 -1
  14. package/dist/components/sections/Video/VideoBlock.d.ts.map +1 -1
  15. package/dist/components/sections/Video/index.d.ts +2 -1
  16. package/dist/components/sections/Video/index.d.ts.map +1 -1
  17. package/dist/components/sections/all-sections.d.ts +2 -1
  18. package/dist/components/sections/all-sections.d.ts.map +1 -1
  19. package/dist/components/sections/brand-guide.js +4 -4
  20. package/dist/components/sections/core-sections.d.ts +2 -1
  21. package/dist/components/sections/core-sections.d.ts.map +1 -1
  22. package/dist/components/sections/register-schemas.js +8 -8
  23. package/dist/components/sections/register.js +8 -8
  24. package/dist/components/shared/VideoSourceField.d.ts.map +1 -1
  25. package/dist/components/shell/EditorShell.d.ts.map +1 -1
  26. package/dist/hooks/useEditorPublish.d.ts +4 -1
  27. package/dist/hooks/useEditorPublish.d.ts.map +1 -1
  28. package/dist/index.js +5 -1
  29. package/dist/lib/save-chunks.d.ts +28 -0
  30. package/dist/lib/save-chunks.d.ts.map +1 -0
  31. package/dist/lib/video-url.d.ts +10 -1
  32. package/dist/lib/video-url.d.ts.map +1 -1
  33. package/dist/lib/video-url.js +1 -1
  34. package/dist/media/index.js +5 -1
  35. package/dist/media/resolve.d.ts +14 -0
  36. package/dist/media/resolve.d.ts.map +1 -1
  37. package/package.json +1 -1
  38. package/src/components/primitives/ResolvedMedia.tsx +10 -5
  39. package/src/components/sections/Media/MediaBlock.tsx +2 -1
  40. package/src/components/sections/Video/VideoBlock.tsx +9 -1
  41. package/src/components/sections/Video/index.tsx +11 -6
  42. package/src/components/shared/VideoSourceField.tsx +32 -1
  43. package/src/components/shell/EditorShell.tsx +8 -2
  44. package/src/hooks/useEditorPublish.ts +134 -50
  45. package/src/lib/save-chunks.ts +53 -0
  46. package/src/lib/video-url.ts +18 -5
  47. package/src/media/resolve.ts +18 -0
@@ -1,4 +1,5 @@
1
1
  import {
2
+ BAKED_MEDIA_KEYS,
2
3
  EXT_TO_MIME,
3
4
  MIME_TO_EXT,
4
5
  ProcessingQueue,
@@ -9,11 +10,12 @@ import {
9
10
  getMediaProvider,
10
11
  hashFileBuffer,
11
12
  mimeToExt,
13
+ rebindImageRef,
12
14
  resolveManifestReferences,
13
15
  resolveMedia,
14
16
  sanitizeMediaName,
15
17
  setMediaProvider
16
- } from "../chunk-TOBPMMRM.js";
18
+ } from "../chunk-S55OZ7F2.js";
17
19
  import "../chunk-4YEOTFVU.js";
18
20
  import {
19
21
  ImageManifestSchema,
@@ -23,6 +25,7 @@ import {
23
25
  } from "../chunk-SWIHUIFT.js";
24
26
  import "../chunk-JSBRDJBE.js";
25
27
  export {
28
+ BAKED_MEDIA_KEYS,
26
29
  EXT_TO_MIME,
27
30
  ImageManifestSchema,
28
31
  MIME_TO_EXT,
@@ -37,6 +40,7 @@ export {
37
40
  getMediaProvider,
38
41
  hashFileBuffer,
39
42
  mimeToExt,
43
+ rebindImageRef,
40
44
  resolveManifestReferences,
41
45
  resolveMedia,
42
46
  sanitizeMediaName,
@@ -12,4 +12,18 @@ export declare function resolveMedia(item: {
12
12
  * without needing to know about the manifest themselves.
13
13
  */
14
14
  export declare function resolveManifestReferences(content: unknown, manifest: MediaManifest, sizes: number[], provider: MediaProvider): unknown;
15
+ /**
16
+ * Keys stamped onto media refs by resolveManifestReferences above — derived
17
+ * from the referenced media item, never authored by the editor. Must stay in
18
+ * sync with the `patch` object built in resolveManifestReferences.
19
+ */
20
+ export declare const BAKED_MEDIA_KEYS: readonly ["src", "srcset", "alt", "width", "height", "poster"];
21
+ /**
22
+ * Re-point a media ref at a different library item. Drops every baked field so
23
+ * stale values resolved for the PREVIOUS image can't shadow the new one in the
24
+ * editor (ResolvedMedia prefers an explicit src prop over live resolution).
25
+ */
26
+ export declare function rebindImageRef<T extends {
27
+ imageId?: string;
28
+ }>(ref: T, imageId: string): T;
15
29
  //# sourceMappingURL=resolve.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"resolve.d.ts","sourceRoot":"","sources":["../../src/media/resolve.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,aAAa,EAAE,aAAa,EAAE,MAAM,SAAS,CAAC;AAC5D,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,YAAY,CAAC;AAEhD,wBAAgB,YAAY,CAC1B,IAAI,EAAE;IAAE,OAAO,CAAC,EAAE,MAAM,CAAC;IAAC,GAAG,CAAC,EAAE,MAAM,CAAA;CAAE,EACxC,QAAQ,EAAE,aAAa,EACvB,KAAK,EAAE,MAAM,EAAE,EACf,QAAQ,EAAE,aAAa,GACtB,aAAa,GAAG,IAAI,CAKtB;AAED;;;;;;GAMG;AACH,wBAAgB,yBAAyB,CACvC,OAAO,EAAE,OAAO,EAChB,QAAQ,EAAE,aAAa,EACvB,KAAK,EAAE,MAAM,EAAE,EACf,QAAQ,EAAE,aAAa,GACtB,OAAO,CA0CT"}
1
+ {"version":3,"file":"resolve.d.ts","sourceRoot":"","sources":["../../src/media/resolve.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,aAAa,EAAE,aAAa,EAAE,MAAM,SAAS,CAAC;AAC5D,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,YAAY,CAAC;AAEhD,wBAAgB,YAAY,CAC1B,IAAI,EAAE;IAAE,OAAO,CAAC,EAAE,MAAM,CAAC;IAAC,GAAG,CAAC,EAAE,MAAM,CAAA;CAAE,EACxC,QAAQ,EAAE,aAAa,EACvB,KAAK,EAAE,MAAM,EAAE,EACf,QAAQ,EAAE,aAAa,GACtB,aAAa,GAAG,IAAI,CAKtB;AAED;;;;;;GAMG;AACH,wBAAgB,yBAAyB,CACvC,OAAO,EAAE,OAAO,EAChB,QAAQ,EAAE,aAAa,EACvB,KAAK,EAAE,MAAM,EAAE,EACf,QAAQ,EAAE,aAAa,GACtB,OAAO,CA0CT;AAED;;;;GAIG;AACH,eAAO,MAAM,gBAAgB,gEAAiE,CAAC;AAE/F;;;;GAIG;AACH,wBAAgB,cAAc,CAAC,CAAC,SAAS;IAAE,OAAO,CAAC,EAAE,MAAM,CAAA;CAAE,EAAE,GAAG,EAAE,CAAC,EAAE,OAAO,EAAE,MAAM,GAAG,CAAC,CAIzF"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@drawnagency/primitives",
3
- "version": "0.1.72",
3
+ "version": "0.1.74",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  "./package.json": "./package.json",
@@ -27,13 +27,18 @@ export function ResolvedMedia({
27
27
  invertFrom,
28
28
  }: ResolvedMediaProps) {
29
29
  const resolved = useResolvedMedia(imageId);
30
- const src = propSrc || resolved.src;
31
- const srcset = propSrcset || resolved.srcset;
30
+ // A successful live resolution is always fresher than baked props (the ref
31
+ // may have been re-pointed since the server baked them) — prefer it, and use
32
+ // the baked values only where resolution is unavailable (zero-JS viewer,
33
+ // deleted/unknown id).
34
+ const hasResolved = !!resolved.src;
35
+ const src = hasResolved ? resolved.src : propSrc;
36
+ const srcset = hasResolved ? resolved.srcset : propSrcset;
32
37
  const poster = resolved.poster;
33
- const alt = propAlt ?? resolved.alt;
38
+ const alt = hasResolved ? resolved.alt : (propAlt ?? "");
34
39
  const kind = resolved.kind;
35
- const width = propWidth ?? resolved.width;
36
- const height = propHeight ?? resolved.height;
40
+ const width = hasResolved ? resolved.width : propWidth;
41
+ const height = hasResolved ? resolved.height : propHeight;
37
42
 
38
43
  // `loaded` fades the placeholder out via onLoad in the hydrated editor. In the
39
44
  // zero-JS viewer this stays false (no JS), so the placeholder remains painted
@@ -5,6 +5,7 @@ import { ResolvedMedia } from "../../primitives/ResolvedMedia";
5
5
  import { ImageDropZone } from "../../primitives/ImageDropZone";
6
6
  import { EditablePlainText } from "../../primitives/EditablePlainText";
7
7
  import { useMediaLibrary } from "../../shell/MediaLibraryContext";
8
+ import { rebindImageRef } from "../../../media/resolve";
8
9
 
9
10
  export interface MediaBlockProps {
10
11
  refValue: SingleMediaReference;
@@ -25,7 +26,7 @@ export function MediaBlock({ refValue, options, link, isEditMode, onRefChange }:
25
26
  ? Array.isArray(refValue.caption) ? refValue.caption.join("\n") : refValue.caption
26
27
  : "";
27
28
 
28
- const replace = onRefChange ? (imageId: string) => onRefChange({ ...refValue, imageId }) : undefined;
29
+ const replace = onRefChange ? (imageId: string) => onRefChange(rebindImageRef(refValue, imageId)) : undefined;
29
30
 
30
31
  // The zero-JS viewer has no MediaLibraryContext, so useResolvedMedia() can't
31
32
  // resolve the URL. The SSR pass (resolveManifestReferences) bakes src/srcset/alt
@@ -7,7 +7,15 @@ import { EditablePlainText } from "../../primitives/EditablePlainText";
7
7
 
8
8
  export type VideoSource =
9
9
  | { type: "library"; ref: { imageId: string }; poster?: { imageId: string; src?: string } }
10
- | { type: "external"; provider: "youtube" | "vimeo"; id: string; url: string; width: number; height: number };
10
+ | {
11
+ type: "external";
12
+ provider: "youtube" | "vimeo" | "instagram";
13
+ id: string;
14
+ kind?: "post" | "reel" | "tv";
15
+ url: string;
16
+ width: number;
17
+ height: number;
18
+ };
11
19
 
12
20
  interface VideoBlockProps {
13
21
  source: VideoSource;
@@ -24,11 +24,15 @@ const LibrarySource = z.object({
24
24
 
25
25
  const ExternalSource = z.object({
26
26
  type: z.literal("external"),
27
- provider: z.enum(["youtube", "vimeo"]).describe("Hosting provider, derived from the pasted URL."),
28
- id: z.string().describe("Provider video id parsed from the pasted URL."),
27
+ provider: z.enum(["youtube", "vimeo", "instagram"]).describe("Hosting provider, derived from the pasted URL."),
28
+ id: z.string().describe("Provider video id parsed from the pasted URL (Instagram: the post/reel shortcode)."),
29
+ kind: z
30
+ .enum(["post", "reel", "tv"])
31
+ .optional()
32
+ .describe("Instagram only: which embed path the shortcode came from ('p' → post). Omitted for other providers."),
29
33
  url: z.string().describe("The original video page URL as pasted."),
30
- width: z.number().positive().describe("Native width used for the aspect-ratio box (from oEmbed; 16 when unknown)."),
31
- height: z.number().positive().describe("Native height used for the aspect-ratio box (from oEmbed; 9 when unknown)."),
34
+ width: z.number().positive().describe("Aspect-ratio width (from oEmbed; Instagram: 9 for reels/tv, 4 for posts; editors can override)."),
35
+ height: z.number().positive().describe("Aspect-ratio height (from oEmbed; Instagram: 16 for reels/tv, 5 for posts; editors can override)."),
32
36
  });
33
37
 
34
38
  const schema = z.object({
@@ -36,7 +40,7 @@ const schema = z.object({
36
40
  content: z.object({
37
41
  source: z
38
42
  .discriminatedUnion("type", [LibrarySource, ExternalSource])
39
- .describe("Where the video comes from: a media-library upload or an external YouTube/Vimeo URL."),
43
+ .describe("Where the video comes from: a media-library upload or an external YouTube/Vimeo/Instagram URL."),
40
44
  caption: z.string().optional().describe("Optional caption rendered beneath the player when Show caption is on."),
41
45
  }),
42
46
  options: z
@@ -56,7 +60,8 @@ export default defineSection({
56
60
  icon: <Clapperboard size={18} />,
57
61
  schema,
58
62
  authoringNote:
59
- "A watchable video player — self-hosted from the media library or embedded from YouTube/Vimeo. " +
63
+ "A watchable video player — self-hosted from the media library or embedded from YouTube/Vimeo/Instagram. " +
64
+ "Instagram embeds render the post card at a kind-derived aspect ratio (reels 9:16, posts 4:5) that editors can override. " +
60
65
  "Use playback 'click' for content viewers press play on (controls + audio) and 'ambient' for silent looping footage. " +
61
66
  "For decorative loops inside grids prefer the Media block; large self-hosted files require the site's asset storage.",
62
67
  component: ({ content, options, onChange }) => (
@@ -13,6 +13,14 @@ interface Props {
13
13
 
14
14
  type Mode = "library" | "embed";
15
15
 
16
+ // Instagram embeds only: the URL kind picks the initial ratio (reels 9:16,
17
+ // posts 4:5), but /p/ shortcodes are ambiguous so editors can correct it.
18
+ const ASPECTS = [
19
+ { label: "9:16", width: 9, height: 16 },
20
+ { label: "4:5", width: 4, height: 5 },
21
+ { label: "1:1", width: 1, height: 1 },
22
+ ] as const;
23
+
16
24
  export function VideoSourceField({ value, onChange }: Props) {
17
25
  const mediaLibrary = useMediaLibrary();
18
26
  const [mode, setMode] = useState<Mode>(value?.type === "external" ? "embed" : "library");
@@ -42,6 +50,7 @@ export function VideoSourceField({ value, onChange }: Props) {
42
50
  type: "external",
43
51
  provider: body.provider,
44
52
  id: body.id,
53
+ ...(body.kind ? { kind: body.kind } : {}),
45
54
  url: body.url,
46
55
  width: body.width,
47
56
  height: body.height,
@@ -118,7 +127,7 @@ export function VideoSourceField({ value, onChange }: Props) {
118
127
  void submitEmbed();
119
128
  }
120
129
  }}
121
- placeholder="Paste a YouTube or Vimeo URL…"
130
+ placeholder="Paste a YouTube, Vimeo, or Instagram URL…"
122
131
  className="flex-1 rounded border border-base-200 bg-base px-2 py-1 text-sm text-base-contrast placeholder:text-base-contrast-light focus:outline-none focus:ring-1 focus:ring-primary"
123
132
  />
124
133
  <Button
@@ -137,6 +146,28 @@ export function VideoSourceField({ value, onChange }: Props) {
137
146
  Added — couldn't fetch exact dimensions, using 16:9.
138
147
  </p>
139
148
  ) : null}
149
+ {value?.type === "external" && value.provider === "instagram" ? (
150
+ <div className="flex items-center gap-2">
151
+ <span className="text-xs text-base-contrast-light">Aspect</span>
152
+ {ASPECTS.map(({ label, width, height }) => {
153
+ const active = value.width === width && value.height === height;
154
+ return (
155
+ <button
156
+ key={label}
157
+ type="button"
158
+ aria-pressed={active}
159
+ onClick={() => onChange({ ...value, width, height })}
160
+ className={cn(
161
+ "cursor-pointer rounded border px-2 py-0.5 text-xs",
162
+ active ? "border-primary bg-primary text-primary-contrast" : "border-base-200 text-base-contrast",
163
+ )}
164
+ >
165
+ {label}
166
+ </button>
167
+ );
168
+ })}
169
+ </div>
170
+ ) : null}
140
171
  </div>
141
172
  )}
142
173
  </div>
@@ -232,7 +232,7 @@ export default function EditorShell({
232
232
 
233
233
  const buildStatus = useBuildStatus();
234
234
 
235
- const { publishAction, publishFeedback, handleSave, handlePublish, handleSaveAndPublish } = useEditorPublish({
235
+ const { publishAction, publishFeedback, saveProgress, handleSave, handlePublish, handleSaveAndPublish } = useEditorPublish({
236
236
  flushNow: persistence.flushNow,
237
237
  cancelPendingFlush: persistence.cancelPendingFlush,
238
238
  isConfigDirty: persistence.isConfigDirty,
@@ -884,6 +884,7 @@ export default function EditorShell({
884
884
  localChangesExist={localChangesExist}
885
885
  publishAction={publishAction}
886
886
  publishFeedback={publishFeedback}
887
+ saveProgress={saveProgress}
887
888
  onSave={handleSave}
888
889
  onPublish={handlePublish}
889
890
  onSaveAndPublish={handleSaveAndPublish}
@@ -1489,12 +1490,14 @@ function DismissButton({ onClick }: { onClick: () => void }) {
1489
1490
  function StatusText({
1490
1491
  publishAction,
1491
1492
  publishFeedback,
1493
+ saveProgress,
1492
1494
  buildState,
1493
1495
  buildElapsed,
1494
1496
  onDismiss,
1495
1497
  }: {
1496
1498
  publishAction: "idle" | "saving" | "publishing";
1497
1499
  publishFeedback: string | null;
1500
+ saveProgress: string | null;
1498
1501
  buildState: "idle" | "building" | "ready" | "error" | "fading";
1499
1502
  buildElapsed: number;
1500
1503
  onDismiss: () => void;
@@ -1502,7 +1505,7 @@ function StatusText({
1502
1505
  if (publishAction === "saving") {
1503
1506
  return (
1504
1507
  <span className="relative text-xs font-medium text-base-content/60">
1505
- Saving changes<LoadingEllipsis />
1508
+ {saveProgress ?? <>Saving changes<LoadingEllipsis /></>}
1506
1509
  </span>
1507
1510
  );
1508
1511
  }
@@ -1550,6 +1553,7 @@ function EditorToolbar({
1550
1553
  localChangesExist,
1551
1554
  publishAction,
1552
1555
  publishFeedback,
1556
+ saveProgress,
1553
1557
  onSave,
1554
1558
  onPublish,
1555
1559
  onSaveAndPublish,
@@ -1569,6 +1573,7 @@ function EditorToolbar({
1569
1573
  localChangesExist: boolean;
1570
1574
  publishAction: "idle" | "saving" | "publishing";
1571
1575
  publishFeedback: string | null;
1576
+ saveProgress: string | null;
1572
1577
  onSave: () => void;
1573
1578
  onPublish: () => void;
1574
1579
  onSaveAndPublish: () => void;
@@ -1640,6 +1645,7 @@ function EditorToolbar({
1640
1645
  <StatusText
1641
1646
  publishAction={publishAction}
1642
1647
  publishFeedback={publishFeedback}
1648
+ saveProgress={saveProgress}
1643
1649
  buildState={buildState}
1644
1650
  buildElapsed={buildElapsed}
1645
1651
  onDismiss={onBuildDismiss}
@@ -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
 
@@ -148,6 +182,7 @@ export function useEditorPublish({
148
182
  pendingMediaDeletions: string[];
149
183
  mediaManifest: MediaManifest;
150
184
  manifestDirty: boolean;
185
+ saveChunkBudget?: number;
151
186
  }): Promise<{ sha: string; savedSections: { sectionId: string; updatedAt: string }[]; uploadedItems: MediaItem[] }> {
152
187
  const dirty = await getDirtySectionRows();
153
188
  const gathered = await gatherMediaPayload(
@@ -157,6 +192,14 @@ export function useEditorPublish({
157
192
  args.manifestDirty,
158
193
  );
159
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
+
160
203
  let siteIndex = args.siteIndexRef.current;
161
204
  if (args.deletedSectionIds?.length) {
162
205
  const deleteSet = new Set(args.deletedSectionIds);
@@ -171,48 +214,71 @@ export function useEditorPublish({
171
214
  };
172
215
  }
173
216
 
174
- const response = await fetch("/api/save", {
175
- method: "POST",
176
- headers: { "Content-Type": "application/json" },
177
- body: JSON.stringify({
178
- sections: dirty.map(({ sectionId, content }) => ({
179
- id: sectionId,
180
- content,
181
- })),
182
- siteIndex,
183
- baseVersion: getBaseVersion(),
184
- ...(args.deletedSectionIds?.length ? { deletedSectionIds: args.deletedSectionIds } : {}),
185
- ...(args.isConfigDirty() ? { siteConfig: args.siteConfig } : {}),
186
- ...(gathered.hasMediaChanges ? {
187
- media: {
188
- uploads: gathered.mediaUploads.map(({ item, blobs }) => ({ item, blobs })),
189
- deletions: args.pendingMediaDeletions.map((id) => ({
190
- id,
191
- folder: args.mediaManifest.images[id]?.folder,
192
- })).filter((d) => d.folder),
193
- manifest: gathered.updatedManifest,
194
- },
195
- } : {}),
196
- }),
197
- });
198
-
199
- if (!response.ok) {
200
- const errorBody = await response.json().catch(() => ({}));
201
- const message = errorBody.message || errorBody.error || "Save failed";
202
- throw Object.assign(new Error(message), { status: response.status });
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
+ }
203
254
  }
204
255
 
205
- const responseData = await response.json().catch(() => null);
206
- if (!responseData?.sha) {
207
- throw new Error("Save response missing SHA");
256
+ if (leadChunks.length > 0) {
257
+ setSaveProgress(finalUploads.length > 0 ? `Saving images ${totalUploads}/${totalUploads}…` : null);
208
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
+
209
278
  return {
210
- sha: responseData.sha as string,
279
+ sha: responseData.sha,
211
280
  savedSections: dirty.map(({ sectionId, updatedAt }) => ({ sectionId, updatedAt })),
212
- // Only the items whose blobs were actually written to GitHub. Items skipped
213
- // by gatherMediaPayload (blobs not yet persisted) are excluded so the caller
214
- // never promotes them into the manifest as orphans.
215
- uploadedItems: gathered.mediaUploads.map(({ item }) => item),
281
+ uploadedItems: [...uploadedItems, ...finalUploads.map((u) => u.item)],
216
282
  };
217
283
  }
218
284
 
@@ -242,6 +308,7 @@ export function useEditorPublish({
242
308
  siteConfig: siteConfig!,
243
309
  siteIndexRef, deletedSectionIds, isConfigDirty,
244
310
  pendingMediaItems, pendingMediaDeletions, mediaManifest, manifestDirty,
311
+ saveChunkBudget,
245
312
  });
246
313
  }
247
314
 
@@ -269,8 +336,9 @@ export function useEditorPublish({
269
336
 
270
337
  function reportSaveError(error: unknown, failLabel: string): void {
271
338
  console.error(`${failLabel} failed:`, error);
339
+ const message = error instanceof Error && error.message ? error.message : `${failLabel} failed`;
272
340
  const isConflict = (error as { status?: number })?.status === 409;
273
- showFeedback(isConflict ? (error as Error).message : `${failLabel} failed`, isConflict ? 8000 : 5000);
341
+ showFeedback(message, isConflict ? 8000 : 6000);
274
342
  }
275
343
 
276
344
  const handleSave = useCallback(async () => {
@@ -286,19 +354,26 @@ export function useEditorPublish({
286
354
  }
287
355
 
288
356
  const { sha, savedSections, uploadedItems } = await runPerformSave();
289
-
290
- await commitSavedRows(savedSections, uploadedItems);
291
- finishSave(uploadedItems);
292
357
  onShasUpdated(sha, null);
293
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
+
294
368
  showFeedback("Saved", 3000);
295
369
  } catch (error) {
296
370
  reportSaveError(error, "Save");
297
371
  } finally {
298
372
  inFlightRef.current = false;
299
373
  setPublishAction("idle");
374
+ setSaveProgress(null);
300
375
  }
301
- }, [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]);
302
377
 
303
378
  const handlePublish = useCallback(async () => {
304
379
  setPublishAction("publishing");
@@ -369,12 +444,20 @@ export function useEditorPublish({
369
444
  const { sha } = publishData;
370
445
 
371
446
  if (hasLocalEdits) {
372
- await commitSavedRows(savedSections, uploadedItems);
373
- // Re-cache against the PUBLISHED sha (commitSavedRows just invalidated the
374
- // cache); the draft branch is gone after publish, so this is the snapshot a
375
- // later cache hit restores.
376
- await cacheContent(sha, sections, siteIndexRef.current, siteConfig!);
377
- finishSave(uploadedItems);
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
+ }
378
461
  }
379
462
 
380
463
  onShasUpdated(null, sha);
@@ -384,8 +467,9 @@ export function useEditorPublish({
384
467
  } finally {
385
468
  inFlightRef.current = false;
386
469
  setPublishAction("idle");
470
+ setSaveProgress(null);
387
471
  }
388
- }, [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]);
389
473
 
390
- return { publishAction, publishFeedback, handleSave, handlePublish, handleSaveAndPublish };
474
+ return { publishAction, publishFeedback, saveProgress, handleSave, handlePublish, handleSaveAndPublish };
391
475
  }
@@ -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
+ }