@drawnagency/primitives 0.1.71 → 0.1.72
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-2HZB675H.js} +12 -12
- package/dist/chunk-7SIZ2LV5.js +117 -0
- package/dist/{chunk-2BHPWO7M.js → chunk-BTGHF3SW.js} +158 -32
- package/dist/{chunk-3DCG4DNU.js → chunk-PZJL2S3Z.js} +8 -4
- package/dist/chunk-RBTSUR5V.js +37 -0
- package/dist/{chunk-NR7UB6DZ.js → chunk-SFKYUKVN.js} +1 -1
- package/dist/{chunk-YMVYTYGQ.js → chunk-SWIHUIFT.js} +28 -16
- package/dist/{chunk-6UDYJQL6.js → chunk-TOBPMMRM.js} +1 -1
- package/dist/{chunk-MU3CDERQ.js → chunk-UBK2IZ6G.js} +276 -96
- package/dist/{chunk-WXVKX6PY.js → chunk-VHINNGIF.js} +41 -95
- package/dist/{chunk-KWBM27UR.js → chunk-WCALKWPO.js} +4 -59
- 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/index.js +7 -7
- 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.map +1 -1
- package/dist/hooks/useMediaPipeline.d.ts +9 -1
- package/dist/hooks/useMediaPipeline.d.ts.map +1 -1
- package/dist/index.js +24 -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/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 +2 -2
- 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/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 +8 -0
- package/src/components/shell/MediaLibraryModal.tsx +47 -1
- package/src/hooks/useEditorPublish.ts +3 -0
- package/src/hooks/useMediaPipeline.ts +188 -25
- package/src/lib/registry.ts +7 -0
- package/src/lib/video-url.ts +39 -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
package/dist/storage/types.d.ts
CHANGED
|
@@ -30,4 +30,30 @@ export type FileWrite = {
|
|
|
30
30
|
content: string | Uint8Array | null;
|
|
31
31
|
encoding?: "utf-8" | "base64";
|
|
32
32
|
};
|
|
33
|
+
/**
|
|
34
|
+
* Optional large-asset store (Supabase Storage bucket). Holds video originals
|
|
35
|
+
* too big for the git path; deliberately generic so future large kinds (zips)
|
|
36
|
+
* can slot in. All paths are SITE-RELATIVE ("media/{hash}/original.mp4") —
|
|
37
|
+
* implementations prepend the tenant prefix ("sites/{siteId}/") themselves so
|
|
38
|
+
* a caller can never address another tenant's objects.
|
|
39
|
+
*/
|
|
40
|
+
export interface AssetStore {
|
|
41
|
+
/** Mint a signed direct-upload URL for the browser (PUT). */
|
|
42
|
+
createUploadUrl(path: string, opts: {
|
|
43
|
+
contentType: string;
|
|
44
|
+
}): Promise<{
|
|
45
|
+
uploadUrl: string;
|
|
46
|
+
path: string;
|
|
47
|
+
publicUrl: string;
|
|
48
|
+
}>;
|
|
49
|
+
/** Deterministic public CDN URL for an object (no network call). */
|
|
50
|
+
getPublicUrl(path: string): string;
|
|
51
|
+
delete(paths: string[]): Promise<void>;
|
|
52
|
+
/** Recursive listing under a site-relative prefix. */
|
|
53
|
+
list(prefix: string): Promise<{
|
|
54
|
+
path: string;
|
|
55
|
+
size: number;
|
|
56
|
+
updatedAt: string;
|
|
57
|
+
}[]>;
|
|
58
|
+
}
|
|
33
59
|
//# sourceMappingURL=types.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../../src/storage/types.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AACH,qBAAa,oBAAqB,SAAQ,KAAK;aACjB,cAAc,EAAE,MAAM,GAAG,IAAI;gBAA7B,cAAc,EAAE,MAAM,GAAG,IAAI;CAI1D;AAED,MAAM,WAAW,eAAe;IAC9B,UAAU,CACR,KAAK,EAAE,SAAS,EAAE,EAClB,OAAO,EAAE,MAAM,EAIf,OAAO,CAAC,EAAE;QAAE,WAAW,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;KAAE,GACxC,OAAO,CAAC;QAAE,OAAO,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;IAEhC,QAAQ,CACN,IAAI,EAAE,MAAM,EACZ,OAAO,CAAC,EAAE;QAAE,KAAK,CAAC,EAAE,OAAO,CAAA;KAAE,GAC5B,OAAO,CAAC,UAAU,GAAG,IAAI,CAAC,CAAC;IAE9B,YAAY,IAAI,OAAO,CAAC;QAAE,OAAO,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;IAE7C,UAAU,IAAI,OAAO,CAAC,MAAM,CAAC,CAAC;IAE9B,aAAa,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE;QAAE,KAAK,CAAC,EAAE,OAAO,CAAA;KAAE,GAAG,OAAO,CAAC,MAAM,EAAE,CAAC,CAAC;CAC/E;AAED,MAAM,MAAM,SAAS,GAAG;IACtB,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,EAAE,MAAM,GAAG,UAAU,GAAG,IAAI,CAAC;IACpC,QAAQ,CAAC,EAAE,OAAO,GAAG,QAAQ,CAAC;CAC/B,CAAC"}
|
|
1
|
+
{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../../src/storage/types.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AACH,qBAAa,oBAAqB,SAAQ,KAAK;aACjB,cAAc,EAAE,MAAM,GAAG,IAAI;gBAA7B,cAAc,EAAE,MAAM,GAAG,IAAI;CAI1D;AAED,MAAM,WAAW,eAAe;IAC9B,UAAU,CACR,KAAK,EAAE,SAAS,EAAE,EAClB,OAAO,EAAE,MAAM,EAIf,OAAO,CAAC,EAAE;QAAE,WAAW,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;KAAE,GACxC,OAAO,CAAC;QAAE,OAAO,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;IAEhC,QAAQ,CACN,IAAI,EAAE,MAAM,EACZ,OAAO,CAAC,EAAE;QAAE,KAAK,CAAC,EAAE,OAAO,CAAA;KAAE,GAC5B,OAAO,CAAC,UAAU,GAAG,IAAI,CAAC,CAAC;IAE9B,YAAY,IAAI,OAAO,CAAC;QAAE,OAAO,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;IAE7C,UAAU,IAAI,OAAO,CAAC,MAAM,CAAC,CAAC;IAE9B,aAAa,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE;QAAE,KAAK,CAAC,EAAE,OAAO,CAAA;KAAE,GAAG,OAAO,CAAC,MAAM,EAAE,CAAC,CAAC;CAC/E;AAED,MAAM,MAAM,SAAS,GAAG;IACtB,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,EAAE,MAAM,GAAG,UAAU,GAAG,IAAI,CAAC;IACpC,QAAQ,CAAC,EAAE,OAAO,GAAG,QAAQ,CAAC;CAC/B,CAAC;AAEF;;;;;;GAMG;AACH,MAAM,WAAW,UAAU;IACzB,6DAA6D;IAC7D,eAAe,CACb,IAAI,EAAE,MAAM,EACZ,IAAI,EAAE;QAAE,WAAW,EAAE,MAAM,CAAA;KAAE,GAC5B,OAAO,CAAC;QAAE,SAAS,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,MAAM,CAAC;QAAC,SAAS,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;IAEnE,oEAAoE;IACpE,YAAY,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAAC;IAEnC,MAAM,CAAC,KAAK,EAAE,MAAM,EAAE,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAEvC,sDAAsD;IACtD,IAAI,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,MAAM,CAAC;QAAC,SAAS,EAAE,MAAM,CAAA;KAAE,EAAE,CAAC,CAAC;CACpF"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@drawnagency/primitives",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.72",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"exports": {
|
|
6
6
|
"./package.json": "./package.json",
|
|
@@ -36,6 +36,10 @@
|
|
|
36
36
|
"types": "./dist/lib/env.d.ts",
|
|
37
37
|
"default": "./dist/lib/env.js"
|
|
38
38
|
},
|
|
39
|
+
"./lib/video-url": {
|
|
40
|
+
"types": "./dist/lib/video-url.d.ts",
|
|
41
|
+
"default": "./dist/lib/video-url.js"
|
|
42
|
+
},
|
|
39
43
|
"./lib/platform-broker": {
|
|
40
44
|
"types": "./dist/lib/platform-broker.d.ts",
|
|
41
45
|
"default": "./dist/lib/platform-broker.js"
|
|
@@ -5,8 +5,10 @@ import { Select } from "../shared/Select";
|
|
|
5
5
|
import { Checkbox } from "../shared/Checkbox";
|
|
6
6
|
import { FormLabel } from "../shared/FormLabel";
|
|
7
7
|
import { LinkField } from "../shared/LinkField";
|
|
8
|
+
import { VideoSourceField } from "../shared/VideoSourceField";
|
|
8
9
|
import { Tabs } from "../shared/Tabs";
|
|
9
10
|
import type { LinkValue } from "../../schemas/link";
|
|
11
|
+
import type { VideoSource } from "../sections/Video/VideoBlock";
|
|
10
12
|
import { cn } from "../../lib/cn";
|
|
11
13
|
|
|
12
14
|
export interface SettingsFormResult {
|
|
@@ -163,6 +165,15 @@ function renderField(
|
|
|
163
165
|
/>
|
|
164
166
|
);
|
|
165
167
|
|
|
168
|
+
case "video-source":
|
|
169
|
+
return (
|
|
170
|
+
<VideoSourceField
|
|
171
|
+
key={key}
|
|
172
|
+
value={value as VideoSource | undefined}
|
|
173
|
+
onChange={(v) => handleFieldChange(key, v)}
|
|
174
|
+
/>
|
|
175
|
+
);
|
|
176
|
+
|
|
166
177
|
default:
|
|
167
178
|
return null;
|
|
168
179
|
}
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import { cn } from "../../../lib/cn";
|
|
2
|
+
import { embedUrl } from "../../../lib/video-url";
|
|
3
|
+
import { useResolvedMedia } from "../../../hooks/useResolvedMedia";
|
|
4
|
+
import { useMediaLibrary } from "../../shell/MediaLibraryContext";
|
|
5
|
+
import { ImageDropZone } from "../../primitives/ImageDropZone";
|
|
6
|
+
import { EditablePlainText } from "../../primitives/EditablePlainText";
|
|
7
|
+
|
|
8
|
+
export type VideoSource =
|
|
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 };
|
|
11
|
+
|
|
12
|
+
interface VideoBlockProps {
|
|
13
|
+
source: VideoSource;
|
|
14
|
+
caption?: string;
|
|
15
|
+
options: { playback?: "ambient" | "click"; showCaption?: boolean };
|
|
16
|
+
isEditMode?: boolean;
|
|
17
|
+
onSourceChange?: (source: VideoSource) => void;
|
|
18
|
+
onCaptionChange?: (caption: string) => void;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function VideoBlock({ source, caption, options, isEditMode, onSourceChange, onCaptionChange }: VideoBlockProps) {
|
|
22
|
+
const mediaLibrary = useMediaLibrary();
|
|
23
|
+
|
|
24
|
+
// Hooks run unconditionally (rules of hooks): undefined ids resolve to nothing.
|
|
25
|
+
const imageId = source.type === "library" ? source.ref.imageId : undefined;
|
|
26
|
+
const posterId = source.type === "library" ? source.poster?.imageId : undefined;
|
|
27
|
+
const resolved = useResolvedMedia(imageId);
|
|
28
|
+
const posterOverride = useResolvedMedia(posterId);
|
|
29
|
+
|
|
30
|
+
const playback = options.playback ?? "click";
|
|
31
|
+
const editable = Boolean(isEditMode && onSourceChange);
|
|
32
|
+
const captionEditable = Boolean(isEditMode && onCaptionChange);
|
|
33
|
+
// Edit mode: show the (possibly empty) editor whenever the toggle is on, so
|
|
34
|
+
// the placeholder is typeable. View mode: only render a caption that exists
|
|
35
|
+
// (mirrors MediaBlock's showCaption gating — no empty figcaption for viewers).
|
|
36
|
+
const showCaptionBlock = Boolean(options.showCaption && (captionEditable || caption));
|
|
37
|
+
|
|
38
|
+
const replaceWithLibraryVideo = editable
|
|
39
|
+
? (newImageId: string) => onSourceChange!({ type: "library", ref: { imageId: newImageId } })
|
|
40
|
+
: undefined;
|
|
41
|
+
|
|
42
|
+
let media: React.ReactNode;
|
|
43
|
+
let aspect: string | undefined;
|
|
44
|
+
|
|
45
|
+
if (source.type === "external") {
|
|
46
|
+
aspect = `${source.width} / ${source.height}`;
|
|
47
|
+
media = (
|
|
48
|
+
<iframe
|
|
49
|
+
src={embedUrl(source)}
|
|
50
|
+
title={caption || "Embedded video"}
|
|
51
|
+
loading="lazy"
|
|
52
|
+
allow="autoplay; fullscreen; picture-in-picture; encrypted-media"
|
|
53
|
+
allowFullScreen
|
|
54
|
+
className="absolute inset-0 h-full w-full border-0"
|
|
55
|
+
/>
|
|
56
|
+
);
|
|
57
|
+
} else {
|
|
58
|
+
// The zero-JS viewer has no MediaLibraryContext, so useResolvedMedia() can't
|
|
59
|
+
// resolve the URL. The SSR pass (resolveManifestReferences) bakes src/poster/
|
|
60
|
+
// width/height onto the ref — it matches any object structurally by its
|
|
61
|
+
// `imageId` key, so content.source.ref gets these fields without code changes
|
|
62
|
+
// there. Forward them here so a real <video> renders (mirrors MediaBlock's
|
|
63
|
+
// `baked` pattern for the same reason).
|
|
64
|
+
const baked = source.ref as Record<string, unknown>;
|
|
65
|
+
const bakedSrc = typeof baked.src === "string" ? baked.src : undefined;
|
|
66
|
+
const bakedPoster = typeof baked.poster === "string" ? baked.poster : undefined;
|
|
67
|
+
const bakedWidth = typeof baked.width === "number" ? baked.width : undefined;
|
|
68
|
+
const bakedHeight = typeof baked.height === "number" ? baked.height : undefined;
|
|
69
|
+
// The poster OVERRIDE lives as a sibling `{ imageId }` object (not nested
|
|
70
|
+
// inside `ref`) so the SSR baker resolves it independently instead of
|
|
71
|
+
// colliding with the video's own auto-generated poster. `source.poster.src`
|
|
72
|
+
// is the baked field on the viewer path; `posterOverride.src` (below) is
|
|
73
|
+
// the live-resolved equivalent for the editor path.
|
|
74
|
+
const bakedPosterOverride = source.poster as Record<string, unknown> | undefined;
|
|
75
|
+
const bakedPosterOverrideSrc = typeof bakedPosterOverride?.src === "string" ? bakedPosterOverride.src : undefined;
|
|
76
|
+
|
|
77
|
+
const src = bakedSrc ?? resolved.src;
|
|
78
|
+
// An explicit poster-image-id override wins; otherwise fall back to the
|
|
79
|
+
// baked/auto-generated frame from the video asset itself.
|
|
80
|
+
const poster = bakedPosterOverrideSrc ?? posterOverride.src ?? bakedPoster ?? resolved.poster;
|
|
81
|
+
const width = bakedWidth ?? resolved.width;
|
|
82
|
+
const height = bakedHeight ?? resolved.height;
|
|
83
|
+
aspect = width && height ? `${width} / ${height}` : undefined;
|
|
84
|
+
|
|
85
|
+
const playbackProps =
|
|
86
|
+
playback === "ambient"
|
|
87
|
+
? { autoPlay: true, muted: true, loop: true, playsInline: true }
|
|
88
|
+
: { controls: true, preload: "metadata" as const };
|
|
89
|
+
|
|
90
|
+
const videoEl = imageId ? (
|
|
91
|
+
<video
|
|
92
|
+
src={src}
|
|
93
|
+
poster={poster}
|
|
94
|
+
width={width}
|
|
95
|
+
height={height}
|
|
96
|
+
className="absolute inset-0 h-full w-full object-contain"
|
|
97
|
+
{...playbackProps}
|
|
98
|
+
/>
|
|
99
|
+
) : (
|
|
100
|
+
<div className="absolute inset-0 flex items-center justify-center bg-base-accent text-base-contrast-light/50">
|
|
101
|
+
<span className="text-sm">No video selected</span>
|
|
102
|
+
</div>
|
|
103
|
+
);
|
|
104
|
+
|
|
105
|
+
media = replaceWithLibraryVideo ? (
|
|
106
|
+
<ImageDropZone onImageSelected={replaceWithLibraryVideo} className="absolute inset-0 h-full w-full">
|
|
107
|
+
{videoEl}
|
|
108
|
+
</ImageDropZone>
|
|
109
|
+
) : (
|
|
110
|
+
videoEl
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const onSurfaceClick =
|
|
115
|
+
replaceWithLibraryVideo && mediaLibrary
|
|
116
|
+
? () => mediaLibrary.openSelectModal((id) => replaceWithLibraryVideo(id))
|
|
117
|
+
: undefined;
|
|
118
|
+
|
|
119
|
+
return (
|
|
120
|
+
<figure className="w-full">
|
|
121
|
+
<div
|
|
122
|
+
data-testid="video-open-surface"
|
|
123
|
+
className={cn("relative w-full overflow-hidden", onSurfaceClick && "cursor-pointer")}
|
|
124
|
+
style={{ aspectRatio: aspect ?? "16 / 9" }}
|
|
125
|
+
onClick={onSurfaceClick}
|
|
126
|
+
onPointerDown={onSurfaceClick ? (e) => e.stopPropagation() : undefined}
|
|
127
|
+
>
|
|
128
|
+
{media}
|
|
129
|
+
</div>
|
|
130
|
+
|
|
131
|
+
{showCaptionBlock && (
|
|
132
|
+
<figcaption className="mt-2 min-h-[1em] text-sm text-base-contrast-light/70">
|
|
133
|
+
{captionEditable ? (
|
|
134
|
+
<EditablePlainText
|
|
135
|
+
tag="span"
|
|
136
|
+
value={caption ?? ""}
|
|
137
|
+
onChange={onCaptionChange!}
|
|
138
|
+
isEditMode={true}
|
|
139
|
+
placeholder="Caption"
|
|
140
|
+
className="block min-h-[1em]"
|
|
141
|
+
/>
|
|
142
|
+
) : (
|
|
143
|
+
caption
|
|
144
|
+
)}
|
|
145
|
+
</figcaption>
|
|
146
|
+
)}
|
|
147
|
+
</figure>
|
|
148
|
+
);
|
|
149
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { defineSection } from "../../../lib/registry";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { Clapperboard } from "lucide-react";
|
|
4
|
+
import { VideoBlock, type VideoSource } from "./VideoBlock";
|
|
5
|
+
|
|
6
|
+
const LibrarySource = z.object({
|
|
7
|
+
type: z.literal("library"),
|
|
8
|
+
ref: z.object({
|
|
9
|
+
imageId: z.string().default("").describe("Media-library id of the video asset. Required for library videos."),
|
|
10
|
+
}),
|
|
11
|
+
// Kept as a SIBLING of `ref` (not nested inside it): the SSR ref-baker
|
|
12
|
+
// (resolveManifestReferences) matches objects structurally by a top-level
|
|
13
|
+
// `imageId` key and merges the resolved video's own poster onto that same
|
|
14
|
+
// object. Nesting the override inside `ref` would collide with that bake
|
|
15
|
+
// and get silently overwritten on the published viewer. As a sibling with
|
|
16
|
+
// its own `imageId`, it gets baked independently.
|
|
17
|
+
poster: z
|
|
18
|
+
.object({
|
|
19
|
+
imageId: z.string().describe("Media-library image id used as the poster frame instead of the auto-generated one."),
|
|
20
|
+
})
|
|
21
|
+
.optional()
|
|
22
|
+
.describe("Optional poster-image override for a library video."),
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
const ExternalSource = z.object({
|
|
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."),
|
|
29
|
+
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)."),
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
const schema = z.object({
|
|
35
|
+
type: z.literal("video"),
|
|
36
|
+
content: z.object({
|
|
37
|
+
source: z
|
|
38
|
+
.discriminatedUnion("type", [LibrarySource, ExternalSource])
|
|
39
|
+
.describe("Where the video comes from: a media-library upload or an external YouTube/Vimeo URL."),
|
|
40
|
+
caption: z.string().optional().describe("Optional caption rendered beneath the player when Show caption is on."),
|
|
41
|
+
}),
|
|
42
|
+
options: z
|
|
43
|
+
.object({
|
|
44
|
+
playback: z
|
|
45
|
+
.enum(["ambient", "click"])
|
|
46
|
+
.describe("'click' (default): poster + native controls with audio. 'ambient': autoplays muted in a loop with no controls.")
|
|
47
|
+
.optional(),
|
|
48
|
+
showCaption: z.boolean().describe("When true, render the caption beneath the player.").optional(),
|
|
49
|
+
})
|
|
50
|
+
.default({}),
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
export default defineSection({
|
|
54
|
+
type: "video",
|
|
55
|
+
label: "Video",
|
|
56
|
+
icon: <Clapperboard size={18} />,
|
|
57
|
+
schema,
|
|
58
|
+
authoringNote:
|
|
59
|
+
"A watchable video player — self-hosted from the media library or embedded from YouTube/Vimeo. " +
|
|
60
|
+
"Use playback 'click' for content viewers press play on (controls + audio) and 'ambient' for silent looping footage. " +
|
|
61
|
+
"For decorative loops inside grids prefer the Media block; large self-hosted files require the site's asset storage.",
|
|
62
|
+
component: ({ content, options, onChange }) => (
|
|
63
|
+
<VideoBlock
|
|
64
|
+
source={content.content.source as VideoSource}
|
|
65
|
+
caption={content.content.caption}
|
|
66
|
+
options={(options ?? {}) as { playback?: "ambient" | "click"; showCaption?: boolean }}
|
|
67
|
+
isEditMode={!!onChange}
|
|
68
|
+
onSourceChange={
|
|
69
|
+
onChange ? (source) => onChange({ ...content, content: { ...content.content, source } }) : undefined
|
|
70
|
+
}
|
|
71
|
+
onCaptionChange={
|
|
72
|
+
onChange
|
|
73
|
+
? (caption) => onChange({ ...content, content: { ...content.content, caption: caption || undefined } })
|
|
74
|
+
: undefined
|
|
75
|
+
}
|
|
76
|
+
/>
|
|
77
|
+
),
|
|
78
|
+
defaults: () => ({
|
|
79
|
+
type: "video" as const,
|
|
80
|
+
content: { source: { type: "library" as const, ref: { imageId: "" } } },
|
|
81
|
+
options: {},
|
|
82
|
+
}),
|
|
83
|
+
getLabel: () => "Video",
|
|
84
|
+
getThumbnails: (content) =>
|
|
85
|
+
content.content.source.type === "library" && content.content.source.ref.imageId
|
|
86
|
+
? [{ type: "image" as const, src: content.content.source.ref.imageId }]
|
|
87
|
+
: [],
|
|
88
|
+
settings: {
|
|
89
|
+
source: {
|
|
90
|
+
type: "video-source",
|
|
91
|
+
label: "Source",
|
|
92
|
+
target: "content",
|
|
93
|
+
default: { type: "library" as const, ref: { imageId: "" } },
|
|
94
|
+
},
|
|
95
|
+
playback: {
|
|
96
|
+
type: "select",
|
|
97
|
+
label: "Playback",
|
|
98
|
+
default: "click",
|
|
99
|
+
options: [
|
|
100
|
+
{ label: "Click to play (controls + audio)", value: "click" },
|
|
101
|
+
{ label: "Ambient loop (muted, no controls)", value: "ambient" },
|
|
102
|
+
],
|
|
103
|
+
},
|
|
104
|
+
showCaption: { type: "checkbox", label: "Show caption", default: false },
|
|
105
|
+
},
|
|
106
|
+
inheritableSettings: ["playback", "showCaption"],
|
|
107
|
+
settingsTabs: [
|
|
108
|
+
{ label: "Source", fields: ["source"] },
|
|
109
|
+
{ label: "Display", fields: ["playback", "showCaption"] },
|
|
110
|
+
],
|
|
111
|
+
});
|
|
@@ -3,6 +3,7 @@ import subHeading from "./SubHeading";
|
|
|
3
3
|
import subSubHeading from "./SubSubHeading";
|
|
4
4
|
import prose from "./Prose";
|
|
5
5
|
import media from "./Media";
|
|
6
|
+
import video from "./Video";
|
|
6
7
|
import button from "./Button";
|
|
7
8
|
import container from "./Container";
|
|
8
9
|
import spacer from "./Spacer";
|
|
@@ -20,6 +21,7 @@ export const coreSectionDefs = [
|
|
|
20
21
|
subSubHeading,
|
|
21
22
|
prose,
|
|
22
23
|
media,
|
|
24
|
+
video,
|
|
23
25
|
button,
|
|
24
26
|
container,
|
|
25
27
|
spacer,
|
|
@@ -17,7 +17,7 @@ interface TabsProps {
|
|
|
17
17
|
/**
|
|
18
18
|
* When true, the tab-bar row uses -mx-6 to break out of the parent's px-6 padding
|
|
19
19
|
* (e.g. EditorModal's content wrapper) and re-pads with px-6 so buttons stay aligned.
|
|
20
|
-
* The tab panel
|
|
20
|
+
* The tab panel also gets a top `pt-4` for spacing under the tab bar.
|
|
21
21
|
* Use only when the parent container has px-6; leave false for noPadding contexts.
|
|
22
22
|
*/
|
|
23
23
|
fullBleedTabBar?: boolean;
|
|
@@ -57,7 +57,7 @@ export function Tabs({ tabs, defaultTabId, activeTabId, onTabChange, fullBleedTa
|
|
|
57
57
|
))}
|
|
58
58
|
</div>
|
|
59
59
|
</div>
|
|
60
|
-
<div role="tabpanel">{activeTab?.content}</div>
|
|
60
|
+
<div role="tabpanel" className={cn(fullBleedTabBar && "pt-4")}>{activeTab?.content}</div>
|
|
61
61
|
</div>
|
|
62
62
|
);
|
|
63
63
|
}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import { useState } from "react";
|
|
2
|
+
import { FormLabel } from "./FormLabel";
|
|
3
|
+
import { Button } from "./Button";
|
|
4
|
+
import { useMediaLibrary } from "../shell/MediaLibraryContext";
|
|
5
|
+
import { useResolvedMedia } from "../../hooks/useResolvedMedia";
|
|
6
|
+
import type { VideoSource } from "../sections/Video/VideoBlock";
|
|
7
|
+
import { cn } from "../../lib/cn";
|
|
8
|
+
|
|
9
|
+
interface Props {
|
|
10
|
+
value: VideoSource | undefined;
|
|
11
|
+
onChange: (source: VideoSource) => void;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
type Mode = "library" | "embed";
|
|
15
|
+
|
|
16
|
+
export function VideoSourceField({ value, onChange }: Props) {
|
|
17
|
+
const mediaLibrary = useMediaLibrary();
|
|
18
|
+
const [mode, setMode] = useState<Mode>(value?.type === "external" ? "embed" : "library");
|
|
19
|
+
const [url, setUrl] = useState(value?.type === "external" ? value.url : "");
|
|
20
|
+
const [loading, setLoading] = useState(false);
|
|
21
|
+
const [error, setError] = useState<string | null>(null);
|
|
22
|
+
const [fallback, setFallback] = useState(false);
|
|
23
|
+
|
|
24
|
+
const libraryImageId = value?.type === "library" ? value.ref.imageId : undefined;
|
|
25
|
+
const resolved = useResolvedMedia(libraryImageId);
|
|
26
|
+
|
|
27
|
+
async function submitEmbed() {
|
|
28
|
+
if (loading) return;
|
|
29
|
+
const trimmed = url.trim();
|
|
30
|
+
if (!trimmed) return;
|
|
31
|
+
setLoading(true);
|
|
32
|
+
setError(null);
|
|
33
|
+
setFallback(false);
|
|
34
|
+
try {
|
|
35
|
+
const res = await fetch(`/api/media/oembed?url=${encodeURIComponent(trimmed)}`);
|
|
36
|
+
const body = await res.json();
|
|
37
|
+
if (!res.ok) {
|
|
38
|
+
setError(body?.message ?? "Couldn't load that video.");
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
onChange({
|
|
42
|
+
type: "external",
|
|
43
|
+
provider: body.provider,
|
|
44
|
+
id: body.id,
|
|
45
|
+
url: body.url,
|
|
46
|
+
width: body.width,
|
|
47
|
+
height: body.height,
|
|
48
|
+
});
|
|
49
|
+
if (body.fallback) {
|
|
50
|
+
setFallback(true);
|
|
51
|
+
} else {
|
|
52
|
+
setUrl("");
|
|
53
|
+
}
|
|
54
|
+
} catch {
|
|
55
|
+
setError("Couldn't reach the video service.");
|
|
56
|
+
} finally {
|
|
57
|
+
setLoading(false);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return (
|
|
62
|
+
<div className="flex flex-col gap-3">
|
|
63
|
+
<FormLabel>Source</FormLabel>
|
|
64
|
+
<div className="flex gap-2" role="tablist">
|
|
65
|
+
{(["library", "embed"] as const).map((m) => (
|
|
66
|
+
<button
|
|
67
|
+
key={m}
|
|
68
|
+
type="button"
|
|
69
|
+
role="tab"
|
|
70
|
+
aria-selected={mode === m}
|
|
71
|
+
onClick={() => setMode(m)}
|
|
72
|
+
className={cn(
|
|
73
|
+
"cursor-pointer rounded border px-3 py-1 text-sm",
|
|
74
|
+
mode === m ? "border-primary bg-primary text-primary-contrast" : "border-base-200 text-base-contrast",
|
|
75
|
+
)}
|
|
76
|
+
>
|
|
77
|
+
{m === "library" ? "Choose from library" : "Paste embed URL"}
|
|
78
|
+
</button>
|
|
79
|
+
))}
|
|
80
|
+
</div>
|
|
81
|
+
|
|
82
|
+
{mode === "library" ? (
|
|
83
|
+
<div className="flex items-center gap-3">
|
|
84
|
+
{libraryImageId ? (
|
|
85
|
+
resolved.poster ? (
|
|
86
|
+
<img src={resolved.poster} alt="" className="h-12 w-20 rounded object-cover" />
|
|
87
|
+
) : (
|
|
88
|
+
<span className="text-sm text-base-contrast-light">Video selected</span>
|
|
89
|
+
)
|
|
90
|
+
) : (
|
|
91
|
+
<span className="text-sm text-base-contrast-light">No video selected</span>
|
|
92
|
+
)}
|
|
93
|
+
<Button
|
|
94
|
+
type="button"
|
|
95
|
+
variant="secondary"
|
|
96
|
+
size="sm"
|
|
97
|
+
onClick={() =>
|
|
98
|
+
mediaLibrary?.openSelectModal((id) => onChange({ type: "library", ref: { imageId: id } }))
|
|
99
|
+
}
|
|
100
|
+
>
|
|
101
|
+
Choose video
|
|
102
|
+
</Button>
|
|
103
|
+
</div>
|
|
104
|
+
) : (
|
|
105
|
+
<div className="flex flex-col gap-2">
|
|
106
|
+
<div className="flex items-center gap-2">
|
|
107
|
+
<input
|
|
108
|
+
type="text"
|
|
109
|
+
value={url}
|
|
110
|
+
onChange={(e) => {
|
|
111
|
+
setUrl(e.target.value);
|
|
112
|
+
setError(null);
|
|
113
|
+
setFallback(false);
|
|
114
|
+
}}
|
|
115
|
+
onKeyDown={(e) => {
|
|
116
|
+
if (e.key === "Enter") {
|
|
117
|
+
e.preventDefault();
|
|
118
|
+
void submitEmbed();
|
|
119
|
+
}
|
|
120
|
+
}}
|
|
121
|
+
placeholder="Paste a YouTube or Vimeo URL…"
|
|
122
|
+
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
|
+
/>
|
|
124
|
+
<Button
|
|
125
|
+
type="button"
|
|
126
|
+
variant="primary"
|
|
127
|
+
size="sm"
|
|
128
|
+
onClick={() => void submitEmbed()}
|
|
129
|
+
disabled={loading || !url.trim()}
|
|
130
|
+
>
|
|
131
|
+
{loading ? "Loading…" : "Use link"}
|
|
132
|
+
</Button>
|
|
133
|
+
</div>
|
|
134
|
+
{error ? <p className="text-xs text-red-600">{error}</p> : null}
|
|
135
|
+
{!error && fallback ? (
|
|
136
|
+
<p className="text-xs text-base-contrast-light/70">
|
|
137
|
+
Added — couldn't fetch exact dimensions, using 16:9.
|
|
138
|
+
</p>
|
|
139
|
+
) : null}
|
|
140
|
+
</div>
|
|
141
|
+
)}
|
|
142
|
+
</div>
|
|
143
|
+
);
|
|
144
|
+
}
|
|
@@ -153,6 +153,10 @@ export default function EditorShell({
|
|
|
153
153
|
const [showChatbotConfig, setShowChatbotConfig] = useState(false);
|
|
154
154
|
const [isRestoring, setIsRestoring] = useState(false);
|
|
155
155
|
const [rejectedUploads, setRejectedUploads] = useState<{ name: string; reason: "size" | "type" }[]>([]);
|
|
156
|
+
// Surfaces useMediaPipeline's onUploadError (oversized/disallowed files,
|
|
157
|
+
// upload-intent failures, direct-to-bucket PUT failures) in the media
|
|
158
|
+
// library modal, alongside its existing client-side rejection banner.
|
|
159
|
+
const [uploadErrorMessage, setUploadErrorMessage] = useState<string | null>(null);
|
|
156
160
|
const [activePageId, setActivePageId] = useState<string | null>(null);
|
|
157
161
|
const [movingSectionId, setMovingSectionId] = useState<string | null>(null);
|
|
158
162
|
|
|
@@ -223,6 +227,7 @@ export default function EditorShell({
|
|
|
223
227
|
setLocalChangesExist,
|
|
224
228
|
setDirtySectionIds,
|
|
225
229
|
markSectionDirty: persistence.markSectionDirty,
|
|
230
|
+
onUploadError: (message) => setUploadErrorMessage(message),
|
|
226
231
|
});
|
|
227
232
|
|
|
228
233
|
const buildStatus = useBuildStatus();
|
|
@@ -1032,6 +1037,9 @@ export default function EditorShell({
|
|
|
1032
1037
|
referenceCountMap={mediaPipeline.referenceCountMap}
|
|
1033
1038
|
localUrlMap={mediaPipeline.pendingLocalUrls}
|
|
1034
1039
|
maxFileSize={siteConfig?.media.maxFileSize}
|
|
1040
|
+
uploadProgress={mediaPipeline.uploadProgress}
|
|
1041
|
+
uploadError={uploadErrorMessage}
|
|
1042
|
+
onDismissUploadError={() => setUploadErrorMessage(null)}
|
|
1035
1043
|
/>
|
|
1036
1044
|
</EditorModal>
|
|
1037
1045
|
<EditorModal
|
|
@@ -17,6 +17,13 @@ export interface MediaLibraryModalProps {
|
|
|
17
17
|
referenceCountMap: Record<string, number>;
|
|
18
18
|
localUrlMap?: Record<string, string>;
|
|
19
19
|
maxFileSize?: number;
|
|
20
|
+
// Direct-to-bucket large-video upload progress, keyed by content hash (0..1).
|
|
21
|
+
uploadProgress?: Record<string, number>;
|
|
22
|
+
// Async upload failure from the pipeline (oversized/disallowed file,
|
|
23
|
+
// upload-intent error, direct-to-bucket PUT failure) — distinct from the
|
|
24
|
+
// client-side size pre-check below, which never reaches the pipeline.
|
|
25
|
+
uploadError?: string | null;
|
|
26
|
+
onDismissUploadError?: () => void;
|
|
20
27
|
}
|
|
21
28
|
|
|
22
29
|
type KindFilter = "all" | MediaKind;
|
|
@@ -97,6 +104,9 @@ export function MediaLibraryModal({
|
|
|
97
104
|
referenceCountMap,
|
|
98
105
|
localUrlMap = {},
|
|
99
106
|
maxFileSize,
|
|
107
|
+
uploadProgress = {},
|
|
108
|
+
uploadError = null,
|
|
109
|
+
onDismissUploadError,
|
|
100
110
|
}: MediaLibraryModalProps) {
|
|
101
111
|
const [search, setSearch] = useState("");
|
|
102
112
|
const [kindFilter, setKindFilter] = useState<KindFilter>("all");
|
|
@@ -111,7 +121,11 @@ export function MediaLibraryModal({
|
|
|
111
121
|
const valid: File[] = [];
|
|
112
122
|
const rejected: string[] = [];
|
|
113
123
|
for (const file of files) {
|
|
114
|
-
|
|
124
|
+
// MP4/WebM videos over the git-path cap route through the pipeline's
|
|
125
|
+
// direct-to-bucket upload instead of being rejected here — it applies
|
|
126
|
+
// its own (much larger) asset cap and reports its own errors.
|
|
127
|
+
const isBucketEligibleVideo = file.type === "video/mp4" || file.type === "video/webm";
|
|
128
|
+
if (file.size > maxFileSize && !isBucketEligibleVideo) {
|
|
115
129
|
rejected.push(file.name);
|
|
116
130
|
} else {
|
|
117
131
|
valid.push(file);
|
|
@@ -161,10 +175,33 @@ export function MediaLibraryModal({
|
|
|
161
175
|
setConfirmDelete(false);
|
|
162
176
|
}
|
|
163
177
|
|
|
178
|
+
const uploadingHashes = Object.keys(uploadProgress);
|
|
179
|
+
|
|
164
180
|
return (
|
|
165
181
|
<div className="flex flex-col gap-4">
|
|
166
182
|
<UploadZone onUpload={handleUpload} />
|
|
167
183
|
|
|
184
|
+
{/* Direct-to-bucket large-video upload progress */}
|
|
185
|
+
{uploadingHashes.length > 0 && (
|
|
186
|
+
<div className="space-y-1.5">
|
|
187
|
+
{uploadingHashes.map((hash) => {
|
|
188
|
+
const pct = Math.round((uploadProgress[hash] ?? 0) * 100);
|
|
189
|
+
return (
|
|
190
|
+
<div key={hash} className="flex items-center gap-2">
|
|
191
|
+
<span className="shrink-0 text-xs text-base-contrast-light">Uploading video…</span>
|
|
192
|
+
<div className="h-1.5 flex-1 overflow-hidden rounded-full bg-base-200">
|
|
193
|
+
<div
|
|
194
|
+
className="h-full rounded-full bg-primary transition-[width]"
|
|
195
|
+
style={{ width: `${pct}%` }}
|
|
196
|
+
/>
|
|
197
|
+
</div>
|
|
198
|
+
<span className="w-9 shrink-0 text-right text-xs text-base-contrast-light">{pct}%</span>
|
|
199
|
+
</div>
|
|
200
|
+
);
|
|
201
|
+
})}
|
|
202
|
+
</div>
|
|
203
|
+
)}
|
|
204
|
+
|
|
168
205
|
{/* Sticky toolbar + confirmation */}
|
|
169
206
|
<div className="sticky top-[-16px] z-10 -mx-6 space-y-3 bg-base px-6 pb-3 pt-4">
|
|
170
207
|
<div className="flex items-center gap-2">
|
|
@@ -235,6 +272,15 @@ export function MediaLibraryModal({
|
|
|
235
272
|
onDismiss={() => setRejectedFiles([])}
|
|
236
273
|
/>
|
|
237
274
|
)}
|
|
275
|
+
|
|
276
|
+
{/* Async upload failure (oversized/disallowed file, upload-intent error,
|
|
277
|
+
direct-to-bucket PUT failure) — distinct from the client-side check above. */}
|
|
278
|
+
{uploadError && (
|
|
279
|
+
<UploadRejectionAlert
|
|
280
|
+
message={uploadError}
|
|
281
|
+
onDismiss={() => onDismissUploadError?.()}
|
|
282
|
+
/>
|
|
283
|
+
)}
|
|
238
284
|
</div>
|
|
239
285
|
|
|
240
286
|
{/* Grid */}
|
|
@@ -107,6 +107,9 @@ export function useEditorPublish({
|
|
|
107
107
|
};
|
|
108
108
|
for (const [key, blob] of Object.entries(storedBlobs)) {
|
|
109
109
|
if (key === "primary" && item.kind === "image") continue;
|
|
110
|
+
// Assets-backed originals live in the bucket; only derived files (poster)
|
|
111
|
+
// ride the git save.
|
|
112
|
+
if (item.storage === "assets" && key === "primary") continue;
|
|
110
113
|
const base64 = await blobToBase64(blob);
|
|
111
114
|
if (key === "primary") {
|
|
112
115
|
const ext = mimeExt[item.mimeType] ?? "bin";
|