@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.
Files changed (79) hide show
  1. package/dist/{chunk-RTYJZAKR.js → chunk-2HZB675H.js} +12 -12
  2. package/dist/chunk-7SIZ2LV5.js +117 -0
  3. package/dist/{chunk-2BHPWO7M.js → chunk-BTGHF3SW.js} +158 -32
  4. package/dist/{chunk-3DCG4DNU.js → chunk-PZJL2S3Z.js} +8 -4
  5. package/dist/chunk-RBTSUR5V.js +37 -0
  6. package/dist/{chunk-NR7UB6DZ.js → chunk-SFKYUKVN.js} +1 -1
  7. package/dist/{chunk-YMVYTYGQ.js → chunk-SWIHUIFT.js} +28 -16
  8. package/dist/{chunk-6UDYJQL6.js → chunk-TOBPMMRM.js} +1 -1
  9. package/dist/{chunk-MU3CDERQ.js → chunk-UBK2IZ6G.js} +276 -96
  10. package/dist/{chunk-WXVKX6PY.js → chunk-VHINNGIF.js} +41 -95
  11. package/dist/{chunk-KWBM27UR.js → chunk-WCALKWPO.js} +4 -59
  12. package/dist/{chunk-GKV77AA6.js → chunk-ZRWGV5O6.js} +3 -3
  13. package/dist/chunk-ZSPCE6MZ.js +70 -0
  14. package/dist/components/editor/SettingsForm.d.ts.map +1 -1
  15. package/dist/components/editor/index.js +6 -3
  16. package/dist/components/primitives/index.js +7 -7
  17. package/dist/components/sections/Video/VideoBlock.d.ts +31 -0
  18. package/dist/components/sections/Video/VideoBlock.d.ts.map +1 -0
  19. package/dist/components/sections/Video/index.d.ts +28 -0
  20. package/dist/components/sections/Video/index.d.ts.map +1 -0
  21. package/dist/components/sections/all-sections.d.ts +25 -0
  22. package/dist/components/sections/all-sections.d.ts.map +1 -1
  23. package/dist/components/sections/brand-guide.js +7 -7
  24. package/dist/components/sections/core-sections.d.ts +25 -0
  25. package/dist/components/sections/core-sections.d.ts.map +1 -1
  26. package/dist/components/sections/register-schemas.js +13 -12
  27. package/dist/components/sections/register.js +13 -12
  28. package/dist/components/shared/Tabs.d.ts +1 -1
  29. package/dist/components/shared/VideoSourceField.d.ts +8 -0
  30. package/dist/components/shared/VideoSourceField.d.ts.map +1 -0
  31. package/dist/components/shell/EditorShell.d.ts.map +1 -1
  32. package/dist/components/shell/MediaLibraryModal.d.ts +4 -1
  33. package/dist/components/shell/MediaLibraryModal.d.ts.map +1 -1
  34. package/dist/hooks/useEditorPublish.d.ts.map +1 -1
  35. package/dist/hooks/useMediaPipeline.d.ts +9 -1
  36. package/dist/hooks/useMediaPipeline.d.ts.map +1 -1
  37. package/dist/index.js +24 -24
  38. package/dist/lib/index.js +10 -10
  39. package/dist/lib/registry.d.ts +6 -0
  40. package/dist/lib/registry.d.ts.map +1 -1
  41. package/dist/lib/video-url.d.ts +7 -0
  42. package/dist/lib/video-url.d.ts.map +1 -0
  43. package/dist/lib/video-url.js +9 -0
  44. package/dist/media/index.js +2 -2
  45. package/dist/media/videoPoster.d.ts +1 -0
  46. package/dist/media/videoPoster.d.ts.map +1 -1
  47. package/dist/schemas/index.js +4 -4
  48. package/dist/schemas/link.d.ts.map +1 -1
  49. package/dist/schemas/link.js +1 -1
  50. package/dist/schemas/media.d.ts +13 -0
  51. package/dist/schemas/media.d.ts.map +1 -1
  52. package/dist/schemas/shared.d.ts.map +1 -1
  53. package/dist/schemas/site-config.d.ts +1 -0
  54. package/dist/schemas/site-config.d.ts.map +1 -1
  55. package/dist/storage/index.d.ts +1 -1
  56. package/dist/storage/index.d.ts.map +1 -1
  57. package/dist/storage/types.d.ts +26 -0
  58. package/dist/storage/types.d.ts.map +1 -1
  59. package/package.json +5 -1
  60. package/src/components/editor/SettingsForm.tsx +11 -0
  61. package/src/components/sections/Video/VideoBlock.tsx +149 -0
  62. package/src/components/sections/Video/index.tsx +111 -0
  63. package/src/components/sections/core-sections.ts +2 -0
  64. package/src/components/shared/Tabs.tsx +2 -2
  65. package/src/components/shared/VideoSourceField.tsx +144 -0
  66. package/src/components/shell/EditorShell.tsx +8 -0
  67. package/src/components/shell/MediaLibraryModal.tsx +47 -1
  68. package/src/hooks/useEditorPublish.ts +3 -0
  69. package/src/hooks/useMediaPipeline.ts +188 -25
  70. package/src/lib/registry.ts +7 -0
  71. package/src/lib/video-url.ts +39 -0
  72. package/src/media/videoPoster.ts +2 -2
  73. package/src/schemas/link.ts +15 -4
  74. package/src/schemas/media.ts +12 -0
  75. package/src/schemas/shared.ts +23 -14
  76. package/src/storage/index.ts +1 -1
  77. package/src/storage/types.ts +23 -0
  78. package/dist/chunk-4E66N6UV.js +0 -10
  79. package/dist/chunk-I5737FJY.js +0 -62
@@ -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.71",
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 inherits the parent's padding unchanged.
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
- if (file.size > maxFileSize) {
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";