@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.
Files changed (85) hide show
  1. package/dist/{chunk-VVK6ZRCN.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-EWKCEPMW.js → chunk-SFKYUKVN.js} +1 -1
  7. package/dist/{chunk-QKOIEWZ4.js → chunk-SWIHUIFT.js} +38 -18
  8. package/dist/{chunk-6UDYJQL6.js → chunk-TOBPMMRM.js} +1 -1
  9. package/dist/{chunk-TYYCKVAW.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-WNPMBFD6.js → chunk-ZRWGV5O6.js} +3 -3
  13. package/dist/chunk-ZSPCE6MZ.js +70 -0
  14. package/dist/components/chat/ChatPanel.d.ts +4 -1
  15. package/dist/components/chat/ChatPanel.d.ts.map +1 -1
  16. package/dist/components/editor/SettingsForm.d.ts.map +1 -1
  17. package/dist/components/editor/index.js +6 -3
  18. package/dist/components/primitives/index.js +7 -7
  19. package/dist/components/sections/Video/VideoBlock.d.ts +31 -0
  20. package/dist/components/sections/Video/VideoBlock.d.ts.map +1 -0
  21. package/dist/components/sections/Video/index.d.ts +28 -0
  22. package/dist/components/sections/Video/index.d.ts.map +1 -0
  23. package/dist/components/sections/all-sections.d.ts +25 -0
  24. package/dist/components/sections/all-sections.d.ts.map +1 -1
  25. package/dist/components/sections/brand-guide.js +7 -7
  26. package/dist/components/sections/core-sections.d.ts +25 -0
  27. package/dist/components/sections/core-sections.d.ts.map +1 -1
  28. package/dist/components/sections/register-schemas.js +13 -12
  29. package/dist/components/sections/register.js +13 -12
  30. package/dist/components/shared/Tabs.d.ts +1 -1
  31. package/dist/components/shared/VideoSourceField.d.ts +8 -0
  32. package/dist/components/shared/VideoSourceField.d.ts.map +1 -0
  33. package/dist/components/shell/ChatbotConfigModal.d.ts.map +1 -1
  34. package/dist/components/shell/EditorShell.d.ts.map +1 -1
  35. package/dist/components/shell/MediaLibraryModal.d.ts +4 -1
  36. package/dist/components/shell/MediaLibraryModal.d.ts.map +1 -1
  37. package/dist/hooks/useEditorPublish.d.ts.map +1 -1
  38. package/dist/hooks/useMediaPipeline.d.ts +9 -1
  39. package/dist/hooks/useMediaPipeline.d.ts.map +1 -1
  40. package/dist/index.js +24 -24
  41. package/dist/lib/index.js +10 -10
  42. package/dist/lib/registry.d.ts +6 -0
  43. package/dist/lib/registry.d.ts.map +1 -1
  44. package/dist/lib/video-url.d.ts +7 -0
  45. package/dist/lib/video-url.d.ts.map +1 -0
  46. package/dist/lib/video-url.js +9 -0
  47. package/dist/media/index.js +2 -2
  48. package/dist/media/videoPoster.d.ts +1 -0
  49. package/dist/media/videoPoster.d.ts.map +1 -1
  50. package/dist/schemas/index.js +4 -4
  51. package/dist/schemas/link.d.ts.map +1 -1
  52. package/dist/schemas/link.js +1 -1
  53. package/dist/schemas/media.d.ts +13 -0
  54. package/dist/schemas/media.d.ts.map +1 -1
  55. package/dist/schemas/shared.d.ts.map +1 -1
  56. package/dist/schemas/site-config.d.ts +3 -0
  57. package/dist/schemas/site-config.d.ts.map +1 -1
  58. package/dist/storage/index.d.ts +1 -1
  59. package/dist/storage/index.d.ts.map +1 -1
  60. package/dist/storage/types.d.ts +26 -0
  61. package/dist/storage/types.d.ts.map +1 -1
  62. package/package.json +5 -1
  63. package/src/components/chat/ChatPanel.tsx +78 -49
  64. package/src/components/editor/SettingsForm.tsx +11 -0
  65. package/src/components/sections/Video/VideoBlock.tsx +149 -0
  66. package/src/components/sections/Video/index.tsx +111 -0
  67. package/src/components/sections/core-sections.ts +2 -0
  68. package/src/components/shared/Tabs.tsx +2 -2
  69. package/src/components/shared/VideoSourceField.tsx +144 -0
  70. package/src/components/shell/ChatbotConfigModal.tsx +44 -3
  71. package/src/components/shell/EditorShell.tsx +8 -0
  72. package/src/components/shell/MediaLibraryModal.tsx +47 -1
  73. package/src/hooks/useEditorPublish.ts +3 -0
  74. package/src/hooks/useMediaPipeline.ts +188 -25
  75. package/src/lib/registry.ts +7 -0
  76. package/src/lib/video-url.ts +39 -0
  77. package/src/media/videoPoster.ts +2 -2
  78. package/src/schemas/link.ts +15 -4
  79. package/src/schemas/media.ts +12 -0
  80. package/src/schemas/shared.ts +23 -14
  81. package/src/schemas/site-config.ts +10 -2
  82. package/src/storage/index.ts +1 -1
  83. package/src/storage/types.ts +23 -0
  84. package/dist/chunk-4E66N6UV.js +0 -10
  85. 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;AAoCtD,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"}
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;;;;;;;;;;iBAgB5B,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"}
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"}
@@ -1,3 +1,3 @@
1
- export type { StorageProvider, FileWrite } from "./types";
1
+ export type { StorageProvider, FileWrite, AssetStore } from "./types";
2
2
  export { StorageConflictError } from "./types";
3
3
  //# sourceMappingURL=index.d.ts.map
@@ -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;AAC1D,OAAO,EAAE,oBAAoB,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"}
@@ -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.70",
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
- // Native navigation is only safe when there's a hash to scroll to — the
133
- // grounding emits hashless same-pathname links (e.g. "/") for content
134
- // before the first heading, and letting those navigate natively would
135
- // full-reload the page, unmounting the panel and wiping chat history.
136
- const sameOriginWithHash = variant !== "editor" && url.origin === window.location.origin && url.hash !== "";
137
- if (sameOriginWithHash) {
138
- if (url.pathname === window.location.pathname) {
139
- return; // literally same document: native hash navigation, panel stays open
140
- }
141
- if (normalizePathname(url.pathname) === normalizePathname(window.location.pathname)) {
142
- // Equivalent pathnames (trailing-slash only difference) but NOT
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
- // Cross-page (or same-page but hashless, or any /edit click): new tab so
154
- // chat history / unsaved editor state survive.
155
- e.preventDefault();
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-40 right-4 lg:right-auto fab-container-right" : "bottom-20 right-4",
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">{siteName} assistant</span>
196
- {tasks.length > 0 && (
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
- <input
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 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
  }