@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.
- package/dist/{chunk-2HZB675H.js → chunk-7T2WQSWF.js} +2 -2
- package/dist/{chunk-7SIZ2LV5.js → chunk-IFHRHV7S.js} +1 -1
- package/dist/{chunk-VHINNGIF.js → chunk-NKVZGPR6.js} +1 -1
- package/dist/{chunk-BTGHF3SW.js → chunk-RIVBCL6U.js} +29 -3
- package/dist/{chunk-TOBPMMRM.js → chunk-S55OZ7F2.js} +8 -0
- package/dist/{chunk-WCALKWPO.js → chunk-T7MP7ANP.js} +11 -7
- package/dist/{chunk-RBTSUR5V.js → chunk-Y4JQGO6G.js} +10 -1
- package/dist/{chunk-UBK2IZ6G.js → chunk-YYDQGA2N.js} +13 -12
- package/dist/components/editor/index.js +3 -3
- package/dist/components/primitives/ResolvedMedia.d.ts.map +1 -1
- package/dist/components/primitives/index.js +4 -4
- package/dist/components/sections/Media/MediaBlock.d.ts.map +1 -1
- package/dist/components/sections/Video/VideoBlock.d.ts +2 -1
- package/dist/components/sections/Video/VideoBlock.d.ts.map +1 -1
- package/dist/components/sections/Video/index.d.ts +2 -1
- package/dist/components/sections/Video/index.d.ts.map +1 -1
- package/dist/components/sections/all-sections.d.ts +2 -1
- package/dist/components/sections/all-sections.d.ts.map +1 -1
- package/dist/components/sections/brand-guide.js +4 -4
- package/dist/components/sections/core-sections.d.ts +2 -1
- package/dist/components/sections/core-sections.d.ts.map +1 -1
- package/dist/components/sections/register-schemas.js +8 -8
- package/dist/components/sections/register.js +8 -8
- package/dist/components/shared/VideoSourceField.d.ts.map +1 -1
- package/dist/components/shell/EditorShell.d.ts.map +1 -1
- package/dist/hooks/useEditorPublish.d.ts +4 -1
- package/dist/hooks/useEditorPublish.d.ts.map +1 -1
- package/dist/index.js +5 -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 +10 -1
- package/dist/lib/video-url.d.ts.map +1 -1
- package/dist/lib/video-url.js +1 -1
- package/dist/media/index.js +5 -1
- package/dist/media/resolve.d.ts +14 -0
- package/dist/media/resolve.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/components/primitives/ResolvedMedia.tsx +10 -5
- package/src/components/sections/Media/MediaBlock.tsx +2 -1
- package/src/components/sections/Video/VideoBlock.tsx +9 -1
- package/src/components/sections/Video/index.tsx +11 -6
- package/src/components/shared/VideoSourceField.tsx +32 -1
- package/src/components/shell/EditorShell.tsx +8 -2
- package/src/hooks/useEditorPublish.ts +134 -50
- package/src/lib/save-chunks.ts +53 -0
- package/src/lib/video-url.ts +18 -5
- package/src/media/resolve.ts +18 -0
package/dist/media/index.js
CHANGED
|
@@ -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-
|
|
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,
|
package/dist/media/resolve.d.ts
CHANGED
|
@@ -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
|
@@ -27,13 +27,18 @@ export function ResolvedMedia({
|
|
|
27
27
|
invertFrom,
|
|
28
28
|
}: ResolvedMediaProps) {
|
|
29
29
|
const resolved = useResolvedMedia(imageId);
|
|
30
|
-
|
|
31
|
-
|
|
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 =
|
|
38
|
+
const alt = hasResolved ? resolved.alt : (propAlt ?? "");
|
|
34
39
|
const kind = resolved.kind;
|
|
35
|
-
const width =
|
|
36
|
-
const 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(
|
|
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
|
-
| {
|
|
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("
|
|
31
|
-
height: z.number().positive().describe("
|
|
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
|
|
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
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
const
|
|
201
|
-
|
|
202
|
-
|
|
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
|
-
|
|
206
|
-
|
|
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
|
|
279
|
+
sha: responseData.sha,
|
|
211
280
|
savedSections: dirty.map(({ sectionId, updatedAt }) => ({ sectionId, updatedAt })),
|
|
212
|
-
|
|
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(
|
|
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
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
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
|
+
}
|