@drawnagency/primitives 0.1.71 → 0.1.73
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/{chunk-RTYJZAKR.js → chunk-7T2WQSWF.js} +12 -12
- package/dist/chunk-IFHRHV7S.js +117 -0
- package/dist/{chunk-2BHPWO7M.js → chunk-N6AE6NBB.js} +158 -32
- package/dist/{chunk-WXVKX6PY.js → chunk-NKVZGPR6.js} +41 -95
- package/dist/{chunk-3DCG4DNU.js → chunk-PZJL2S3Z.js} +8 -4
- package/dist/chunk-RBTSUR5V.js +37 -0
- package/dist/{chunk-6UDYJQL6.js → chunk-S55OZ7F2.js} +9 -1
- package/dist/{chunk-NR7UB6DZ.js → chunk-SFKYUKVN.js} +1 -1
- package/dist/{chunk-YMVYTYGQ.js → chunk-SWIHUIFT.js} +28 -16
- package/dist/{chunk-KWBM27UR.js → chunk-T7MP7ANP.js} +13 -64
- package/dist/{chunk-MU3CDERQ.js → chunk-XTRTK26E.js} +276 -96
- package/dist/{chunk-GKV77AA6.js → chunk-ZRWGV5O6.js} +3 -3
- package/dist/chunk-ZSPCE6MZ.js +70 -0
- package/dist/components/editor/SettingsForm.d.ts.map +1 -1
- package/dist/components/editor/index.js +6 -3
- package/dist/components/primitives/ResolvedMedia.d.ts.map +1 -1
- package/dist/components/primitives/index.js +7 -7
- package/dist/components/sections/Media/MediaBlock.d.ts.map +1 -1
- package/dist/components/sections/Video/VideoBlock.d.ts +31 -0
- package/dist/components/sections/Video/VideoBlock.d.ts.map +1 -0
- package/dist/components/sections/Video/index.d.ts +28 -0
- package/dist/components/sections/Video/index.d.ts.map +1 -0
- package/dist/components/sections/all-sections.d.ts +25 -0
- package/dist/components/sections/all-sections.d.ts.map +1 -1
- package/dist/components/sections/brand-guide.js +7 -7
- package/dist/components/sections/core-sections.d.ts +25 -0
- package/dist/components/sections/core-sections.d.ts.map +1 -1
- package/dist/components/sections/register-schemas.js +13 -12
- package/dist/components/sections/register.js +13 -12
- package/dist/components/shared/Tabs.d.ts +1 -1
- package/dist/components/shared/VideoSourceField.d.ts +8 -0
- package/dist/components/shared/VideoSourceField.d.ts.map +1 -0
- package/dist/components/shell/EditorShell.d.ts.map +1 -1
- package/dist/components/shell/MediaLibraryModal.d.ts +4 -1
- package/dist/components/shell/MediaLibraryModal.d.ts.map +1 -1
- package/dist/hooks/useEditorPublish.d.ts +4 -1
- package/dist/hooks/useEditorPublish.d.ts.map +1 -1
- package/dist/hooks/useMediaPipeline.d.ts +9 -1
- package/dist/hooks/useMediaPipeline.d.ts.map +1 -1
- package/dist/index.js +28 -24
- package/dist/lib/index.js +10 -10
- package/dist/lib/registry.d.ts +6 -0
- package/dist/lib/registry.d.ts.map +1 -1
- package/dist/lib/save-chunks.d.ts +28 -0
- package/dist/lib/save-chunks.d.ts.map +1 -0
- package/dist/lib/video-url.d.ts +7 -0
- package/dist/lib/video-url.d.ts.map +1 -0
- package/dist/lib/video-url.js +9 -0
- package/dist/media/index.js +6 -2
- package/dist/media/resolve.d.ts +14 -0
- package/dist/media/resolve.d.ts.map +1 -1
- package/dist/media/videoPoster.d.ts +1 -0
- package/dist/media/videoPoster.d.ts.map +1 -1
- package/dist/schemas/index.js +4 -4
- package/dist/schemas/link.d.ts.map +1 -1
- package/dist/schemas/link.js +1 -1
- package/dist/schemas/media.d.ts +13 -0
- package/dist/schemas/media.d.ts.map +1 -1
- package/dist/schemas/shared.d.ts.map +1 -1
- package/dist/schemas/site-config.d.ts +1 -0
- package/dist/schemas/site-config.d.ts.map +1 -1
- package/dist/storage/index.d.ts +1 -1
- package/dist/storage/index.d.ts.map +1 -1
- package/dist/storage/types.d.ts +26 -0
- package/dist/storage/types.d.ts.map +1 -1
- package/package.json +5 -1
- package/src/components/editor/SettingsForm.tsx +11 -0
- package/src/components/primitives/ResolvedMedia.tsx +10 -5
- package/src/components/sections/Media/MediaBlock.tsx +2 -1
- package/src/components/sections/Video/VideoBlock.tsx +149 -0
- package/src/components/sections/Video/index.tsx +111 -0
- package/src/components/sections/core-sections.ts +2 -0
- package/src/components/shared/Tabs.tsx +2 -2
- package/src/components/shared/VideoSourceField.tsx +144 -0
- package/src/components/shell/EditorShell.tsx +16 -2
- package/src/components/shell/MediaLibraryModal.tsx +47 -1
- package/src/hooks/useEditorPublish.ts +137 -50
- package/src/hooks/useMediaPipeline.ts +188 -25
- package/src/lib/registry.ts +7 -0
- package/src/lib/save-chunks.ts +53 -0
- package/src/lib/video-url.ts +39 -0
- package/src/media/resolve.ts +18 -0
- package/src/media/videoPoster.ts +2 -2
- package/src/schemas/link.ts +15 -4
- package/src/schemas/media.ts +12 -0
- package/src/schemas/shared.ts +23 -14
- package/src/storage/index.ts +1 -1
- package/src/storage/types.ts +23 -0
- package/dist/chunk-4E66N6UV.js +0 -10
- package/dist/chunk-I5737FJY.js +0 -62
|
@@ -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,11 +227,12 @@ 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();
|
|
229
234
|
|
|
230
|
-
const { publishAction, publishFeedback, handleSave, handlePublish, handleSaveAndPublish } = useEditorPublish({
|
|
235
|
+
const { publishAction, publishFeedback, saveProgress, handleSave, handlePublish, handleSaveAndPublish } = useEditorPublish({
|
|
231
236
|
flushNow: persistence.flushNow,
|
|
232
237
|
cancelPendingFlush: persistence.cancelPendingFlush,
|
|
233
238
|
isConfigDirty: persistence.isConfigDirty,
|
|
@@ -879,6 +884,7 @@ export default function EditorShell({
|
|
|
879
884
|
localChangesExist={localChangesExist}
|
|
880
885
|
publishAction={publishAction}
|
|
881
886
|
publishFeedback={publishFeedback}
|
|
887
|
+
saveProgress={saveProgress}
|
|
882
888
|
onSave={handleSave}
|
|
883
889
|
onPublish={handlePublish}
|
|
884
890
|
onSaveAndPublish={handleSaveAndPublish}
|
|
@@ -1032,6 +1038,9 @@ export default function EditorShell({
|
|
|
1032
1038
|
referenceCountMap={mediaPipeline.referenceCountMap}
|
|
1033
1039
|
localUrlMap={mediaPipeline.pendingLocalUrls}
|
|
1034
1040
|
maxFileSize={siteConfig?.media.maxFileSize}
|
|
1041
|
+
uploadProgress={mediaPipeline.uploadProgress}
|
|
1042
|
+
uploadError={uploadErrorMessage}
|
|
1043
|
+
onDismissUploadError={() => setUploadErrorMessage(null)}
|
|
1035
1044
|
/>
|
|
1036
1045
|
</EditorModal>
|
|
1037
1046
|
<EditorModal
|
|
@@ -1481,12 +1490,14 @@ function DismissButton({ onClick }: { onClick: () => void }) {
|
|
|
1481
1490
|
function StatusText({
|
|
1482
1491
|
publishAction,
|
|
1483
1492
|
publishFeedback,
|
|
1493
|
+
saveProgress,
|
|
1484
1494
|
buildState,
|
|
1485
1495
|
buildElapsed,
|
|
1486
1496
|
onDismiss,
|
|
1487
1497
|
}: {
|
|
1488
1498
|
publishAction: "idle" | "saving" | "publishing";
|
|
1489
1499
|
publishFeedback: string | null;
|
|
1500
|
+
saveProgress: string | null;
|
|
1490
1501
|
buildState: "idle" | "building" | "ready" | "error" | "fading";
|
|
1491
1502
|
buildElapsed: number;
|
|
1492
1503
|
onDismiss: () => void;
|
|
@@ -1494,7 +1505,7 @@ function StatusText({
|
|
|
1494
1505
|
if (publishAction === "saving") {
|
|
1495
1506
|
return (
|
|
1496
1507
|
<span className="relative text-xs font-medium text-base-content/60">
|
|
1497
|
-
Saving changes<LoadingEllipsis
|
|
1508
|
+
{saveProgress ?? <>Saving changes<LoadingEllipsis /></>}
|
|
1498
1509
|
</span>
|
|
1499
1510
|
);
|
|
1500
1511
|
}
|
|
@@ -1542,6 +1553,7 @@ function EditorToolbar({
|
|
|
1542
1553
|
localChangesExist,
|
|
1543
1554
|
publishAction,
|
|
1544
1555
|
publishFeedback,
|
|
1556
|
+
saveProgress,
|
|
1545
1557
|
onSave,
|
|
1546
1558
|
onPublish,
|
|
1547
1559
|
onSaveAndPublish,
|
|
@@ -1561,6 +1573,7 @@ function EditorToolbar({
|
|
|
1561
1573
|
localChangesExist: boolean;
|
|
1562
1574
|
publishAction: "idle" | "saving" | "publishing";
|
|
1563
1575
|
publishFeedback: string | null;
|
|
1576
|
+
saveProgress: string | null;
|
|
1564
1577
|
onSave: () => void;
|
|
1565
1578
|
onPublish: () => void;
|
|
1566
1579
|
onSaveAndPublish: () => void;
|
|
@@ -1632,6 +1645,7 @@ function EditorToolbar({
|
|
|
1632
1645
|
<StatusText
|
|
1633
1646
|
publishAction={publishAction}
|
|
1634
1647
|
publishFeedback={publishFeedback}
|
|
1648
|
+
saveProgress={saveProgress}
|
|
1635
1649
|
buildState={buildState}
|
|
1636
1650
|
buildElapsed={buildElapsed}
|
|
1637
1651
|
onDismiss={onBuildDismiss}
|
|
@@ -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 */}
|