@drawnagency/primitives 0.1.88 → 0.1.90
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-75P6HWPN.js → chunk-5BM2Y3NJ.js} +5 -3
- package/dist/{chunk-OEL57NYV.js → chunk-AGWE2LJG.js} +26 -2
- package/dist/{chunk-DRCPDOW3.js → chunk-CZ3YXGK5.js} +59 -0
- package/dist/{chunk-PTVY3Q7P.js → chunk-KBQNJZUX.js} +1 -1
- package/dist/{chunk-XPUBUR64.js → chunk-KMQZYORS.js} +28 -2
- package/dist/{chunk-WA3HFN54.js → chunk-MK7YB2A4.js} +1 -1
- package/dist/{chunk-EO7K45GV.js → chunk-NV4R6VHN.js} +1 -1
- package/dist/{chunk-XDG2K6UT.js → chunk-SIYQ37QY.js} +13 -5
- package/dist/{chunk-NEDOJHHR.js → chunk-XKRQMTE4.js} +11 -2
- package/dist/{chunk-SK6GGTTO.js → chunk-XUIJHYKI.js} +3 -3
- package/dist/{chunk-H2FENAQH.js → chunk-ZLG7S54V.js} +242 -83
- package/dist/components/chat/ChatPanel.d.ts +20 -3
- package/dist/components/chat/ChatPanel.d.ts.map +1 -1
- package/dist/components/chat/ChatWidget.d.ts +7 -1
- package/dist/components/chat/ChatWidget.d.ts.map +1 -1
- package/dist/components/chat/chat-history.d.ts +24 -0
- package/dist/components/chat/chat-history.d.ts.map +1 -0
- package/dist/components/editor/AudienceIndicator.d.ts +6 -0
- package/dist/components/editor/AudienceIndicator.d.ts.map +1 -1
- package/dist/components/editor/ChildBlockWrapper.d.ts.map +1 -1
- package/dist/components/editor/ColSpanControl.d.ts.map +1 -1
- package/dist/components/editor/DragHandle.d.ts +3 -1
- package/dist/components/editor/DragHandle.d.ts.map +1 -1
- package/dist/components/editor/SectionWrapper.d.ts.map +1 -1
- package/dist/components/editor/StatusIndicator.d.ts +7 -0
- package/dist/components/editor/StatusIndicator.d.ts.map +1 -1
- package/dist/components/editor/index.js +100 -78
- package/dist/components/primitives/index.js +5 -5
- package/dist/components/sections/Container/Container.d.ts +3 -2
- package/dist/components/sections/Container/Container.d.ts.map +1 -1
- package/dist/components/sections/Container/ContainerSettingsForm.d.ts +4 -2
- package/dist/components/sections/Container/ContainerSettingsForm.d.ts.map +1 -1
- package/dist/components/sections/Container/index.d.ts +8 -2
- package/dist/components/sections/Container/index.d.ts.map +1 -1
- package/dist/components/sections/SectionLayout.d.ts +3 -1
- package/dist/components/sections/SectionLayout.d.ts.map +1 -1
- package/dist/components/sections/ViewRenderer.d.ts +3 -1
- package/dist/components/sections/ViewRenderer.d.ts.map +1 -1
- package/dist/components/sections/all-sections.d.ts +8 -2
- package/dist/components/sections/all-sections.d.ts.map +1 -1
- package/dist/components/sections/brand-guide.js +5 -5
- package/dist/components/sections/core-sections.d.ts +8 -2
- package/dist/components/sections/core-sections.d.ts.map +1 -1
- package/dist/components/sections/register-schemas.js +8 -8
- package/dist/components/sections/register.js +8 -8
- package/dist/components/shared/Navigation.d.ts +6 -1
- package/dist/components/shared/Navigation.d.ts.map +1 -1
- package/dist/components/shared/Tooltip.d.ts +8 -1
- package/dist/components/shared/Tooltip.d.ts.map +1 -1
- package/dist/components/shell/AudiencePreviewMenu.d.ts +7 -0
- package/dist/components/shell/AudiencePreviewMenu.d.ts.map +1 -0
- package/dist/components/shell/AuthoringDefaultsMenu.d.ts +10 -0
- package/dist/components/shell/AuthoringDefaultsMenu.d.ts.map +1 -0
- package/dist/components/shell/EditorContext.d.ts +3 -0
- package/dist/components/shell/EditorContext.d.ts.map +1 -1
- package/dist/components/shell/EditorShell.d.ts.map +1 -1
- package/dist/components/shell/MediaLibraryContext.d.ts +3 -1
- package/dist/components/shell/MediaLibraryContext.d.ts.map +1 -1
- package/dist/components/shell/SiteSettingsChatbot.d.ts.map +1 -1
- package/dist/hooks/useBuildStatus.d.ts.map +1 -1
- package/dist/hooks/useMediaPipeline.d.ts +5 -1
- package/dist/hooks/useMediaPipeline.d.ts.map +1 -1
- package/dist/index.js +17 -5
- package/dist/lib/authoring-defaults.d.ts +17 -0
- package/dist/lib/authoring-defaults.d.ts.map +1 -0
- package/dist/lib/chatbot.d.ts +10 -0
- package/dist/lib/chatbot.d.ts.map +1 -0
- package/dist/lib/container-grid.d.ts +6 -0
- package/dist/lib/container-grid.d.ts.map +1 -1
- package/dist/lib/contrast.d.ts +5 -0
- package/dist/lib/contrast.d.ts.map +1 -1
- package/dist/lib/index.d.ts +2 -1
- package/dist/lib/index.d.ts.map +1 -1
- package/dist/lib/index.js +11 -3
- package/dist/lib/migrate-sections-transform.js +1 -1
- package/dist/media/compose-preview.d.ts +23 -0
- package/dist/media/compose-preview.d.ts.map +1 -0
- package/dist/media/index.d.ts +1 -0
- package/dist/media/index.d.ts.map +1 -1
- package/dist/media/index.js +4 -2
- package/dist/media/preview-inputs.d.ts +31 -0
- package/dist/media/preview-inputs.d.ts.map +1 -0
- package/dist/schemas/index.js +4 -2
- package/dist/schemas/media.d.ts +2 -0
- package/dist/schemas/media.d.ts.map +1 -1
- package/dist/schemas/site-config.d.ts +18 -0
- package/dist/schemas/site-config.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/components/chat/ChatPanel.tsx +437 -65
- package/src/components/chat/ChatWidget.tsx +90 -32
- package/src/components/chat/chat-history.ts +105 -0
- package/src/components/editor/AudienceIndicator.tsx +50 -37
- package/src/components/editor/ChildBlockWrapper.tsx +8 -7
- package/src/components/editor/ColSpanControl.tsx +14 -11
- package/src/components/editor/DragHandle.tsx +5 -2
- package/src/components/editor/PagesModal.tsx +10 -10
- package/src/components/editor/SectionWrapper.tsx +32 -21
- package/src/components/editor/StatusIndicator.tsx +37 -24
- package/src/components/sections/Container/Container.tsx +185 -43
- package/src/components/sections/Container/ContainerSettingsForm.tsx +64 -24
- package/src/components/sections/Container/index.tsx +45 -20
- package/src/components/sections/SectionLayout.tsx +13 -3
- package/src/components/sections/ViewRenderer.tsx +11 -2
- package/src/components/shared/Navigation.tsx +18 -5
- package/src/components/shared/Tooltip.tsx +18 -2
- package/src/components/shell/AudiencePreviewMenu.tsx +73 -0
- package/src/components/shell/AuthoringDefaultsMenu.tsx +54 -0
- package/src/components/shell/EditorContext.tsx +5 -1
- package/src/components/shell/EditorShell.tsx +130 -13
- package/src/components/shell/MediaLibraryContext.tsx +1 -1
- package/src/components/shell/SiteSettingsChatbot.tsx +49 -1
- package/src/hooks/useBuildStatus.ts +35 -4
- package/src/hooks/useMediaPipeline.ts +7 -3
- package/src/lib/authoring-defaults.ts +42 -0
- package/src/lib/chatbot.ts +30 -0
- package/src/lib/container-grid.ts +14 -0
- package/src/lib/contrast.ts +12 -0
- package/src/lib/index.ts +2 -1
- package/src/lib/migrate-sections-transform.ts +5 -3
- package/src/media/compose-preview.ts +90 -0
- package/src/media/index.ts +1 -0
- package/src/media/preview-inputs.ts +77 -0
- package/src/schemas/media.ts +3 -0
- package/src/schemas/site-config.ts +27 -2
|
@@ -1,10 +1,22 @@
|
|
|
1
1
|
import { useEffect, useState } from "react";
|
|
2
2
|
import { Button } from "../shared/Button";
|
|
3
3
|
import { cn } from "../../lib/cn";
|
|
4
|
+
import { contrastColor } from "../../lib/contrast";
|
|
4
5
|
import ChatPanel from "../chat/ChatPanel";
|
|
5
6
|
import type { SiteConfig, ChatbotConfig, ChatbotTask } from "../../schemas/site-config";
|
|
6
7
|
import type { Audience } from "../../auth/types";
|
|
7
8
|
import { audiencePasses } from "../../lib/pages";
|
|
9
|
+
import { resolveChatbotName, DEFAULT_CHATBOT_NAME } from "../../lib/chatbot";
|
|
10
|
+
|
|
11
|
+
/** Comma-separated textarea → schema list: trimmed, blanks dropped, clamped
|
|
12
|
+
* to the schema caps (30 phrases × 80 chars). */
|
|
13
|
+
function parseThinkingPhrases(text: string): string[] {
|
|
14
|
+
return text
|
|
15
|
+
.split(",")
|
|
16
|
+
.map((s) => s.trim().slice(0, 80))
|
|
17
|
+
.filter(Boolean)
|
|
18
|
+
.slice(0, 30);
|
|
19
|
+
}
|
|
8
20
|
|
|
9
21
|
interface Props {
|
|
10
22
|
siteConfig: SiteConfig;
|
|
@@ -24,7 +36,7 @@ function AudienceChips({ audiences, selected, onToggle }: { audiences: Audience[
|
|
|
24
36
|
<button key={a.name} type="button" onClick={() => onToggle(a.name)}
|
|
25
37
|
className={cn("rounded-full border px-2 py-0.5 text-xs",
|
|
26
38
|
selected.includes(a.name) ? "border-primary bg-primary text-primary-contrast" : "border-base-200 bg-base-accent text-base-contrast")}
|
|
27
|
-
style={a.color && selected.includes(a.name) ? { backgroundColor: a.color, borderColor: a.color } : undefined}>
|
|
39
|
+
style={a.color && selected.includes(a.name) ? { backgroundColor: a.color, borderColor: a.color, color: contrastColor(a.color) } : undefined}>
|
|
28
40
|
{a.displayName}
|
|
29
41
|
</button>
|
|
30
42
|
))}
|
|
@@ -38,6 +50,9 @@ export function SiteSettingsChatbot({ siteConfig, onSiteConfigChange, audiences,
|
|
|
38
50
|
const [newTaskLabel, setNewTaskLabel] = useState("");
|
|
39
51
|
const [showTest, setShowTest] = useState(false);
|
|
40
52
|
const [previewAudience, setPreviewAudience] = useState("");
|
|
53
|
+
// Local text mirror of thinkingPhrases: parsing on every keystroke while
|
|
54
|
+
// round-tripping through the array would fight trailing commas/spaces.
|
|
55
|
+
const [thinkingText, setThinkingText] = useState(() => (chatbot.thinkingPhrases ?? []).join(", "));
|
|
41
56
|
|
|
42
57
|
// A deselected audience can no longer chat — never keep simulating it.
|
|
43
58
|
useEffect(() => {
|
|
@@ -77,6 +92,10 @@ export function SiteSettingsChatbot({ siteConfig, onSiteConfigChange, audiences,
|
|
|
77
92
|
const v = chatbot.audiencePrompts[name];
|
|
78
93
|
return typeof v === "string" ? v : "";
|
|
79
94
|
};
|
|
95
|
+
const audienceNameValue = (name: string) => {
|
|
96
|
+
const v = chatbot.audienceNames?.[name];
|
|
97
|
+
return typeof v === "string" ? v : "";
|
|
98
|
+
};
|
|
80
99
|
|
|
81
100
|
return (
|
|
82
101
|
<div className="flex flex-col gap-4">
|
|
@@ -94,12 +113,39 @@ export function SiteSettingsChatbot({ siteConfig, onSiteConfigChange, audiences,
|
|
|
94
113
|
Show the chat widget to viewers
|
|
95
114
|
</label>
|
|
96
115
|
|
|
116
|
+
<div>
|
|
117
|
+
<label htmlFor="chatbot-name" className="mb-1 block text-sm font-medium text-base-contrast">Assistant name</label>
|
|
118
|
+
<input id="chatbot-name" aria-label="Assistant name" maxLength={60} value={chatbot.name}
|
|
119
|
+
onChange={(e) => patch({ name: e.target.value })}
|
|
120
|
+
placeholder={DEFAULT_CHATBOT_NAME}
|
|
121
|
+
className="w-full rounded border border-base-200 bg-base px-2 py-1 text-sm text-base-contrast" />
|
|
122
|
+
<p className="mt-1 text-xs text-base-contrast-light">Shown in the widget header. Leave blank for “{DEFAULT_CHATBOT_NAME}”. Audiences can override it below.</p>
|
|
123
|
+
</div>
|
|
124
|
+
|
|
125
|
+
<div>
|
|
126
|
+
<label htmlFor="chatbot-thinking-phrases" className="mb-1 block text-sm font-medium text-base-contrast">Thinking phrases</label>
|
|
127
|
+
<textarea id="chatbot-thinking-phrases" aria-label="Thinking phrases" rows={2}
|
|
128
|
+
value={thinkingText}
|
|
129
|
+
onChange={(e) => { setThinkingText(e.target.value); patch({ thinkingPhrases: parseThinkingPhrases(e.target.value) }); }}
|
|
130
|
+
placeholder="Mixing the palette, Checking the guide, Consulting the brand book"
|
|
131
|
+
className="w-full rounded border border-base-200 bg-base px-2 py-1 text-sm text-base-contrast" />
|
|
132
|
+
<p className="mt-1 text-xs text-base-contrast-light">Comma-separated; one is shown at random while the assistant thinks, with “…” added automatically. Leave blank for “Thinking…”.</p>
|
|
133
|
+
</div>
|
|
134
|
+
|
|
97
135
|
<div>
|
|
98
136
|
<p className="mb-1 text-sm font-medium text-base-contrast">Audiences</p>
|
|
99
137
|
<p className="mb-2 text-xs text-base-contrast-light">Who sees the widget — only the selected audiences; none selected means no one.</p>
|
|
100
138
|
<AudienceChips audiences={audiences} selected={chatbot.audiences} onToggle={(n) => patch({ audiences: toggleName(chatbot.audiences, n) })} />
|
|
101
139
|
{chatbot.audiences.map((name) => (
|
|
102
140
|
<div key={name} className="mt-2">
|
|
141
|
+
<label htmlFor={`audience-name-${name}`} className="mb-1 block text-xs font-medium text-base-contrast">
|
|
142
|
+
Assistant name for {name}
|
|
143
|
+
</label>
|
|
144
|
+
<input id={`audience-name-${name}`} aria-label={`Assistant name for ${name}`} maxLength={60}
|
|
145
|
+
value={audienceNameValue(name)}
|
|
146
|
+
onChange={(e) => patch({ audienceNames: { ...chatbot.audienceNames, [name]: e.target.value } })}
|
|
147
|
+
placeholder={resolveChatbotName(chatbot, null)}
|
|
148
|
+
className="mb-1 w-full rounded border border-base-200 bg-base px-2 py-1 text-sm text-base-contrast" />
|
|
103
149
|
<label htmlFor={`audience-prompt-${name}`} className="mb-1 block text-xs font-medium text-base-contrast">
|
|
104
150
|
Additional guidance for {name}
|
|
105
151
|
</label>
|
|
@@ -175,6 +221,8 @@ export function SiteSettingsChatbot({ siteConfig, onSiteConfigChange, audiences,
|
|
|
175
221
|
{showTest && platformEnabled && (
|
|
176
222
|
<ChatPanel
|
|
177
223
|
open onClose={() => setShowTest(false)} siteName={siteConfig.siteName}
|
|
224
|
+
botName={resolveChatbotName(chatbot, previewAudience || null)}
|
|
225
|
+
thinkingPhrases={chatbot.thinkingPhrases}
|
|
178
226
|
tasks={chatbot.tasks
|
|
179
227
|
.filter((t) => previewAudience === "" || audiencePasses(t.access, previewAudience))
|
|
180
228
|
.map((t) => ({ id: t.id, label: t.label }))}
|
|
@@ -23,6 +23,14 @@ const FADE_DURATION = 1000;
|
|
|
23
23
|
// whose deploy was never confirmed (e.g. superseded by a later build) — show
|
|
24
24
|
// idle rather than a phantom in-progress publish.
|
|
25
25
|
const STALE_BUILD_MS = 15 * 60 * 1000;
|
|
26
|
+
// Hard cap on an ACTIVE tracking session. Completion normally arrives via a terminal
|
|
27
|
+
// poll (ready/error), which the Netlify webhook writes to the deploy-status row. If
|
|
28
|
+
// that signal is lost/delayed (webhook dropped or unconfigured) the row stays
|
|
29
|
+
// "building" forever and the elapsed counter would climb indefinitely. This backstop
|
|
30
|
+
// stops the runaway counter and hides the indicator after the cap — the same "a build
|
|
31
|
+
// shouldn't run longer than this" invariant as STALE_BUILD_MS, applied to the live
|
|
32
|
+
// session rather than only on mount.
|
|
33
|
+
const MAX_TRACKING_MS = STALE_BUILD_MS;
|
|
26
34
|
|
|
27
35
|
export function useBuildStatus(): BuildStatusResult {
|
|
28
36
|
const [state, setState] = useState<BuildState>("idle");
|
|
@@ -31,6 +39,7 @@ export function useBuildStatus(): BuildStatusResult {
|
|
|
31
39
|
const clearRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
32
40
|
const fadeRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
33
41
|
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
|
42
|
+
const giveUpRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
34
43
|
const isPolling = useRef(false);
|
|
35
44
|
const mountedRef = useRef(true);
|
|
36
45
|
|
|
@@ -52,6 +61,23 @@ export function useBuildStatus(): BuildStatusResult {
|
|
|
52
61
|
isPolling.current = false;
|
|
53
62
|
}, []);
|
|
54
63
|
|
|
64
|
+
const clearGiveUp = useCallback(() => {
|
|
65
|
+
if (giveUpRef.current) { clearTimeout(giveUpRef.current); giveUpRef.current = null; }
|
|
66
|
+
}, []);
|
|
67
|
+
|
|
68
|
+
// Arm the runaway backstop: if the cap elapses before a terminal signal, stop
|
|
69
|
+
// polling + the timer and drop back to idle (hide the indicator). `elapsedMs` lets a
|
|
70
|
+
// build already in progress at mount count against the same absolute cap.
|
|
71
|
+
const armGiveUp = useCallback((elapsedMs = 0) => {
|
|
72
|
+
clearGiveUp();
|
|
73
|
+
giveUpRef.current = setTimeout(() => {
|
|
74
|
+
if (!mountedRef.current) return;
|
|
75
|
+
stopPolling();
|
|
76
|
+
stopTimer();
|
|
77
|
+
setState("idle");
|
|
78
|
+
}, Math.max(0, MAX_TRACKING_MS - elapsedMs));
|
|
79
|
+
}, [clearGiveUp, stopPolling, stopTimer]);
|
|
80
|
+
|
|
55
81
|
const fetchStatus = useCallback(async (): Promise<BuildStatusResponse | null> => {
|
|
56
82
|
try {
|
|
57
83
|
const res = await fetch("/api/build-status");
|
|
@@ -86,6 +112,7 @@ export function useBuildStatus(): BuildStatusResult {
|
|
|
86
112
|
if (data.state === "ready" || data.state === "error") {
|
|
87
113
|
stopPolling();
|
|
88
114
|
stopTimer();
|
|
115
|
+
clearGiveUp();
|
|
89
116
|
if (data.state === "ready") {
|
|
90
117
|
clearRef.current = setTimeout(() => {
|
|
91
118
|
if (!mountedRef.current) return;
|
|
@@ -98,7 +125,7 @@ export function useBuildStatus(): BuildStatusResult {
|
|
|
98
125
|
}
|
|
99
126
|
}
|
|
100
127
|
},
|
|
101
|
-
[stopPolling, stopTimer],
|
|
128
|
+
[stopPolling, stopTimer, clearGiveUp],
|
|
102
129
|
);
|
|
103
130
|
|
|
104
131
|
const startPolling = useCallback(() => {
|
|
@@ -130,6 +157,7 @@ export function useBuildStatus(): BuildStatusResult {
|
|
|
130
157
|
// counts real elapsed time instead of sitting frozen at 0:00.
|
|
131
158
|
startTimer(Number.isFinite(ageMs) ? Math.max(0, Math.floor(ageMs / 1000)) : 0);
|
|
132
159
|
startPolling();
|
|
160
|
+
armGiveUp(Number.isFinite(ageMs) ? ageMs : 0);
|
|
133
161
|
return;
|
|
134
162
|
}
|
|
135
163
|
|
|
@@ -143,24 +171,27 @@ export function useBuildStatus(): BuildStatusResult {
|
|
|
143
171
|
mountedRef.current = false;
|
|
144
172
|
stopPolling();
|
|
145
173
|
stopTimer();
|
|
174
|
+
clearGiveUp();
|
|
146
175
|
if (clearRef.current) clearTimeout(clearRef.current);
|
|
147
176
|
if (fadeRef.current) clearTimeout(fadeRef.current);
|
|
148
177
|
};
|
|
149
|
-
}, [fetchStatus, handleStatusUpdate, startPolling, startTimer, stopPolling, stopTimer]);
|
|
178
|
+
}, [fetchStatus, handleStatusUpdate, startPolling, startTimer, stopPolling, stopTimer, armGiveUp, clearGiveUp]);
|
|
150
179
|
|
|
151
180
|
const startTracking = useCallback(() => {
|
|
152
181
|
if (clearRef.current) { clearTimeout(clearRef.current); clearRef.current = null; }
|
|
153
182
|
setState("building");
|
|
154
183
|
startTimer();
|
|
155
184
|
startPolling();
|
|
156
|
-
|
|
185
|
+
armGiveUp(0);
|
|
186
|
+
}, [startPolling, startTimer, armGiveUp]);
|
|
157
187
|
|
|
158
188
|
const dismiss = useCallback(() => {
|
|
159
189
|
if (clearRef.current) { clearTimeout(clearRef.current); clearRef.current = null; }
|
|
160
190
|
setState("idle");
|
|
161
191
|
stopPolling();
|
|
162
192
|
stopTimer();
|
|
163
|
-
|
|
193
|
+
clearGiveUp();
|
|
194
|
+
}, [stopPolling, stopTimer, clearGiveUp]);
|
|
164
195
|
|
|
165
196
|
return { state, elapsedSeconds, startTracking, dismiss };
|
|
166
197
|
}
|
|
@@ -76,6 +76,7 @@ export function useMediaPipeline({
|
|
|
76
76
|
// Display-folder target per in-flight upload, keyed by content hash. Set at
|
|
77
77
|
// enqueue time, consumed when the processing queue finalizes the item.
|
|
78
78
|
const uploadTargetsRef = useRef<Map<string, string | null>>(new Map());
|
|
79
|
+
const hiddenUploadsRef = useRef<Map<string, boolean>>(new Map());
|
|
79
80
|
const destroyedRef = useRef(false);
|
|
80
81
|
|
|
81
82
|
// Shared persistence path for every pending media item — the processing
|
|
@@ -171,10 +172,12 @@ export function useMediaPipeline({
|
|
|
171
172
|
alt: "",
|
|
172
173
|
storage: "github",
|
|
173
174
|
...(targetFolderId ? { folderId: targetFolderId } : {}),
|
|
175
|
+
...(hiddenUploadsRef.current.get(event.item.hash) ? { hidden: true } : {}),
|
|
174
176
|
};
|
|
175
177
|
|
|
176
178
|
const persisted = await finalizePendingMedia(item, localUrls, blobsMap);
|
|
177
179
|
uploadTargetsRef.current.delete(event.item.hash);
|
|
180
|
+
hiddenUploadsRef.current.delete(event.item.hash);
|
|
178
181
|
if (!persisted) return;
|
|
179
182
|
|
|
180
183
|
const callback = uploadCallbacksRef.current.get(event.item.hash);
|
|
@@ -603,7 +606,7 @@ export function useMediaPipeline({
|
|
|
603
606
|
|
|
604
607
|
// --- Unified enqueue ---
|
|
605
608
|
|
|
606
|
-
const enqueueFile = useCallback((file: File, onComplete?: (imageId: string) => void) => {
|
|
609
|
+
const enqueueFile = useCallback((file: File, onComplete?: (imageId: string) => void, opts?: { hidden?: boolean }) => {
|
|
607
610
|
if (!siteConfig) return;
|
|
608
611
|
const mediaConfig = siteConfig.media;
|
|
609
612
|
if (file.size > mediaConfig.maxFileSize) return;
|
|
@@ -627,6 +630,7 @@ export function useMediaPipeline({
|
|
|
627
630
|
if (onComplete) {
|
|
628
631
|
uploadCallbacksRef.current.set(hash, onComplete);
|
|
629
632
|
}
|
|
633
|
+
if (opts?.hidden) hiddenUploadsRef.current.set(hash, true);
|
|
630
634
|
|
|
631
635
|
let kind: "image" | "animated" | "video" = "image";
|
|
632
636
|
if (file.type === "image/gif" || file.type === "image/apng") kind = "animated";
|
|
@@ -646,12 +650,12 @@ export function useMediaPipeline({
|
|
|
646
650
|
// Placeholder — EditorShell overrides this
|
|
647
651
|
},
|
|
648
652
|
uploadFile: (file: File) => handleMediaUpload([file]),
|
|
649
|
-
uploadFileWithCallback: (file: File, onComplete: (imageId: string) => void) => {
|
|
653
|
+
uploadFileWithCallback: (file: File, onComplete: (imageId: string) => void, opts?: { hidden?: boolean }) => {
|
|
650
654
|
const blobUrl = URL.createObjectURL(file);
|
|
651
655
|
enqueueFile(file, (imageId) => {
|
|
652
656
|
URL.revokeObjectURL(blobUrl);
|
|
653
657
|
onComplete(imageId);
|
|
654
|
-
});
|
|
658
|
+
}, opts);
|
|
655
659
|
return blobUrl;
|
|
656
660
|
},
|
|
657
661
|
reportRejectedUploads: () => {
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { StatusSchema } from "../schemas/site-config";
|
|
3
|
+
import type { Audience } from "../auth/types";
|
|
4
|
+
|
|
5
|
+
export const AuthoringDefaultsSchema = z.object({
|
|
6
|
+
status: StatusSchema,
|
|
7
|
+
access: z.array(z.string()),
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
export type AuthoringDefaults = z.infer<typeof AuthoringDefaultsSchema>;
|
|
11
|
+
|
|
12
|
+
export const FALLBACK_AUTHORING_DEFAULTS: AuthoringDefaults = { status: "draft", access: [] };
|
|
13
|
+
|
|
14
|
+
// Per-origin key — every client site is its own domain, so the preference is
|
|
15
|
+
// automatically per-site.
|
|
16
|
+
const STORAGE_KEY = "portal-authoring-defaults";
|
|
17
|
+
|
|
18
|
+
export function loadAuthoringDefaults(): AuthoringDefaults {
|
|
19
|
+
if (typeof localStorage === "undefined") return FALLBACK_AUTHORING_DEFAULTS;
|
|
20
|
+
try {
|
|
21
|
+
const raw = localStorage.getItem(STORAGE_KEY);
|
|
22
|
+
if (!raw) return FALLBACK_AUTHORING_DEFAULTS;
|
|
23
|
+
return AuthoringDefaultsSchema.parse(JSON.parse(raw));
|
|
24
|
+
} catch {
|
|
25
|
+
return FALLBACK_AUTHORING_DEFAULTS;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function saveAuthoringDefaults(defaults: AuthoringDefaults): void {
|
|
30
|
+
if (typeof localStorage === "undefined") return;
|
|
31
|
+
try {
|
|
32
|
+
localStorage.setItem(STORAGE_KEY, JSON.stringify(defaults));
|
|
33
|
+
} catch {
|
|
34
|
+
// Quota/private-mode failures just lose the preference.
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Stored default audiences can outlive the audiences themselves — drop any
|
|
39
|
+
// name the site no longer has before stamping it onto a new section's access.
|
|
40
|
+
export function filterAccessToKnown(access: string[], audiences: Audience[]): string[] {
|
|
41
|
+
return access.filter((name) => audiences.some((a) => a.name === name));
|
|
42
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
// Pure, browser-safe chatbot display helpers shared by the viewer page
|
|
2
|
+
// (server-side name resolution), brand-chat prompt assembly, and the widget.
|
|
3
|
+
import type { ChatbotConfig } from "../schemas/site-config";
|
|
4
|
+
|
|
5
|
+
export const DEFAULT_CHATBOT_NAME = "Portal Assistant";
|
|
6
|
+
|
|
7
|
+
/** Resolve the assistant's display name for an audience (null = base name).
|
|
8
|
+
* Record lookups can hit inherited Object.prototype members ("constructor"
|
|
9
|
+
* is a legal audience name) — anything non-string, and blank strings,
|
|
10
|
+
* behave as absent. */
|
|
11
|
+
export function resolveChatbotName(
|
|
12
|
+
// Partial: pre-migration configs (and test fixtures) may predate the fields.
|
|
13
|
+
chatbot: Partial<Pick<ChatbotConfig, "name" | "audienceNames">>,
|
|
14
|
+
audience: string | null,
|
|
15
|
+
): string {
|
|
16
|
+
const perAudience = audience !== null ? chatbot.audienceNames?.[audience] : undefined;
|
|
17
|
+
if (typeof perAudience === "string" && perAudience.trim()) return perAudience.trim();
|
|
18
|
+
if (typeof chatbot.name === "string" && chatbot.name.trim()) return chatbot.name.trim();
|
|
19
|
+
return DEFAULT_CHATBOT_NAME;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** The widget's waiting phrases with "…" appended; falls back to "Thinking…". */
|
|
23
|
+
export function resolveThinkingPhrases(phrases: readonly string[] | undefined): string[] {
|
|
24
|
+
const list = (phrases ?? [])
|
|
25
|
+
.filter((p): p is string => typeof p === "string")
|
|
26
|
+
.map((p) => p.trim())
|
|
27
|
+
.filter(Boolean)
|
|
28
|
+
.map((p) => `${p}…`);
|
|
29
|
+
return list.length > 0 ? list : ["Thinking…"];
|
|
30
|
+
}
|
|
@@ -42,6 +42,20 @@ export const gapClass: Record<string, string> = {
|
|
|
42
42
|
lg: "gap-8",
|
|
43
43
|
};
|
|
44
44
|
|
|
45
|
+
/**
|
|
46
|
+
* Column count -> `columns-N` classes for masonry (CSS multi-column), mirroring
|
|
47
|
+
* `containerGridClass`'s collapse curve (`grid-cols-`→`columns-`). Full static
|
|
48
|
+
* strings so Tailwind's source scan generates them.
|
|
49
|
+
*/
|
|
50
|
+
export const masonryColumnsClass: Record<number, string> = {
|
|
51
|
+
1: "columns-1",
|
|
52
|
+
2: "columns-1 @sm:columns-2",
|
|
53
|
+
3: "columns-1 @sm:columns-2 @lg:columns-3",
|
|
54
|
+
4: "columns-1 @xs:columns-2 @lg:columns-4",
|
|
55
|
+
5: "columns-1 @xs:columns-2 @md:columns-3 @xl:columns-5",
|
|
56
|
+
6: "columns-1 @xs:columns-2 @md:columns-3 @2xl:columns-6",
|
|
57
|
+
};
|
|
58
|
+
|
|
45
59
|
/**
|
|
46
60
|
* Per-column-count container-query classes that HIDE a spacer cell once the container
|
|
47
61
|
* collapses to a single column (mobile). Mirrors `containerGridClass`'s collapse curve:
|
package/src/lib/contrast.ts
CHANGED
|
@@ -9,3 +9,15 @@ export function deriveContrast(hex: string): string {
|
|
|
9
9
|
const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255;
|
|
10
10
|
return luminance >= 0.5 ? "#1a1a1a" : "#f0f0f0";
|
|
11
11
|
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Perceived-luminance contrast pick (same YIQ formula the admin dashboard
|
|
15
|
+
* uses). Returns near-black for light backgrounds, white for dark ones.
|
|
16
|
+
*/
|
|
17
|
+
export function contrastColor(hex: string): "#1a1a1a" | "#ffffff" {
|
|
18
|
+
const h = hex.replace("#", "");
|
|
19
|
+
const r = parseInt(h.slice(0, 2), 16);
|
|
20
|
+
const g = parseInt(h.slice(2, 4), 16);
|
|
21
|
+
const b = parseInt(h.slice(4, 6), 16);
|
|
22
|
+
return (r * 299 + g * 587 + b * 114) / 1000 > 150 ? "#1a1a1a" : "#ffffff";
|
|
23
|
+
}
|
package/src/lib/index.ts
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
export { env } from "./env";
|
|
2
2
|
export { cn } from "./cn";
|
|
3
3
|
export { audiencePasses } from "./pages";
|
|
4
|
+
export { resolveChatbotName, resolveThinkingPhrases, DEFAULT_CHATBOT_NAME } from "./chatbot";
|
|
4
5
|
export { generateNavLinks, toSectionId, type NavItem } from "./nav";
|
|
5
|
-
export { deriveContrast } from "./contrast";
|
|
6
|
+
export { deriveContrast, contrastColor } from "./contrast";
|
|
6
7
|
export { sanitizeHtml, ensureSanitizer } from "./sanitize";
|
|
7
8
|
export { gridColsClass } from "./grid";
|
|
8
9
|
export { getIcon, curatedIcons, type IconEntry } from "./icons";
|
|
@@ -99,7 +99,8 @@ function upgradeGrid(section: Obj): Obj {
|
|
|
99
99
|
type: "container",
|
|
100
100
|
content: {
|
|
101
101
|
columns: content.columns,
|
|
102
|
-
|
|
102
|
+
layout: "grid",
|
|
103
|
+
equalizeRowHeight: false,
|
|
103
104
|
children: media.map((item, i) => mapRefItem(item, id, i)),
|
|
104
105
|
},
|
|
105
106
|
};
|
|
@@ -126,7 +127,7 @@ function upgradeSplitContent(section: Obj): Obj {
|
|
|
126
127
|
};
|
|
127
128
|
const children = options.imagePosition === "right" ? [proseChild, mediaChild] : [mediaChild, proseChild];
|
|
128
129
|
|
|
129
|
-
const out: Obj = { id, type: "container", content: { columns: 2,
|
|
130
|
+
const out: Obj = { id, type: "container", content: { columns: 2, layout: "grid", equalizeRowHeight: false, children } };
|
|
130
131
|
if (section.layout !== undefined) out.layout = section.layout;
|
|
131
132
|
return out;
|
|
132
133
|
}
|
|
@@ -163,7 +164,8 @@ function upgradeDoDont(section: Obj): Obj {
|
|
|
163
164
|
type: "container",
|
|
164
165
|
content: {
|
|
165
166
|
columns: 2,
|
|
166
|
-
|
|
167
|
+
layout: "grid",
|
|
168
|
+
equalizeRowHeight: false,
|
|
167
169
|
children: [
|
|
168
170
|
dodontChild(doItems, id, 0, "do", childOptions),
|
|
169
171
|
dodontChild(dontItems, id, 1, "dont", childOptions),
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
export interface PreviewImageInputs {
|
|
2
|
+
/** "logo" draws the resolved logo centered on a solid black/white bg;
|
|
3
|
+
* "text" draws the site name in the heading font on the solid brand color. */
|
|
4
|
+
mode: "logo" | "text";
|
|
5
|
+
/** Solid background color: black/white for logo mode, brand color for text mode. */
|
|
6
|
+
background: string;
|
|
7
|
+
/** Loadable URL of the chosen logo variant (logo mode only). */
|
|
8
|
+
logoUrl: string | null;
|
|
9
|
+
/** Apply a CSS invert filter to the logo (dark bg + invertInDark, no dark variant). */
|
|
10
|
+
invertLogo: boolean;
|
|
11
|
+
siteName: string;
|
|
12
|
+
headingFont: string;
|
|
13
|
+
/** Computed contrasting text color (text mode). */
|
|
14
|
+
textColor: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const WIDTH = 1200;
|
|
18
|
+
const HEIGHT = 630;
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Composes the 1200×630 social-preview raster in the browser (Canvas API only,
|
|
22
|
+
* no dependency). Returns a WebP blob. Must run client-side (uses document/Image
|
|
23
|
+
* and, for text mode, document.fonts). Falls back to the text treatment if the
|
|
24
|
+
* logo image fails to load.
|
|
25
|
+
*/
|
|
26
|
+
export async function composePreviewImage(inputs: PreviewImageInputs): Promise<Blob> {
|
|
27
|
+
const canvas = document.createElement("canvas");
|
|
28
|
+
canvas.width = WIDTH;
|
|
29
|
+
canvas.height = HEIGHT;
|
|
30
|
+
const ctx = canvas.getContext("2d");
|
|
31
|
+
if (!ctx) throw new Error("2D canvas context unavailable");
|
|
32
|
+
|
|
33
|
+
ctx.fillStyle = inputs.background;
|
|
34
|
+
ctx.fillRect(0, 0, WIDTH, HEIGHT);
|
|
35
|
+
|
|
36
|
+
let drewLogo = false;
|
|
37
|
+
if (inputs.mode === "logo" && inputs.logoUrl) {
|
|
38
|
+
try {
|
|
39
|
+
const img = await loadImage(inputs.logoUrl);
|
|
40
|
+
// An SVG (or any image) with no intrinsic size yields width/height 0, which
|
|
41
|
+
// would make scale Infinity and drawImage silently paint nothing while
|
|
42
|
+
// drewLogo stays true — a blank solid-color card with no text fallback.
|
|
43
|
+
// Throw here so control drops to the catch below and the text treatment runs.
|
|
44
|
+
if (!(img.width > 0 && img.height > 0)) {
|
|
45
|
+
throw new Error("Logo image has no intrinsic size");
|
|
46
|
+
}
|
|
47
|
+
const maxW = WIDTH * 0.6;
|
|
48
|
+
const maxH = HEIGHT * 0.5;
|
|
49
|
+
const scale = Math.min(maxW / img.width, maxH / img.height);
|
|
50
|
+
const w = img.width * scale;
|
|
51
|
+
const h = img.height * scale;
|
|
52
|
+
ctx.filter = inputs.invertLogo ? "invert(1)" : "none";
|
|
53
|
+
ctx.drawImage(img, (WIDTH - w) / 2, (HEIGHT - h) / 2, w, h);
|
|
54
|
+
ctx.filter = "none";
|
|
55
|
+
drewLogo = true;
|
|
56
|
+
} catch {
|
|
57
|
+
// fall through to text treatment
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (!drewLogo) {
|
|
62
|
+
if (document.fonts?.ready) await document.fonts.ready;
|
|
63
|
+
// fillText doesn't itself trigger a font load — explicitly load the exact
|
|
64
|
+
// weight/family used below so it's available before we draw.
|
|
65
|
+
await document.fonts.load(`600 84px ${inputs.headingFont}`).catch(() => {});
|
|
66
|
+
ctx.fillStyle = inputs.textColor;
|
|
67
|
+
ctx.textAlign = "center";
|
|
68
|
+
ctx.textBaseline = "middle";
|
|
69
|
+
ctx.font = `600 84px ${inputs.headingFont}, system-ui, sans-serif`;
|
|
70
|
+
ctx.fillText(inputs.siteName, WIDTH / 2, HEIGHT / 2, WIDTH * 0.86);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return await new Promise<Blob>((resolve, reject) => {
|
|
74
|
+
canvas.toBlob(
|
|
75
|
+
(blob) => (blob ? resolve(blob) : reject(new Error("toBlob returned null"))),
|
|
76
|
+
"image/webp",
|
|
77
|
+
0.9,
|
|
78
|
+
);
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function loadImage(url: string): Promise<HTMLImageElement> {
|
|
83
|
+
return new Promise((resolve, reject) => {
|
|
84
|
+
const img = new Image();
|
|
85
|
+
img.crossOrigin = "anonymous";
|
|
86
|
+
img.onload = () => resolve(img);
|
|
87
|
+
img.onerror = () => reject(new Error(`failed to load ${url}`));
|
|
88
|
+
img.src = url;
|
|
89
|
+
});
|
|
90
|
+
}
|
package/src/media/index.ts
CHANGED
|
@@ -6,6 +6,7 @@ export * from "./utils";
|
|
|
6
6
|
export * from "./queue";
|
|
7
7
|
export * from "./resolve";
|
|
8
8
|
export { generateVideoPoster } from "./videoPoster";
|
|
9
|
+
export { composePreviewImage, type PreviewImageInputs } from "./compose-preview";
|
|
9
10
|
|
|
10
11
|
const MEDIA_KEY = Symbol.for("@drawnagency/primitives/media");
|
|
11
12
|
const _g = globalThis as Record<symbol, MediaProvider | null>;
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import type { SiteConfig } from "../schemas/site-config";
|
|
2
|
+
import { contrastColor } from "../lib/contrast";
|
|
3
|
+
import type { PreviewImageInputs } from "./compose-preview";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Derives the canvas composition inputs from a SiteConfig, plus a signature
|
|
7
|
+
* string over only the composition-relevant fields (so the editor recomposes
|
|
8
|
+
* when brand inputs change but not for unrelated edits like `description`).
|
|
9
|
+
*
|
|
10
|
+
* `resolveLogoUrl` maps a logo imageId to a loadable URL (or null if it can't
|
|
11
|
+
* be resolved yet); the caller supplies it so this stays pure/testable.
|
|
12
|
+
*
|
|
13
|
+
* With-logo background/variant rules (static raster; darkMode "optional" bakes
|
|
14
|
+
* the light default since a baked image can't follow the viewer's theme):
|
|
15
|
+
* light | optional -> white bg + light variant
|
|
16
|
+
* dark -> black bg + (dark variant ?? invert(light) if invertInDark ?? light)
|
|
17
|
+
*/
|
|
18
|
+
export function derivePreviewInputs(
|
|
19
|
+
siteConfig: SiteConfig,
|
|
20
|
+
resolveLogoUrl: (imageId: string) => string | null,
|
|
21
|
+
): { inputs: PreviewImageInputs; signature: string } {
|
|
22
|
+
const { logo, darkMode, primaryColor, siteName, headingFont } = siteConfig;
|
|
23
|
+
const dark = darkMode === "dark";
|
|
24
|
+
|
|
25
|
+
let mode: "logo" | "text" = "text";
|
|
26
|
+
let logoUrl: string | null = null;
|
|
27
|
+
let invertLogo = false;
|
|
28
|
+
|
|
29
|
+
if (logo) {
|
|
30
|
+
if (dark && logo.dark) {
|
|
31
|
+
logoUrl = resolveLogoUrl(logo.dark.imageId);
|
|
32
|
+
} else {
|
|
33
|
+
logoUrl = resolveLogoUrl(logo.light.imageId);
|
|
34
|
+
invertLogo = dark && !logo.dark && logo.invertInDark;
|
|
35
|
+
}
|
|
36
|
+
if (logoUrl) mode = "logo";
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const inputs: PreviewImageInputs = {
|
|
40
|
+
mode,
|
|
41
|
+
background: mode === "logo" ? (dark ? "#000000" : "#ffffff") : primaryColor,
|
|
42
|
+
logoUrl,
|
|
43
|
+
invertLogo,
|
|
44
|
+
siteName,
|
|
45
|
+
headingFont,
|
|
46
|
+
textColor: contrastColor(primaryColor),
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
const signature = JSON.stringify({
|
|
50
|
+
mode: inputs.mode,
|
|
51
|
+
background: inputs.background,
|
|
52
|
+
logoUrl: inputs.logoUrl,
|
|
53
|
+
invertLogo: inputs.invertLogo,
|
|
54
|
+
siteName: inputs.siteName,
|
|
55
|
+
headingFont: inputs.headingFont,
|
|
56
|
+
textColor: inputs.textColor,
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
return { inputs, signature };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const PREVIEW_WIDTH = 1200;
|
|
63
|
+
const PREVIEW_HEIGHT = 630;
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* The actual largest variant width/height the /api/media route will serve for
|
|
67
|
+
* the 1200×630 preview, mirroring media/worker.ts: variants are generated for
|
|
68
|
+
* each configured size ≤ source width; if none qualify, the source width is
|
|
69
|
+
* used. The route 404s any non-variant width, so URLs must use this width.
|
|
70
|
+
* NOTE: keep in sync with worker.ts's applicableSizes logic.
|
|
71
|
+
*/
|
|
72
|
+
export function servedPreviewSize(sizes: number[]): { width: number; height: number } {
|
|
73
|
+
const applicable = sizes.filter((s) => s <= PREVIEW_WIDTH);
|
|
74
|
+
const width = applicable.length ? Math.max(...applicable) : PREVIEW_WIDTH;
|
|
75
|
+
const height = Math.round((PREVIEW_HEIGHT * width) / PREVIEW_WIDTH);
|
|
76
|
+
return { width, height };
|
|
77
|
+
}
|
package/src/schemas/media.ts
CHANGED
|
@@ -41,6 +41,9 @@ export const MediaItemSchema = z.object({
|
|
|
41
41
|
// Display folder (MediaFolderSchema key). Absent = root/unfiled. NOT the
|
|
42
42
|
// git storage dir — that's `folder` above, which never changes.
|
|
43
43
|
folderId: z.string().optional(),
|
|
44
|
+
// System-generated assets (e.g. the Phase 3 social-preview raster) set this
|
|
45
|
+
// so the media-library UI can exclude them. Absent on all user uploads.
|
|
46
|
+
hidden: z.boolean().optional(),
|
|
44
47
|
});
|
|
45
48
|
|
|
46
49
|
export type MediaItem = z.infer<typeof MediaItemSchema>;
|
|
@@ -7,7 +7,7 @@ import { CORNER_RADIUS_OPTIONS } from "../lib/corner-radius";
|
|
|
7
7
|
// document options block onto PageSchema.
|
|
8
8
|
import { documentOptionsSchema } from "../document/engine/settings";
|
|
9
9
|
|
|
10
|
-
const StatusSchema = z.enum(["draft", "live", "archived", "published"]).transform(
|
|
10
|
+
export const StatusSchema = z.enum(["draft", "live", "archived", "published"]).transform(
|
|
11
11
|
(val) => val === "published" ? "live" as const : val,
|
|
12
12
|
);
|
|
13
13
|
|
|
@@ -164,6 +164,15 @@ export type ChatbotTask = z.infer<typeof ChatbotTaskSchema>;
|
|
|
164
164
|
export const ChatbotConfigSchema = z
|
|
165
165
|
.object({
|
|
166
166
|
enabled: z.boolean().default(false),
|
|
167
|
+
// Assistant display name shown in the widget header and used in the system
|
|
168
|
+
// prompt. Blank falls back to "Portal Assistant" (resolveChatbotName).
|
|
169
|
+
name: z.string().max(60).default("Portal Assistant"),
|
|
170
|
+
// Per-audience name overrides, keyed by audience name — same inert-when-
|
|
171
|
+
// deselected semantics as audiencePrompts.
|
|
172
|
+
audienceNames: z.record(z.string(), z.string().max(60)).default({}),
|
|
173
|
+
// Brand-flavored waiting phrases, shown (with "…" appended) while the
|
|
174
|
+
// assistant streams no text yet. Empty = the widget's "Thinking…" default.
|
|
175
|
+
thinkingPhrases: z.array(z.string().max(80)).max(30).default([]),
|
|
167
176
|
systemPrompt: z.string().max(8_000).default(""),
|
|
168
177
|
// Audience names the widget is shown to — strict opt-in: [] = no viewer
|
|
169
178
|
// sees it (editors always can via /edit). Gates: [...slug].astro + /api/chat.
|
|
@@ -225,10 +234,26 @@ export const SiteConfigSchema = z.object({
|
|
|
225
234
|
.nullable()
|
|
226
235
|
.default(null),
|
|
227
236
|
media: MediaConfigSchema.default(MediaConfigSchema.parse({})),
|
|
237
|
+
// Composed social-preview raster, stored as a media-library reference so it
|
|
238
|
+
// participates in usage tracking + GC protection. Composed client-side at
|
|
239
|
+
// save time (Phase 3). `width`/`height` are the ACTUAL served variant size
|
|
240
|
+
// (the /api/media route only serves exact generated-variant widths), so the
|
|
241
|
+
// og:image URL + dimensions are correct. Defaults null so pre-existing
|
|
242
|
+
// configs keep parsing and sites without a preview emit no og:image tags.
|
|
243
|
+
previewImage: z
|
|
244
|
+
.object({
|
|
245
|
+
imageId: z.string(),
|
|
246
|
+
width: z.number().int().positive(),
|
|
247
|
+
height: z.number().int().positive(),
|
|
248
|
+
})
|
|
249
|
+
.nullable()
|
|
250
|
+
.default(null),
|
|
251
|
+
// Optional site description → og:description + <meta name="description">.
|
|
252
|
+
description: z.string().max(300).nullable().default(null),
|
|
228
253
|
// Brand chatbot config (editor-owned; the platform kill switch lives in the
|
|
229
254
|
// sites table, not here). Every field defaults so pre-existing
|
|
230
255
|
// site-config.json files keep parsing.
|
|
231
|
-
chatbot: ChatbotConfigSchema.default({ enabled: false, systemPrompt: "", audiences: [], tasks: [], audiencePrompts: {} }),
|
|
256
|
+
chatbot: ChatbotConfigSchema.default({ enabled: false, name: "Portal Assistant", audienceNames: {}, thinkingPhrases: [], systemPrompt: "", audiences: [], tasks: [], audiencePrompts: {} }),
|
|
232
257
|
});
|
|
233
258
|
|
|
234
259
|
export type SiteConfig = z.infer<typeof SiteConfigSchema>;
|