@drawnagency/primitives 0.1.70 → 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-VVK6ZRCN.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-EWKCEPMW.js → chunk-SFKYUKVN.js} +1 -1
- package/dist/{chunk-QKOIEWZ4.js → chunk-SWIHUIFT.js} +38 -18
- package/dist/{chunk-6UDYJQL6.js → chunk-TOBPMMRM.js} +1 -1
- package/dist/{chunk-TYYCKVAW.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-WNPMBFD6.js → chunk-ZRWGV5O6.js} +3 -3
- package/dist/chunk-ZSPCE6MZ.js +70 -0
- package/dist/components/chat/ChatPanel.d.ts +4 -1
- package/dist/components/chat/ChatPanel.d.ts.map +1 -1
- 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/ChatbotConfigModal.d.ts.map +1 -1
- 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 +3 -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/chat/ChatPanel.tsx +78 -49
- 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/ChatbotConfigModal.tsx +44 -3
- 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/schemas/site-config.ts +10 -2
- 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
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"shared.d.ts","sourceRoot":"","sources":["../../src/schemas/shared.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAwBxB,eAAO,MAAM,cAAc;;;;;;;;;;;;;;;;;;;2BAKzB,CAAC;AAEH,MAAM,MAAM,QAAQ,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,cAAc,CAAC,CAAC;
|
|
1
|
+
{"version":3,"file":"shared.d.ts","sourceRoot":"","sources":["../../src/schemas/shared.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAwBxB,eAAO,MAAM,cAAc;;;;;;;;;;;;;;;;;;;2BAKzB,CAAC;AAEH,MAAM,MAAM,QAAQ,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,cAAc,CAAC,CAAC;AA6CtD,eAAO,MAAM,oBAAoB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;2BAE/B,CAAC;AAEH,MAAM,MAAM,cAAc,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,oBAAoB,CAAC,CAAC;AAElE;iEACiE;AACjE,eAAO,MAAM,0BAA0B;;;;;;;;;;;;;;;;;;;;;;;;;;2BAAqD,CAAC;AAE7F,MAAM,MAAM,oBAAoB,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,0BAA0B,CAAC,CAAC;AAG9E,eAAO,MAAM,cAAc,aAEiC,CAAC;AAO7D,eAAO,MAAM,cAAc,aAG6C,CAAC"}
|
|
@@ -161,6 +161,7 @@ export declare const ChatbotConfigSchema: z.ZodObject<{
|
|
|
161
161
|
enabled: z.ZodDefault<z.ZodBoolean>;
|
|
162
162
|
systemPrompt: z.ZodDefault<z.ZodString>;
|
|
163
163
|
audiences: z.ZodDefault<z.ZodArray<z.ZodString>>;
|
|
164
|
+
audiencePrompts: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodString>>;
|
|
164
165
|
tasks: z.ZodDefault<z.ZodArray<z.ZodObject<{
|
|
165
166
|
id: z.ZodString;
|
|
166
167
|
label: z.ZodString;
|
|
@@ -189,11 +190,13 @@ export declare const SiteConfigSchema: z.ZodObject<{
|
|
|
189
190
|
sizes: z.ZodDefault<z.ZodArray<z.ZodNumber>>;
|
|
190
191
|
maxFileSize: z.ZodDefault<z.ZodNumber>;
|
|
191
192
|
quality: z.ZodDefault<z.ZodNumber>;
|
|
193
|
+
maxAssetSize: z.ZodDefault<z.ZodNumber>;
|
|
192
194
|
}, z.core.$strip>>;
|
|
193
195
|
chatbot: z.ZodDefault<z.ZodObject<{
|
|
194
196
|
enabled: z.ZodDefault<z.ZodBoolean>;
|
|
195
197
|
systemPrompt: z.ZodDefault<z.ZodString>;
|
|
196
198
|
audiences: z.ZodDefault<z.ZodArray<z.ZodString>>;
|
|
199
|
+
audiencePrompts: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodString>>;
|
|
197
200
|
tasks: z.ZodDefault<z.ZodArray<z.ZodObject<{
|
|
198
201
|
id: z.ZodString;
|
|
199
202
|
label: z.ZodString;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"site-config.d.ts","sourceRoot":"","sources":["../../src/schemas/site-config.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAQxB,eAAO,MAAM,iBAAiB;;;;;;;;;iBAI5B,CAAC;AAEH,MAAM,MAAM,WAAW,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,iBAAiB,CAAC,CAAC;AAO5D,eAAO,MAAM,cAAc,0DAA2D,CAAC;AAEvF,eAAO,MAAM,gBAAgB;;;EAA+B,CAAC;AAE7D,eAAO,MAAM,UAAU;;;;;;;;;;;;iBASrB,CAAC;AAEH,MAAM,MAAM,IAAI,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,UAAU,CAAC,CAAC;AA0C9C,QAAA,MAAM,oBAAoB;;;;;;;;;;;;;;;;;;;;;;;;;;iBA6CtB,CAAC;AAEL,eAAO,MAAM,WAAW;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;kBAAyE,CAAC;AAElG,MAAM,MAAM,SAAS,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,oBAAoB,CAAC,CAAC;AAE7D,oFAAoF;AACpF,wBAAgB,kBAAkB,CAAC,GAAG,EAAE,OAAO,GAAG,SAAS,CAE1D;AAMD,eAAO,MAAM,iBAAiB;;;;;iBAM5B,CAAC;AACH,MAAM,MAAM,WAAW,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,iBAAiB,CAAC,CAAC;AAE5D,eAAO,MAAM,mBAAmB
|
|
1
|
+
{"version":3,"file":"site-config.d.ts","sourceRoot":"","sources":["../../src/schemas/site-config.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAQxB,eAAO,MAAM,iBAAiB;;;;;;;;;iBAI5B,CAAC;AAEH,MAAM,MAAM,WAAW,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,iBAAiB,CAAC,CAAC;AAO5D,eAAO,MAAM,cAAc,0DAA2D,CAAC;AAEvF,eAAO,MAAM,gBAAgB;;;EAA+B,CAAC;AAE7D,eAAO,MAAM,UAAU;;;;;;;;;;;;iBASrB,CAAC;AAEH,MAAM,MAAM,IAAI,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,UAAU,CAAC,CAAC;AA0C9C,QAAA,MAAM,oBAAoB;;;;;;;;;;;;;;;;;;;;;;;;;;iBA6CtB,CAAC;AAEL,eAAO,MAAM,WAAW;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;kBAAyE,CAAC;AAElG,MAAM,MAAM,SAAS,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,oBAAoB,CAAC,CAAC;AAE7D,oFAAoF;AACpF,wBAAgB,kBAAkB,CAAC,GAAG,EAAE,OAAO,GAAG,SAAS,CAE1D;AAMD,eAAO,MAAM,iBAAiB;;;;;iBAM5B,CAAC;AACH,MAAM,MAAM,WAAW,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,iBAAiB,CAAC,CAAC;AAE5D,eAAO,MAAM,mBAAmB;;;;;;;;;;;iBAwB5B,CAAC;AACL,MAAM,MAAM,aAAa,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,mBAAmB,CAAC,CAAC;AAEhE,eAAO,MAAM,gBAAgB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;iBAyB3B,CAAC;AAEH,MAAM,MAAM,UAAU,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,gBAAgB,CAAC,CAAC"}
|
package/dist/storage/index.d.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/storage/index.ts"],"names":[],"mappings":"AAAA,YAAY,EAAE,eAAe,EAAE,SAAS,EAAE,MAAM,SAAS,CAAC;
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/storage/index.ts"],"names":[],"mappings":"AAAA,YAAY,EAAE,eAAe,EAAE,SAAS,EAAE,UAAU,EAAE,MAAM,SAAS,CAAC;AACtE,OAAO,EAAE,oBAAoB,EAAE,MAAM,SAAS,CAAC"}
|
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"
|
|
@@ -1,21 +1,15 @@
|
|
|
1
|
-
import { useEffect, useRef, useState, type MouseEvent } from "react";
|
|
1
|
+
import { useEffect, useRef, useState, type KeyboardEvent, type MouseEvent } from "react";
|
|
2
2
|
import { marked } from "marked";
|
|
3
3
|
import { cn } from "../../lib/cn";
|
|
4
4
|
import { ensureSanitizer, sanitizeHtml } from "../../lib/sanitize";
|
|
5
|
+
import { Select } from "../shared/Select";
|
|
6
|
+
import { IconButton } from "../shared/IconButton";
|
|
7
|
+
import { RemoveIcon } from "../shared/icons";
|
|
5
8
|
|
|
6
9
|
export interface ChatMessage { role: "user" | "assistant"; content: string }
|
|
7
10
|
|
|
8
11
|
const SESSION_KEY = "bp-chat-session";
|
|
9
12
|
|
|
10
|
-
// Astro's trailingSlash: "ignore" means the viewer can be on "/brand/" while
|
|
11
|
-
// grounding links say "/brand#voice-tone" — the pathnames are equivalent
|
|
12
|
-
// but not literally equal. Per the HTML spec, same-document fragment
|
|
13
|
-
// navigation requires URL equality *excluding* the fragment, so a literal
|
|
14
|
-
// mismatch (e.g. "/brand/" vs "/brand") can't rely on native navigation —
|
|
15
|
-
// this helper only detects that "equivalent but not equal" case so we can
|
|
16
|
-
// handle it ourselves. "/" stays "/" rather than collapsing to "".
|
|
17
|
-
const normalizePathname = (p: string): string => p.replace(/\/+$/, "") || "/";
|
|
18
|
-
|
|
19
13
|
function sessionId(): string {
|
|
20
14
|
let id = window.localStorage.getItem(SESSION_KEY);
|
|
21
15
|
if (!id) {
|
|
@@ -44,7 +38,7 @@ export function assistantHtml(md: string): string {
|
|
|
44
38
|
const MD_STYLES =
|
|
45
39
|
"[&_p:not(:first-child)]:mt-2 [&_ul]:mt-1 [&_ul]:list-disc [&_ul]:pl-4 " +
|
|
46
40
|
"[&_ol]:mt-1 [&_ol]:list-decimal [&_ol]:pl-4 [&_li:not(:first-child)]:mt-1 " +
|
|
47
|
-
"[&_a]:underline [&_a]:underline-offset-2 " +
|
|
41
|
+
"[&_a]:underline [&_a]:underline-offset-2 [&_a]:text-primary " +
|
|
48
42
|
"[&_code]:rounded [&_code]:bg-base-200 [&_code]:px-1 [&_code]:font-mono [&_code]:text-[0.8em] " +
|
|
49
43
|
"[&_pre]:mt-2 [&_pre]:overflow-x-auto [&_pre]:rounded [&_pre]:bg-base-200 [&_pre]:p-2 " +
|
|
50
44
|
"[&_h1]:mt-2 [&_h1]:font-semibold [&_h2]:mt-2 [&_h2]:font-semibold " +
|
|
@@ -53,7 +47,7 @@ const MD_STYLES =
|
|
|
53
47
|
|
|
54
48
|
/** Parse our /api/chat SSE frames out of a fetch body. Exported for tests. */
|
|
55
49
|
export async function streamChat(
|
|
56
|
-
body: { messages: ChatMessage[]; sessionId: string; taskId?: string; draftChatbot?: unknown },
|
|
50
|
+
body: { messages: ChatMessage[]; sessionId: string; taskId?: string; draftChatbot?: unknown; simulateAudience?: string },
|
|
57
51
|
onText: (text: string) => void,
|
|
58
52
|
): Promise<{ ok: true; truncated: boolean } | { ok: false; error: string; retryAfterMs?: number }> {
|
|
59
53
|
try {
|
|
@@ -98,7 +92,7 @@ export async function streamChat(
|
|
|
98
92
|
}
|
|
99
93
|
}
|
|
100
94
|
|
|
101
|
-
export default function ChatPanel({ open, onClose, siteName, tasks, isEditor, draftChatbot, variant = "viewer" }: {
|
|
95
|
+
export default function ChatPanel({ open, onClose, siteName, tasks, isEditor, draftChatbot, simulateAudience, variant = "viewer" }: {
|
|
102
96
|
open: boolean;
|
|
103
97
|
onClose: () => void;
|
|
104
98
|
siteName: string;
|
|
@@ -106,6 +100,8 @@ export default function ChatPanel({ open, onClose, siteName, tasks, isEditor, dr
|
|
|
106
100
|
isEditor?: boolean;
|
|
107
101
|
/** Set by the configurator's test panel and the /edit chat FAB. */
|
|
108
102
|
draftChatbot?: unknown;
|
|
103
|
+
/** Configurator test panel only: preview this audience's prompt overlay. */
|
|
104
|
+
simulateAudience?: string;
|
|
109
105
|
/** Anchors the panel above its launcher: viewer corner FAB vs /edit FAB column. */
|
|
110
106
|
variant?: "viewer" | "editor";
|
|
111
107
|
}) {
|
|
@@ -115,6 +111,33 @@ export default function ChatPanel({ open, onClose, siteName, tasks, isEditor, dr
|
|
|
115
111
|
const [busy, setBusy] = useState(false);
|
|
116
112
|
const [notice, setNotice] = useState<string | null>(null);
|
|
117
113
|
const streamingRef = useRef("");
|
|
114
|
+
const composerRef = useRef<HTMLTextAreaElement>(null);
|
|
115
|
+
|
|
116
|
+
// Autosize the composer: height tracks content up to the className's
|
|
117
|
+
// 4-line max-h cap, then it scrolls internally. Keyed on `input` so the
|
|
118
|
+
// post-send clear snaps it back to a single row. (field-sizing: content
|
|
119
|
+
// would do this in CSS alone, but it's still Chromium-only.)
|
|
120
|
+
useEffect(() => {
|
|
121
|
+
const el = composerRef.current;
|
|
122
|
+
if (!el) return;
|
|
123
|
+
el.style.height = "auto";
|
|
124
|
+
el.style.height = `${el.scrollHeight}px`;
|
|
125
|
+
}, [input]);
|
|
126
|
+
|
|
127
|
+
function onComposerKeyDown(e: KeyboardEvent<HTMLTextAreaElement>) {
|
|
128
|
+
// Chat convention: Enter sends, Shift+Enter inserts a newline.
|
|
129
|
+
if (e.key === "Enter" && !e.shiftKey) {
|
|
130
|
+
e.preventDefault();
|
|
131
|
+
void send();
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// The tasks prop can shrink live (the configurator's Preview-as filters it):
|
|
136
|
+
// never keep a selected task that's no longer offered, or the next send would
|
|
137
|
+
// assemble a task the previewed audience can't access.
|
|
138
|
+
useEffect(() => {
|
|
139
|
+
if (taskId && !tasks.some((t) => t.id === taskId)) setTaskId("");
|
|
140
|
+
}, [taskId, tasks]);
|
|
118
141
|
|
|
119
142
|
const [mdReady, setMdReady] = useState(false);
|
|
120
143
|
useEffect(() => {
|
|
@@ -128,31 +151,30 @@ export default function ChatPanel({ open, onClose, siteName, tasks, isEditor, dr
|
|
|
128
151
|
function onMessagesClick(e: MouseEvent<HTMLDivElement>) {
|
|
129
152
|
const link = (e.target as HTMLElement).closest("a");
|
|
130
153
|
if (!link || !link.getAttribute("href")) return;
|
|
154
|
+
e.preventDefault();
|
|
131
155
|
const url = new URL(link.href, window.location.href);
|
|
132
|
-
//
|
|
133
|
-
//
|
|
134
|
-
//
|
|
135
|
-
//
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
//
|
|
143
|
-
// literally equal — per spec, same-document fragment navigation
|
|
144
|
-
// requires URL equality excluding the fragment, so the browser would
|
|
145
|
-
// treat a literal mismatch as a full cross-document navigation,
|
|
146
|
-
// unmounting the panel and wiping chat history. Perform the fragment
|
|
147
|
-
// navigation ourselves on the current (already-correct) URL instead.
|
|
148
|
-
e.preventDefault();
|
|
149
|
-
window.location.hash = url.hash;
|
|
150
|
-
return;
|
|
156
|
+
// Trust the hash, not the path: the model reliably preserves a section's
|
|
157
|
+
// #anchor but improvises the path around it (a bare "#anchor", or a path
|
|
158
|
+
// copied from elsewhere in the prompt). If the cited anchor exists in the
|
|
159
|
+
// CURRENT document — viewer page or /edit editor, both render heading
|
|
160
|
+
// ids — scroll to it in place; nothing navigates and the panel survives.
|
|
161
|
+
let anchorId = "";
|
|
162
|
+
if (url.hash.length > 1 && url.origin === window.location.origin) {
|
|
163
|
+
try {
|
|
164
|
+
anchorId = decodeURIComponent(url.hash.slice(1));
|
|
165
|
+
} catch {
|
|
166
|
+
anchorId = url.hash.slice(1); // malformed percent-encoding in model output
|
|
151
167
|
}
|
|
152
168
|
}
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
169
|
+
const target = anchorId ? document.getElementById(anchorId) : null;
|
|
170
|
+
if (target) {
|
|
171
|
+
target.scrollIntoView({ behavior: "smooth", block: "start" });
|
|
172
|
+
window.history.replaceState(null, "", url.hash);
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
// Anchor not in this document (cross-page citation on a multi-page
|
|
176
|
+
// portal, external URL, hashless link, or hallucinated anchor): new tab
|
|
177
|
+
// so chat history and any unsaved editor state survive.
|
|
156
178
|
window.open(url.href, "_blank", "noopener");
|
|
157
179
|
}
|
|
158
180
|
|
|
@@ -166,7 +188,7 @@ export default function ChatPanel({ open, onClose, siteName, tasks, isEditor, dr
|
|
|
166
188
|
setMessages([...history, { role: "assistant", content: "" }]);
|
|
167
189
|
streamingRef.current = "";
|
|
168
190
|
const result = await streamChat(
|
|
169
|
-
{ messages: history, sessionId: sessionId(), ...(taskId ? { taskId } : {}), ...(draftChatbot !== undefined ? { draftChatbot } : {}) },
|
|
191
|
+
{ messages: history, sessionId: sessionId(), ...(taskId ? { taskId } : {}), ...(draftChatbot !== undefined ? { draftChatbot } : {}), ...(simulateAudience ? { simulateAudience } : {}) },
|
|
170
192
|
(delta) => {
|
|
171
193
|
streamingRef.current += delta;
|
|
172
194
|
const streamed = streamingRef.current;
|
|
@@ -188,20 +210,24 @@ export default function ChatPanel({ open, onClose, siteName, tasks, isEditor, dr
|
|
|
188
210
|
<div role="dialog" aria-label={`Chat with ${siteName}`}
|
|
189
211
|
className={cn(
|
|
190
212
|
"fixed z-40 flex h-[28rem] w-80 flex-col rounded-lg border border-base-200 bg-base shadow-xl",
|
|
191
|
-
variant === "editor" ? "bottom-
|
|
213
|
+
variant === "editor" ? "bottom-4 fab-panel-left max-w-[calc(100vw-5.25rem)]" : "bottom-20 right-4",
|
|
192
214
|
)}>
|
|
193
215
|
|
|
194
216
|
<div className="flex items-center justify-between gap-2 border-b border-base-200 px-3 py-2">
|
|
195
|
-
<span className="text-sm font-medium text-base-contrast">
|
|
196
|
-
{
|
|
197
|
-
<select aria-label="Task" value={taskId} onChange={(e) => setTaskId(e.target.value)}
|
|
198
|
-
className="max-w-36 rounded border border-base-200 bg-base-accent px-1 py-0.5 text-xs">
|
|
199
|
-
<option value="">General</option>
|
|
200
|
-
{tasks.map((t) => <option key={t.id} value={t.id}>{t.label}</option>)}
|
|
201
|
-
</select>
|
|
202
|
-
)}
|
|
203
|
-
<button type="button" aria-label="Close chat" onClick={onClose} className="text-base-contrast-light hover:text-base-contrast">×</button>
|
|
217
|
+
<span className="text-sm font-medium text-base-contrast">Portal Assistant</span>
|
|
218
|
+
<IconButton icon={<RemoveIcon size={18} />} label="Close chat" onClick={onClose} className="rounded-md hover:bg-base-accent" />
|
|
204
219
|
</div>
|
|
220
|
+
{tasks.length > 0 && (
|
|
221
|
+
<div className="border-b border-base-200 px-3 py-2">
|
|
222
|
+
<Select
|
|
223
|
+
label="Task"
|
|
224
|
+
value={taskId}
|
|
225
|
+
onChange={setTaskId}
|
|
226
|
+
options={[{ value: "", label: "General" }, ...tasks.map((t) => ({ value: t.id, label: t.label }))]}
|
|
227
|
+
selectClassName="py-1"
|
|
228
|
+
/>
|
|
229
|
+
</div>
|
|
230
|
+
)}
|
|
205
231
|
<div className="flex-1 space-y-2 overflow-y-auto p-3" onClick={onMessagesClick}>
|
|
206
232
|
{messages.map((m, i) =>
|
|
207
233
|
m.role === "assistant" && mdReady && m.content ? (
|
|
@@ -219,13 +245,16 @@ export default function ChatPanel({ open, onClose, siteName, tasks, isEditor, dr
|
|
|
219
245
|
)}
|
|
220
246
|
{notice && <p className="text-xs text-base-contrast-light">{notice}</p>}
|
|
221
247
|
</div>
|
|
222
|
-
<form className="flex gap-2 border-t border-base-200 p-2" onSubmit={(e) => { e.preventDefault(); void send(); }}>
|
|
223
|
-
<
|
|
248
|
+
<form className="flex items-end gap-2 border-t border-base-200 p-2" onSubmit={(e) => { e.preventDefault(); void send(); }}>
|
|
249
|
+
<textarea
|
|
250
|
+
ref={composerRef}
|
|
251
|
+
rows={1}
|
|
224
252
|
value={input}
|
|
225
253
|
onChange={(e) => setInput(e.target.value)}
|
|
254
|
+
onKeyDown={onComposerKeyDown}
|
|
226
255
|
placeholder={`Ask about ${siteName}…`}
|
|
227
256
|
maxLength={4000}
|
|
228
|
-
className="min-w-0 flex-1 rounded border border-base-200 bg-base px-2 py-1 text-sm text-base-contrast"
|
|
257
|
+
className="max-h-[5.625rem] min-w-0 flex-1 resize-none overflow-y-auto rounded border border-base-200 bg-base px-2 py-1 text-sm text-base-contrast focus:outline-none focus:bg-[color-mix(in_srgb,var(--color-base),var(--color-base-contrast)_5%)]"
|
|
229
258
|
/>
|
|
230
259
|
<button type="submit" aria-label="Send" disabled={busy}
|
|
231
260
|
className="rounded bg-primary px-3 py-1 text-sm text-primary-contrast disabled:opacity-50">→</button>
|
|
@@ -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
|
}
|