@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
|
@@ -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
|
+
}
|
|
@@ -1,10 +1,11 @@
|
|
|
1
|
-
import { useState } from "react";
|
|
1
|
+
import { useEffect, useState } from "react";
|
|
2
2
|
import { EditorModal } from "./EditorModal";
|
|
3
3
|
import { Button } from "../shared/Button";
|
|
4
4
|
import { cn } from "../../lib/cn";
|
|
5
5
|
import ChatPanel from "../chat/ChatPanel";
|
|
6
6
|
import type { SiteConfig, ChatbotConfig, ChatbotTask } from "../../schemas/site-config";
|
|
7
7
|
import type { Audience } from "../../auth/types";
|
|
8
|
+
import { audiencePasses } from "../../lib/pages";
|
|
8
9
|
|
|
9
10
|
interface Props {
|
|
10
11
|
isOpen: boolean;
|
|
@@ -39,6 +40,12 @@ export function ChatbotConfigModal({ isOpen, onClose, siteConfig, onSiteConfigCh
|
|
|
39
40
|
const chatbot = siteConfig.chatbot;
|
|
40
41
|
const [newTaskLabel, setNewTaskLabel] = useState("");
|
|
41
42
|
const [showTest, setShowTest] = useState(false);
|
|
43
|
+
const [previewAudience, setPreviewAudience] = useState("");
|
|
44
|
+
|
|
45
|
+
// A deselected audience can no longer chat — never keep simulating it.
|
|
46
|
+
useEffect(() => {
|
|
47
|
+
if (previewAudience && !chatbot.audiences.includes(previewAudience)) setPreviewAudience("");
|
|
48
|
+
}, [previewAudience, chatbot.audiences]);
|
|
42
49
|
|
|
43
50
|
const patch = (p: Partial<ChatbotConfig>) =>
|
|
44
51
|
onSiteConfigChange({ ...siteConfig, chatbot: { ...chatbot, ...p } });
|
|
@@ -67,6 +74,13 @@ export function ChatbotConfigModal({ isOpen, onClose, siteConfig, onSiteConfigCh
|
|
|
67
74
|
const toggleName = (list: string[], name: string) =>
|
|
68
75
|
list.includes(name) ? list.filter((n) => n !== name) : [...list, name];
|
|
69
76
|
|
|
77
|
+
// Record lookups can hit inherited Object.prototype members ("constructor"
|
|
78
|
+
// is a legal audience name) — treat anything non-string as absent.
|
|
79
|
+
const overlayValue = (name: string) => {
|
|
80
|
+
const v = chatbot.audiencePrompts[name];
|
|
81
|
+
return typeof v === "string" ? v : "";
|
|
82
|
+
};
|
|
83
|
+
|
|
70
84
|
return (
|
|
71
85
|
<EditorModal isOpen={isOpen} onClose={onClose} title="Chatbot" size="settings" noPadding>
|
|
72
86
|
<div className="flex flex-1 flex-col gap-4 overflow-y-auto px-6 py-4">
|
|
@@ -79,14 +93,27 @@ export function ChatbotConfigModal({ isOpen, onClose, siteConfig, onSiteConfigCh
|
|
|
79
93
|
|
|
80
94
|
<label className="flex items-center gap-2 text-sm text-base-contrast">
|
|
81
95
|
<input type="checkbox" aria-label="Enable chatbot" checked={chatbot.enabled}
|
|
96
|
+
className="h-5 w-5 cursor-pointer accent-primary"
|
|
82
97
|
onChange={(e) => patch({ enabled: e.target.checked })} />
|
|
83
98
|
Show the chat widget to viewers
|
|
84
99
|
</label>
|
|
85
100
|
|
|
86
101
|
<div>
|
|
87
102
|
<p className="mb-1 text-sm font-medium text-base-contrast">Audiences</p>
|
|
88
|
-
<p className="mb-2 text-xs text-base-contrast-light">Who sees the widget
|
|
103
|
+
<p className="mb-2 text-xs text-base-contrast-light">Who sees the widget — only the selected audiences; none selected means no one.</p>
|
|
89
104
|
<AudienceChips audiences={audiences} selected={chatbot.audiences} onToggle={(n) => patch({ audiences: toggleName(chatbot.audiences, n) })} />
|
|
105
|
+
{chatbot.audiences.map((name) => (
|
|
106
|
+
<div key={name} className="mt-2">
|
|
107
|
+
<label htmlFor={`audience-prompt-${name}`} className="mb-1 block text-xs font-medium text-base-contrast">
|
|
108
|
+
Additional guidance for {name}
|
|
109
|
+
</label>
|
|
110
|
+
<textarea id={`audience-prompt-${name}`} aria-label={`Guidance for ${name}`} rows={2} maxLength={8000}
|
|
111
|
+
value={overlayValue(name)}
|
|
112
|
+
onChange={(e) => patch({ audiencePrompts: { ...chatbot.audiencePrompts, [name]: e.target.value } })}
|
|
113
|
+
placeholder={`Only ${name} sees answers shaped by this — safe for ${name}-level info.`}
|
|
114
|
+
className="w-full rounded border border-base-200 bg-base px-2 py-1 text-sm text-base-contrast" />
|
|
115
|
+
</div>
|
|
116
|
+
))}
|
|
90
117
|
</div>
|
|
91
118
|
|
|
92
119
|
<div>
|
|
@@ -138,11 +165,25 @@ export function ChatbotConfigModal({ isOpen, onClose, siteConfig, onSiteConfigCh
|
|
|
138
165
|
<p className="mt-1 text-xs text-base-contrast-light">
|
|
139
166
|
Tests your unsaved prompt and tasks. Answers ground on published content.
|
|
140
167
|
</p>
|
|
168
|
+
{showTest && platformEnabled && (
|
|
169
|
+
<label className="mt-2 flex items-center gap-2 text-xs text-base-contrast">
|
|
170
|
+
Preview as
|
|
171
|
+
<select aria-label="Preview as" value={previewAudience} onChange={(e) => setPreviewAudience(e.target.value)}
|
|
172
|
+
className="rounded border border-base-200 bg-base-accent px-1 py-0.5 text-xs">
|
|
173
|
+
<option value="">Editor (base prompt only)</option>
|
|
174
|
+
{chatbot.audiences.map((n) => <option key={n} value={n}>{n}</option>)}
|
|
175
|
+
</select>
|
|
176
|
+
<span className="text-base-contrast-light">Prompt and task preview only — grounding stays editor-wide.</span>
|
|
177
|
+
</label>
|
|
178
|
+
)}
|
|
141
179
|
{showTest && platformEnabled && (
|
|
142
180
|
<ChatPanel
|
|
143
181
|
open onClose={() => setShowTest(false)} siteName={siteConfig.siteName}
|
|
144
|
-
tasks={chatbot.tasks
|
|
182
|
+
tasks={chatbot.tasks
|
|
183
|
+
.filter((t) => previewAudience === "" || audiencePasses(t.access, previewAudience))
|
|
184
|
+
.map((t) => ({ id: t.id, label: t.label }))}
|
|
145
185
|
isEditor draftChatbot={chatbot}
|
|
186
|
+
simulateAudience={previewAudience || undefined}
|
|
146
187
|
/>
|
|
147
188
|
)}
|
|
148
189
|
</div>
|
|
@@ -153,6 +153,10 @@ export default function EditorShell({
|
|
|
153
153
|
const [showChatbotConfig, setShowChatbotConfig] = useState(false);
|
|
154
154
|
const [isRestoring, setIsRestoring] = useState(false);
|
|
155
155
|
const [rejectedUploads, setRejectedUploads] = useState<{ name: string; reason: "size" | "type" }[]>([]);
|
|
156
|
+
// Surfaces useMediaPipeline's onUploadError (oversized/disallowed files,
|
|
157
|
+
// upload-intent failures, direct-to-bucket PUT failures) in the media
|
|
158
|
+
// library modal, alongside its existing client-side rejection banner.
|
|
159
|
+
const [uploadErrorMessage, setUploadErrorMessage] = useState<string | null>(null);
|
|
156
160
|
const [activePageId, setActivePageId] = useState<string | null>(null);
|
|
157
161
|
const [movingSectionId, setMovingSectionId] = useState<string | null>(null);
|
|
158
162
|
|
|
@@ -223,6 +227,7 @@ export default function EditorShell({
|
|
|
223
227
|
setLocalChangesExist,
|
|
224
228
|
setDirtySectionIds,
|
|
225
229
|
markSectionDirty: persistence.markSectionDirty,
|
|
230
|
+
onUploadError: (message) => setUploadErrorMessage(message),
|
|
226
231
|
});
|
|
227
232
|
|
|
228
233
|
const buildStatus = useBuildStatus();
|
|
@@ -1032,6 +1037,9 @@ export default function EditorShell({
|
|
|
1032
1037
|
referenceCountMap={mediaPipeline.referenceCountMap}
|
|
1033
1038
|
localUrlMap={mediaPipeline.pendingLocalUrls}
|
|
1034
1039
|
maxFileSize={siteConfig?.media.maxFileSize}
|
|
1040
|
+
uploadProgress={mediaPipeline.uploadProgress}
|
|
1041
|
+
uploadError={uploadErrorMessage}
|
|
1042
|
+
onDismissUploadError={() => setUploadErrorMessage(null)}
|
|
1035
1043
|
/>
|
|
1036
1044
|
</EditorModal>
|
|
1037
1045
|
<EditorModal
|
|
@@ -17,6 +17,13 @@ export interface MediaLibraryModalProps {
|
|
|
17
17
|
referenceCountMap: Record<string, number>;
|
|
18
18
|
localUrlMap?: Record<string, string>;
|
|
19
19
|
maxFileSize?: number;
|
|
20
|
+
// Direct-to-bucket large-video upload progress, keyed by content hash (0..1).
|
|
21
|
+
uploadProgress?: Record<string, number>;
|
|
22
|
+
// Async upload failure from the pipeline (oversized/disallowed file,
|
|
23
|
+
// upload-intent error, direct-to-bucket PUT failure) — distinct from the
|
|
24
|
+
// client-side size pre-check below, which never reaches the pipeline.
|
|
25
|
+
uploadError?: string | null;
|
|
26
|
+
onDismissUploadError?: () => void;
|
|
20
27
|
}
|
|
21
28
|
|
|
22
29
|
type KindFilter = "all" | MediaKind;
|
|
@@ -97,6 +104,9 @@ export function MediaLibraryModal({
|
|
|
97
104
|
referenceCountMap,
|
|
98
105
|
localUrlMap = {},
|
|
99
106
|
maxFileSize,
|
|
107
|
+
uploadProgress = {},
|
|
108
|
+
uploadError = null,
|
|
109
|
+
onDismissUploadError,
|
|
100
110
|
}: MediaLibraryModalProps) {
|
|
101
111
|
const [search, setSearch] = useState("");
|
|
102
112
|
const [kindFilter, setKindFilter] = useState<KindFilter>("all");
|
|
@@ -111,7 +121,11 @@ export function MediaLibraryModal({
|
|
|
111
121
|
const valid: File[] = [];
|
|
112
122
|
const rejected: string[] = [];
|
|
113
123
|
for (const file of files) {
|
|
114
|
-
|
|
124
|
+
// MP4/WebM videos over the git-path cap route through the pipeline's
|
|
125
|
+
// direct-to-bucket upload instead of being rejected here — it applies
|
|
126
|
+
// its own (much larger) asset cap and reports its own errors.
|
|
127
|
+
const isBucketEligibleVideo = file.type === "video/mp4" || file.type === "video/webm";
|
|
128
|
+
if (file.size > maxFileSize && !isBucketEligibleVideo) {
|
|
115
129
|
rejected.push(file.name);
|
|
116
130
|
} else {
|
|
117
131
|
valid.push(file);
|
|
@@ -161,10 +175,33 @@ export function MediaLibraryModal({
|
|
|
161
175
|
setConfirmDelete(false);
|
|
162
176
|
}
|
|
163
177
|
|
|
178
|
+
const uploadingHashes = Object.keys(uploadProgress);
|
|
179
|
+
|
|
164
180
|
return (
|
|
165
181
|
<div className="flex flex-col gap-4">
|
|
166
182
|
<UploadZone onUpload={handleUpload} />
|
|
167
183
|
|
|
184
|
+
{/* Direct-to-bucket large-video upload progress */}
|
|
185
|
+
{uploadingHashes.length > 0 && (
|
|
186
|
+
<div className="space-y-1.5">
|
|
187
|
+
{uploadingHashes.map((hash) => {
|
|
188
|
+
const pct = Math.round((uploadProgress[hash] ?? 0) * 100);
|
|
189
|
+
return (
|
|
190
|
+
<div key={hash} className="flex items-center gap-2">
|
|
191
|
+
<span className="shrink-0 text-xs text-base-contrast-light">Uploading video…</span>
|
|
192
|
+
<div className="h-1.5 flex-1 overflow-hidden rounded-full bg-base-200">
|
|
193
|
+
<div
|
|
194
|
+
className="h-full rounded-full bg-primary transition-[width]"
|
|
195
|
+
style={{ width: `${pct}%` }}
|
|
196
|
+
/>
|
|
197
|
+
</div>
|
|
198
|
+
<span className="w-9 shrink-0 text-right text-xs text-base-contrast-light">{pct}%</span>
|
|
199
|
+
</div>
|
|
200
|
+
);
|
|
201
|
+
})}
|
|
202
|
+
</div>
|
|
203
|
+
)}
|
|
204
|
+
|
|
168
205
|
{/* Sticky toolbar + confirmation */}
|
|
169
206
|
<div className="sticky top-[-16px] z-10 -mx-6 space-y-3 bg-base px-6 pb-3 pt-4">
|
|
170
207
|
<div className="flex items-center gap-2">
|
|
@@ -235,6 +272,15 @@ export function MediaLibraryModal({
|
|
|
235
272
|
onDismiss={() => setRejectedFiles([])}
|
|
236
273
|
/>
|
|
237
274
|
)}
|
|
275
|
+
|
|
276
|
+
{/* Async upload failure (oversized/disallowed file, upload-intent error,
|
|
277
|
+
direct-to-bucket PUT failure) — distinct from the client-side check above. */}
|
|
278
|
+
{uploadError && (
|
|
279
|
+
<UploadRejectionAlert
|
|
280
|
+
message={uploadError}
|
|
281
|
+
onDismiss={() => onDismissUploadError?.()}
|
|
282
|
+
/>
|
|
283
|
+
)}
|
|
238
284
|
</div>
|
|
239
285
|
|
|
240
286
|
{/* Grid */}
|
|
@@ -107,6 +107,9 @@ export function useEditorPublish({
|
|
|
107
107
|
};
|
|
108
108
|
for (const [key, blob] of Object.entries(storedBlobs)) {
|
|
109
109
|
if (key === "primary" && item.kind === "image") continue;
|
|
110
|
+
// Assets-backed originals live in the bucket; only derived files (poster)
|
|
111
|
+
// ride the git save.
|
|
112
|
+
if (item.storage === "assets" && key === "primary") continue;
|
|
110
113
|
const base64 = await blobToBase64(blob);
|
|
111
114
|
if (key === "primary") {
|
|
112
115
|
const ext = mimeExt[item.mimeType] ?? "bin";
|
|
@@ -24,6 +24,10 @@ interface UseMediaPipelineInput {
|
|
|
24
24
|
setLocalChangesExist: React.Dispatch<React.SetStateAction<boolean>>;
|
|
25
25
|
setDirtySectionIds: React.Dispatch<React.SetStateAction<Set<string>>>;
|
|
26
26
|
markSectionDirty: (sectionId: string, content: SectionContent) => void;
|
|
27
|
+
// Surfaces upload rejections/failures (oversized files, disallowed types,
|
|
28
|
+
// upload-intent errors, direct-to-bucket PUT failures) to the caller, which
|
|
29
|
+
// decides how to display them (e.g. the media modal's rejection banner).
|
|
30
|
+
onUploadError?: (message: string) => void;
|
|
27
31
|
}
|
|
28
32
|
|
|
29
33
|
export function useMediaPipeline({
|
|
@@ -35,11 +39,16 @@ export function useMediaPipeline({
|
|
|
35
39
|
setLocalChangesExist,
|
|
36
40
|
setDirtySectionIds,
|
|
37
41
|
markSectionDirty,
|
|
42
|
+
onUploadError,
|
|
38
43
|
}: UseMediaPipelineInput) {
|
|
39
44
|
const [processingItems, setProcessingItems] = useState<QueueItem[]>([]);
|
|
40
45
|
const [pendingMediaItems, setPendingMediaItems] = useState<LibraryMediaItem[]>([]);
|
|
41
46
|
const [pendingLocalUrls, setPendingLocalUrls] = useState<Record<string, string>>({});
|
|
42
47
|
const [pendingDeletions, setPendingDeletions] = useState<string[]>([]);
|
|
48
|
+
// Direct-to-bucket large-video PUT progress, keyed by content hash (0..1).
|
|
49
|
+
// Populated by uploadLargeVideo's XHR upload.onprogress, cleared on
|
|
50
|
+
// completion or failure.
|
|
51
|
+
const [uploadProgress, setUploadProgress] = useState<Record<string, number>>({});
|
|
43
52
|
// True when the manifest holds unsaved edits that produce no upload/deletion —
|
|
44
53
|
// e.g. alt text on an already-published image. Without this the save path's
|
|
45
54
|
// hasMediaChanges check (uploads/deletions only) skips the change and the edit
|
|
@@ -52,6 +61,45 @@ export function useMediaPipeline({
|
|
|
52
61
|
const uploadCallbacksRef = useRef<Map<string, (imageId: string) => void>>(new Map());
|
|
53
62
|
const destroyedRef = useRef(false);
|
|
54
63
|
|
|
64
|
+
// Shared persistence path for every pending media item — the processing
|
|
65
|
+
// queue's small-file finalize AND the direct-to-bucket large-video upload
|
|
66
|
+
// both funnel through this so a Dexie write, the pendingMediaItems/
|
|
67
|
+
// pendingLocalUrls state, and cleanup-on-failure can't drift between them.
|
|
68
|
+
const finalizePendingMedia = useCallback(
|
|
69
|
+
async (
|
|
70
|
+
item: LibraryMediaItem,
|
|
71
|
+
localUrls: Record<string, string>,
|
|
72
|
+
blobsMap: Record<string, Blob>,
|
|
73
|
+
): Promise<boolean> => {
|
|
74
|
+
if (destroyedRef.current) return false;
|
|
75
|
+
try {
|
|
76
|
+
// Persist blobs to IndexedDB BEFORE marking the item pending.
|
|
77
|
+
// Previously this was fire-and-forget: a Save firing before the
|
|
78
|
+
// write landed — or a failed write (e.g. storage quota) — left the
|
|
79
|
+
// item promoted into the manifest with no backing file, an orphan
|
|
80
|
+
// that dropped out on the next render and blocked re-adding it.
|
|
81
|
+
await addPendingMediaItem(item, localUrls, blobsMap);
|
|
82
|
+
} catch (err) {
|
|
83
|
+
console.error(`[useMediaPipeline] Failed to persist media "${item.originalName}":`, err);
|
|
84
|
+
for (const url of Object.values(localUrls)) URL.revokeObjectURL(url);
|
|
85
|
+
return false;
|
|
86
|
+
}
|
|
87
|
+
if (destroyedRef.current) return false;
|
|
88
|
+
removePendingMediaDeletion(item.id);
|
|
89
|
+
setPendingDeletions((prev) => prev.filter((d) => d !== item.id));
|
|
90
|
+
setPendingMediaItems((prev) => [...prev, item]);
|
|
91
|
+
const displayKey = item.kind === "video"
|
|
92
|
+
? "primary"
|
|
93
|
+
: Object.keys(localUrls).find((k) => k !== "primary" && k !== "poster") ?? "primary";
|
|
94
|
+
if (localUrls[displayKey]) {
|
|
95
|
+
setPendingLocalUrls((prev) => ({ ...prev, [item.id]: localUrls[displayKey] }));
|
|
96
|
+
}
|
|
97
|
+
setLocalChangesExist(true);
|
|
98
|
+
return true;
|
|
99
|
+
},
|
|
100
|
+
[setLocalChangesExist],
|
|
101
|
+
);
|
|
102
|
+
|
|
55
103
|
// --- Processing queue ---
|
|
56
104
|
|
|
57
105
|
const mediaConfigKey = siteConfig
|
|
@@ -103,31 +151,11 @@ export function useMediaPipeline({
|
|
|
103
151
|
size: v.size,
|
|
104
152
|
})),
|
|
105
153
|
alt: "",
|
|
154
|
+
storage: "github",
|
|
106
155
|
};
|
|
107
156
|
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
// Previously this was fire-and-forget: a Save firing before the
|
|
111
|
-
// write landed — or a failed write (e.g. storage quota) — left the
|
|
112
|
-
// item promoted into the manifest with no backing file, an orphan
|
|
113
|
-
// that dropped out on the next render and blocked re-adding it.
|
|
114
|
-
await addPendingMediaItem(item, localUrls, blobsMap);
|
|
115
|
-
} catch (err) {
|
|
116
|
-
console.error(`[useMediaPipeline] Failed to persist media "${item.originalName}":`, err);
|
|
117
|
-
for (const url of Object.values(localUrls)) URL.revokeObjectURL(url);
|
|
118
|
-
return;
|
|
119
|
-
}
|
|
120
|
-
if (destroyedRef.current) return;
|
|
121
|
-
removePendingMediaDeletion(item.id);
|
|
122
|
-
setPendingDeletions((prev) => prev.filter((d) => d !== item.id));
|
|
123
|
-
setPendingMediaItems((prev) => [...prev, item]);
|
|
124
|
-
const displayKey = kind === "video"
|
|
125
|
-
? "primary"
|
|
126
|
-
: Object.keys(localUrls).find((k) => k !== "primary" && k !== "poster") ?? "primary";
|
|
127
|
-
if (localUrls[displayKey]) {
|
|
128
|
-
setPendingLocalUrls((prev) => ({ ...prev, [item.id]: localUrls[displayKey] }));
|
|
129
|
-
}
|
|
130
|
-
setLocalChangesExist(true);
|
|
157
|
+
const persisted = await finalizePendingMedia(item, localUrls, blobsMap);
|
|
158
|
+
if (!persisted) return;
|
|
131
159
|
|
|
132
160
|
const callback = uploadCallbacksRef.current.get(event.item.hash);
|
|
133
161
|
if (callback) {
|
|
@@ -179,6 +207,121 @@ export function useMediaPipeline({
|
|
|
179
207
|
|
|
180
208
|
// --- Media handlers ---
|
|
181
209
|
|
|
210
|
+
// Direct-to-bucket path for videos over the git-path cap (maxFileSize): mint
|
|
211
|
+
// a signed URL, PUT the original straight to the bucket (with progress),
|
|
212
|
+
// then persist a pending item whose ONLY Dexie blob is the poster — the
|
|
213
|
+
// original never rides the base64 /api/save payload.
|
|
214
|
+
// Every exit path below must (1) never throw out to the caller — so the
|
|
215
|
+
// handleMediaUpload batch loop always advances to the next file — (2) call
|
|
216
|
+
// onUploadError exactly once on failure, and (3) leave no leaked
|
|
217
|
+
// uploadProgress[hash] entry. Each network/decode stage below gets its own
|
|
218
|
+
// try/catch so it can report a stage-specific, actionable message and
|
|
219
|
+
// `return` (never rethrow); the outer try/catch is a last-resort net for
|
|
220
|
+
// anything upstream of those stages (e.g. hashing), and the single outer
|
|
221
|
+
// `finally` is the one place progress gets cleared, regardless of which
|
|
222
|
+
// stage — or none — ran.
|
|
223
|
+
const uploadLargeVideo = useCallback(async (file: File, mediaConfig: SiteConfig["media"]) => {
|
|
224
|
+
let hash: string | undefined;
|
|
225
|
+
try {
|
|
226
|
+
const buffer = await file.arrayBuffer();
|
|
227
|
+
hash = await hashFileBuffer(buffer);
|
|
228
|
+
|
|
229
|
+
const isDeleted = pendingDeletions.includes(hash);
|
|
230
|
+
const existsInManifest = hash in mediaManifest.images;
|
|
231
|
+
const existsInPending = pendingMediaItems.some((i) => i.hash === hash);
|
|
232
|
+
if ((existsInManifest || existsInPending) && !isDeleted) return;
|
|
233
|
+
|
|
234
|
+
if (file.size > mediaConfig.maxAssetSize) {
|
|
235
|
+
onUploadError?.(`${file.name} exceeds the ${Math.round(mediaConfig.maxAssetSize / 1024 / 1024)}MB video limit.`);
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
let uploadUrl: string;
|
|
240
|
+
let publicUrl: string;
|
|
241
|
+
try {
|
|
242
|
+
const intentRes = await fetch("/api/media/upload-intent", {
|
|
243
|
+
method: "POST",
|
|
244
|
+
headers: { "Content-Type": "application/json" },
|
|
245
|
+
body: JSON.stringify({ hash, contentType: file.type, size: file.size }),
|
|
246
|
+
});
|
|
247
|
+
if (!intentRes.ok) {
|
|
248
|
+
const body = await intentRes.json().catch(() => ({}) as { message?: string });
|
|
249
|
+
onUploadError?.(body.message ?? "Video upload is not available on this site.");
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
({ uploadUrl, publicUrl } = await intentRes.json());
|
|
253
|
+
} catch (err) {
|
|
254
|
+
onUploadError?.(`${file.name}: couldn't start the upload — ${(err as Error).message}. Try again.`);
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
try {
|
|
259
|
+
// Direct browser -> bucket PUT with progress (fetch has no upload progress).
|
|
260
|
+
await new Promise<void>((resolvePut, rejectPut) => {
|
|
261
|
+
const xhr = new XMLHttpRequest();
|
|
262
|
+
xhr.open("PUT", uploadUrl);
|
|
263
|
+
xhr.setRequestHeader("Content-Type", file.type);
|
|
264
|
+
xhr.upload.onprogress = (e) => {
|
|
265
|
+
if (e.lengthComputable) setUploadProgress((prev) => ({ ...prev, [hash as string]: e.loaded / e.total }));
|
|
266
|
+
};
|
|
267
|
+
xhr.onload = () => (xhr.status >= 200 && xhr.status < 300 ? resolvePut() : rejectPut(new Error(`upload ${xhr.status}`)));
|
|
268
|
+
xhr.onerror = () => rejectPut(new Error("upload failed"));
|
|
269
|
+
xhr.send(file);
|
|
270
|
+
});
|
|
271
|
+
} catch (err) {
|
|
272
|
+
onUploadError?.(`${file.name}: upload failed — ${(err as Error).message}. Try again.`);
|
|
273
|
+
return;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
try {
|
|
277
|
+
const { posterBlob, width, height, duration } = await generateVideoPoster(file, mediaConfig.quality);
|
|
278
|
+
const sanitizedName = sanitizeMediaName(file.name);
|
|
279
|
+
const item: LibraryMediaItem = {
|
|
280
|
+
id: hash,
|
|
281
|
+
hash,
|
|
282
|
+
kind: "video",
|
|
283
|
+
originalName: sanitizedName,
|
|
284
|
+
width,
|
|
285
|
+
height,
|
|
286
|
+
mimeType: file.type,
|
|
287
|
+
size: file.size,
|
|
288
|
+
folder: `${sanitizedName}-${hash}`,
|
|
289
|
+
variants: [],
|
|
290
|
+
alt: "",
|
|
291
|
+
storage: "assets",
|
|
292
|
+
url: publicUrl,
|
|
293
|
+
// MediaRecorder-sourced webm/mp4 (screen recordings) commonly report
|
|
294
|
+
// duration as Infinity or NaN. JSON.stringify(Infinity) -> null,
|
|
295
|
+
// which ImageManifestSchema's z.number().positive().optional()
|
|
296
|
+
// rejects on the next save — omit the field entirely instead.
|
|
297
|
+
...(Number.isFinite(duration) && duration > 0 ? { duration } : {}),
|
|
298
|
+
};
|
|
299
|
+
// Only the poster blob is persisted — the original lives in the bucket, so
|
|
300
|
+
// save/publish never ships large base64 through the Netlify function.
|
|
301
|
+
const localUrls = { primary: URL.createObjectURL(file), poster: URL.createObjectURL(posterBlob) };
|
|
302
|
+
await finalizePendingMedia(item, localUrls, { poster: posterBlob });
|
|
303
|
+
} catch (err) {
|
|
304
|
+
// The PUT already succeeded, so the original is sitting in the bucket
|
|
305
|
+
// with no pending item pointing at it — an orphan. Task 11's GC prunes
|
|
306
|
+
// unreferenced bucket objects; we don't attempt to delete it here.
|
|
307
|
+
onUploadError?.(`${file.name}: uploaded but couldn't finish processing — try again.`);
|
|
308
|
+
}
|
|
309
|
+
} catch (err) {
|
|
310
|
+
console.error(`[useMediaPipeline] uploadLargeVideo failed unexpectedly for "${file.name}":`, err);
|
|
311
|
+
onUploadError?.(`${file.name}: upload failed unexpectedly. Try again.`);
|
|
312
|
+
} finally {
|
|
313
|
+
if (hash !== undefined) {
|
|
314
|
+
const h = hash;
|
|
315
|
+
setUploadProgress((prev) => {
|
|
316
|
+
if (!(h in prev)) return prev;
|
|
317
|
+
const next = { ...prev };
|
|
318
|
+
delete next[h];
|
|
319
|
+
return next;
|
|
320
|
+
});
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
}, [mediaManifest, pendingMediaItems, pendingDeletions, onUploadError, finalizePendingMedia]);
|
|
324
|
+
|
|
182
325
|
const handleMediaUpload = useCallback(async (files: File[]) => {
|
|
183
326
|
if (!siteConfig) return;
|
|
184
327
|
const mediaConfig = siteConfig.media;
|
|
@@ -186,7 +329,26 @@ export function useMediaPipeline({
|
|
|
186
329
|
if (!queue) return;
|
|
187
330
|
|
|
188
331
|
for (const file of files) {
|
|
189
|
-
if (file.size > mediaConfig.maxFileSize)
|
|
332
|
+
if (file.size > mediaConfig.maxFileSize) {
|
|
333
|
+
if (file.type === "video/mp4" || file.type === "video/webm") {
|
|
334
|
+
// uploadLargeVideo already guarantees it never throws (see its own
|
|
335
|
+
// try/catch/finally) and reports failures via onUploadError itself;
|
|
336
|
+
// this catch is defense-in-depth only, so it must NOT call
|
|
337
|
+
// onUploadError again — doing so would double-report.
|
|
338
|
+
try {
|
|
339
|
+
await uploadLargeVideo(file, mediaConfig);
|
|
340
|
+
} catch (err) {
|
|
341
|
+
console.error(`[useMediaPipeline] uploadLargeVideo threw unexpectedly for "${file.name}":`, err);
|
|
342
|
+
}
|
|
343
|
+
} else {
|
|
344
|
+
onUploadError?.(
|
|
345
|
+
file.type.startsWith("video/")
|
|
346
|
+
? `${file.name}: only MP4/WebM videos can be uploaded.`
|
|
347
|
+
: `${file.name} is larger than the ${Math.round(mediaConfig.maxFileSize / 1024 / 1024)}MB media limit.`,
|
|
348
|
+
);
|
|
349
|
+
}
|
|
350
|
+
continue;
|
|
351
|
+
}
|
|
190
352
|
|
|
191
353
|
const buffer = await file.arrayBuffer();
|
|
192
354
|
const hash = await hashFileBuffer(buffer);
|
|
@@ -202,7 +364,7 @@ export function useMediaPipeline({
|
|
|
202
364
|
|
|
203
365
|
queue.add({ buffer, originalName: file.name, mimeType: file.type, hash, kind });
|
|
204
366
|
}
|
|
205
|
-
}, [siteConfig, mediaManifest, pendingMediaItems, pendingDeletions]);
|
|
367
|
+
}, [siteConfig, mediaManifest, pendingMediaItems, pendingDeletions, onUploadError, uploadLargeVideo]);
|
|
206
368
|
|
|
207
369
|
const handleMediaDelete = useCallback(async (ids: string[]) => {
|
|
208
370
|
const idSet = new Set(ids);
|
|
@@ -348,6 +510,7 @@ export function useMediaPipeline({
|
|
|
348
510
|
setPendingDeletions,
|
|
349
511
|
manifestDirty,
|
|
350
512
|
setManifestDirty,
|
|
513
|
+
uploadProgress,
|
|
351
514
|
handleMediaUpload,
|
|
352
515
|
handleMediaDelete,
|
|
353
516
|
handleMediaAltChange,
|
package/src/lib/registry.ts
CHANGED
|
@@ -3,6 +3,7 @@ import type { ComponentType, ReactNode } from "react";
|
|
|
3
3
|
import type { Audience } from "../schemas/auth";
|
|
4
4
|
import type { LinkValue } from "../schemas/link";
|
|
5
5
|
import type { SectionContent } from "../schemas/sections";
|
|
6
|
+
import type { VideoSource } from "../components/sections/Video/VideoBlock";
|
|
6
7
|
|
|
7
8
|
// --- Settings field types ---
|
|
8
9
|
|
|
@@ -55,6 +56,12 @@ export type SettingsFieldDef =
|
|
|
55
56
|
label: string;
|
|
56
57
|
default: LinkValue;
|
|
57
58
|
target?: "content" | "options";
|
|
59
|
+
}
|
|
60
|
+
| {
|
|
61
|
+
type: "video-source";
|
|
62
|
+
label: string;
|
|
63
|
+
default: VideoSource;
|
|
64
|
+
target?: "content" | "options";
|
|
58
65
|
};
|
|
59
66
|
|
|
60
67
|
export type SettingsSchema = Record<string, SettingsFieldDef>;
|