@drawnagency/primitives 0.1.84 → 0.1.87
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-665DW3HR.js → chunk-6VOIZP2O.js} +3 -3
- package/dist/{chunk-YKOLOZB2.js → chunk-DRCPDOW3.js} +14 -0
- package/dist/{chunk-MBJUWOM2.js → chunk-EO7K45GV.js} +1 -1
- package/dist/{chunk-DKXISNJ2.js → chunk-NEDOJHHR.js} +2 -2
- package/dist/{chunk-DO3ZENLN.js → chunk-OEL57NYV.js} +1 -1
- package/dist/{chunk-FDBZZ4AT.js → chunk-PTVY3Q7P.js} +1 -1
- package/dist/{chunk-56J4QMF4.js → chunk-VY5V4HBV.js} +7 -7
- package/dist/{chunk-EP3LVOXZ.js → chunk-WA3HFN54.js} +1 -1
- package/dist/{chunk-Q6LZOH5M.js → chunk-XDG2K6UT.js} +2 -2
- package/dist/{chunk-E3MTIMOG.js → chunk-XPUBUR64.js} +10 -0
- package/dist/collab/rebase.d.ts +23 -0
- package/dist/collab/rebase.d.ts.map +1 -1
- package/dist/collab/rebase.js +12 -1
- package/dist/components/editor/index.js +7 -7
- package/dist/components/primitives/index.js +5 -5
- package/dist/components/sections/brand-guide.js +5 -5
- 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/shell/EditorShell.d.ts.map +1 -1
- package/dist/components/shell/{ChatbotConfigModal.d.ts → SiteSettingsChatbot.d.ts} +2 -4
- package/dist/components/shell/SiteSettingsChatbot.d.ts.map +1 -0
- package/dist/components/shell/SiteSettingsDisplay.d.ts.map +1 -1
- package/dist/components/shell/SiteSettingsModal.d.ts +3 -1
- package/dist/components/shell/SiteSettingsModal.d.ts.map +1 -1
- package/dist/hooks/useMediaPipeline.d.ts +2 -1
- package/dist/hooks/useMediaPipeline.d.ts.map +1 -1
- package/dist/index.js +6 -4
- package/dist/lib/image-refs.d.ts +9 -0
- package/dist/lib/image-refs.d.ts.map +1 -1
- package/dist/lib/index.js +2 -2
- package/dist/media/index.js +4 -2
- package/dist/media/resolve.d.ts +25 -0
- package/dist/media/resolve.d.ts.map +1 -1
- package/dist/schemas/index.js +2 -2
- package/dist/schemas/site-config.d.ts +10 -0
- package/dist/schemas/site-config.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/collab/rebase.ts +33 -0
- package/src/components/editor/ChildBlockWrapper.tsx +1 -1
- package/src/components/editor/PagesModal.tsx +39 -31
- package/src/components/editor/SectionWrapper.tsx +3 -3
- package/src/components/shared/Navigation.tsx +20 -2
- package/src/components/shared/Tabs.tsx +1 -1
- package/src/components/shell/EditorShell.tsx +29 -76
- package/src/components/shell/SiteSettingsChatbot.tsx +188 -0
- package/src/components/shell/SiteSettingsDisplay.tsx +106 -0
- package/src/components/shell/SiteSettingsModal.tsx +29 -11
- package/src/hooks/useMediaPipeline.ts +16 -3
- package/src/lib/image-refs.ts +19 -0
- package/src/media/resolve.ts +34 -0
- package/src/schemas/site-config.ts +13 -0
- package/dist/components/shell/ChatbotConfigModal.d.ts.map +0 -1
- package/src/components/shell/ChatbotConfigModal.tsx +0 -193
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import { useEffect, useState } from "react";
|
|
2
|
+
import { Button } from "../shared/Button";
|
|
3
|
+
import { cn } from "../../lib/cn";
|
|
4
|
+
import ChatPanel from "../chat/ChatPanel";
|
|
5
|
+
import type { SiteConfig, ChatbotConfig, ChatbotTask } from "../../schemas/site-config";
|
|
6
|
+
import type { Audience } from "../../auth/types";
|
|
7
|
+
import { audiencePasses } from "../../lib/pages";
|
|
8
|
+
|
|
9
|
+
interface Props {
|
|
10
|
+
siteConfig: SiteConfig;
|
|
11
|
+
onSiteConfigChange: (config: SiteConfig) => void;
|
|
12
|
+
audiences: Audience[];
|
|
13
|
+
platformEnabled: boolean;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function slugify(label: string): string {
|
|
17
|
+
return label.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 32) || "task";
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function AudienceChips({ audiences, selected, onToggle }: { audiences: Audience[]; selected: string[]; onToggle: (name: string) => void }) {
|
|
21
|
+
return (
|
|
22
|
+
<div className="flex flex-wrap gap-1">
|
|
23
|
+
{audiences.map((a) => (
|
|
24
|
+
<button key={a.name} type="button" onClick={() => onToggle(a.name)}
|
|
25
|
+
className={cn("rounded-full border px-2 py-0.5 text-xs",
|
|
26
|
+
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}>
|
|
28
|
+
{a.displayName}
|
|
29
|
+
</button>
|
|
30
|
+
))}
|
|
31
|
+
{audiences.length === 0 && <span className="text-xs text-base-contrast-light">No audiences configured</span>}
|
|
32
|
+
</div>
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function SiteSettingsChatbot({ siteConfig, onSiteConfigChange, audiences, platformEnabled }: Props) {
|
|
37
|
+
const chatbot = siteConfig.chatbot;
|
|
38
|
+
const [newTaskLabel, setNewTaskLabel] = useState("");
|
|
39
|
+
const [showTest, setShowTest] = useState(false);
|
|
40
|
+
const [previewAudience, setPreviewAudience] = useState("");
|
|
41
|
+
|
|
42
|
+
// A deselected audience can no longer chat — never keep simulating it.
|
|
43
|
+
useEffect(() => {
|
|
44
|
+
if (previewAudience && !chatbot.audiences.includes(previewAudience)) setPreviewAudience("");
|
|
45
|
+
}, [previewAudience, chatbot.audiences]);
|
|
46
|
+
|
|
47
|
+
const patch = (p: Partial<ChatbotConfig>) =>
|
|
48
|
+
onSiteConfigChange({ ...siteConfig, chatbot: { ...chatbot, ...p } });
|
|
49
|
+
|
|
50
|
+
const patchTask = (i: number, p: Partial<ChatbotTask>) =>
|
|
51
|
+
patch({ tasks: chatbot.tasks.map((t, idx) => (idx === i ? { ...t, ...p } : t)) });
|
|
52
|
+
|
|
53
|
+
function addTask() {
|
|
54
|
+
const label = newTaskLabel.trim();
|
|
55
|
+
if (!label || chatbot.tasks.length >= 20) return;
|
|
56
|
+
let id = slugify(label);
|
|
57
|
+
if (chatbot.tasks.some((t) => t.id === id)) {
|
|
58
|
+
for (let n = 2; ; n++) {
|
|
59
|
+
const suffix = `-${n}`;
|
|
60
|
+
const candidate = `${id.slice(0, 32 - suffix.length)}${suffix}`;
|
|
61
|
+
if (!chatbot.tasks.some((t) => t.id === candidate)) {
|
|
62
|
+
id = candidate;
|
|
63
|
+
break;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
patch({ tasks: [...chatbot.tasks, { id, label, prompt: "", access: [] }] });
|
|
68
|
+
setNewTaskLabel("");
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const toggleName = (list: string[], name: string) =>
|
|
72
|
+
list.includes(name) ? list.filter((n) => n !== name) : [...list, name];
|
|
73
|
+
|
|
74
|
+
// Record lookups can hit inherited Object.prototype members ("constructor"
|
|
75
|
+
// is a legal audience name) — treat anything non-string as absent.
|
|
76
|
+
const overlayValue = (name: string) => {
|
|
77
|
+
const v = chatbot.audiencePrompts[name];
|
|
78
|
+
return typeof v === "string" ? v : "";
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
return (
|
|
82
|
+
<div className="flex flex-col gap-4">
|
|
83
|
+
{!platformEnabled && (
|
|
84
|
+
<div className="rounded border border-base-200 bg-base-accent px-3 py-2 text-sm text-base-contrast">
|
|
85
|
+
Chat is not enabled for this site — contact Drawn Agency. You can still edit the
|
|
86
|
+
configuration below; it activates when chat is switched on.
|
|
87
|
+
</div>
|
|
88
|
+
)}
|
|
89
|
+
|
|
90
|
+
<label className="flex items-center gap-2 text-sm text-base-contrast">
|
|
91
|
+
<input type="checkbox" aria-label="Enable chatbot" checked={chatbot.enabled}
|
|
92
|
+
className="h-5 w-5 cursor-pointer accent-primary"
|
|
93
|
+
onChange={(e) => patch({ enabled: e.target.checked })} />
|
|
94
|
+
Show the chat widget to viewers
|
|
95
|
+
</label>
|
|
96
|
+
|
|
97
|
+
<div>
|
|
98
|
+
<p className="mb-1 text-sm font-medium text-base-contrast">Audiences</p>
|
|
99
|
+
<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
|
+
<AudienceChips audiences={audiences} selected={chatbot.audiences} onToggle={(n) => patch({ audiences: toggleName(chatbot.audiences, n) })} />
|
|
101
|
+
{chatbot.audiences.map((name) => (
|
|
102
|
+
<div key={name} className="mt-2">
|
|
103
|
+
<label htmlFor={`audience-prompt-${name}`} className="mb-1 block text-xs font-medium text-base-contrast">
|
|
104
|
+
Additional guidance for {name}
|
|
105
|
+
</label>
|
|
106
|
+
<textarea id={`audience-prompt-${name}`} aria-label={`Guidance for ${name}`} rows={2} maxLength={8000}
|
|
107
|
+
value={overlayValue(name)}
|
|
108
|
+
onChange={(e) => patch({ audiencePrompts: { ...chatbot.audiencePrompts, [name]: e.target.value } })}
|
|
109
|
+
placeholder={`Only ${name} sees answers shaped by this — safe for ${name}-level info.`}
|
|
110
|
+
className="w-full rounded border border-base-200 bg-base px-2 py-1 text-sm text-base-contrast" />
|
|
111
|
+
</div>
|
|
112
|
+
))}
|
|
113
|
+
</div>
|
|
114
|
+
|
|
115
|
+
<div>
|
|
116
|
+
<label htmlFor="chatbot-system-prompt" className="mb-1 block text-sm font-medium text-base-contrast">System prompt</label>
|
|
117
|
+
<textarea id="chatbot-system-prompt" aria-label="System prompt" rows={5} maxLength={8000}
|
|
118
|
+
value={chatbot.systemPrompt}
|
|
119
|
+
onChange={(e) => patch({ systemPrompt: e.target.value })}
|
|
120
|
+
placeholder="Describe the brand's voice and how the assistant should behave — e.g. “Speak warmly and concisely; always reference our color names, never hex codes.”"
|
|
121
|
+
className="w-full rounded border border-base-200 bg-base px-2 py-1 text-sm text-base-contrast" />
|
|
122
|
+
<p className="text-right text-xs text-base-contrast-light">{chatbot.systemPrompt.length}/8000</p>
|
|
123
|
+
</div>
|
|
124
|
+
|
|
125
|
+
<div>
|
|
126
|
+
<p className="mb-1 text-sm font-medium text-base-contrast">Tasks</p>
|
|
127
|
+
<p className="mb-2 text-xs text-base-contrast-light">Goal presets shown as a dropdown in the widget. Each task layers its own instructions onto the system prompt.</p>
|
|
128
|
+
<div className="space-y-3">
|
|
129
|
+
{chatbot.tasks.map((t, i) => (
|
|
130
|
+
<div key={t.id} className="rounded border border-base-200 p-2">
|
|
131
|
+
<div className="mb-1 flex items-center justify-between gap-2">
|
|
132
|
+
<input aria-label={`Task ${t.id} label`} value={t.label} maxLength={60}
|
|
133
|
+
onChange={(e) => patchTask(i, { label: e.target.value })}
|
|
134
|
+
className="min-w-0 flex-1 rounded border border-base-200 bg-base px-2 py-1 text-sm" />
|
|
135
|
+
<Button type="button" variant="destructive"
|
|
136
|
+
onClick={() => patch({ tasks: chatbot.tasks.filter((_, idx) => idx !== i) })}>
|
|
137
|
+
Remove
|
|
138
|
+
</Button>
|
|
139
|
+
</div>
|
|
140
|
+
<textarea aria-label={`Task ${t.id} prompt`} rows={2} maxLength={4000} value={t.prompt}
|
|
141
|
+
onChange={(e) => patchTask(i, { prompt: e.target.value })}
|
|
142
|
+
placeholder="Instructions for this task…"
|
|
143
|
+
className="mb-1 w-full rounded border border-base-200 bg-base px-2 py-1 text-sm" />
|
|
144
|
+
<AudienceChips audiences={audiences} selected={t.access} onToggle={(n) => patchTask(i, { access: toggleName(t.access, n) })} />
|
|
145
|
+
</div>
|
|
146
|
+
))}
|
|
147
|
+
</div>
|
|
148
|
+
<div className="mt-2 flex gap-2">
|
|
149
|
+
<input aria-label="New task label" value={newTaskLabel} maxLength={60}
|
|
150
|
+
onChange={(e) => setNewTaskLabel(e.target.value)}
|
|
151
|
+
placeholder="e.g. Draft a social post"
|
|
152
|
+
className="min-w-0 flex-1 rounded border border-base-200 bg-base px-2 py-1 text-sm" />
|
|
153
|
+
<Button type="button" onClick={addTask}>Add task</Button>
|
|
154
|
+
</div>
|
|
155
|
+
</div>
|
|
156
|
+
|
|
157
|
+
<div>
|
|
158
|
+
<Button type="button" onClick={() => setShowTest((s) => !s)} disabled={!platformEnabled}>
|
|
159
|
+
{showTest ? "Hide test chat" : "Test with current draft"}
|
|
160
|
+
</Button>
|
|
161
|
+
<p className="mt-1 text-xs text-base-contrast-light">
|
|
162
|
+
Tests your unsaved prompt and tasks. Answers ground on published content.
|
|
163
|
+
</p>
|
|
164
|
+
{showTest && platformEnabled && (
|
|
165
|
+
<label className="mt-2 flex items-center gap-2 text-xs text-base-contrast">
|
|
166
|
+
Preview as
|
|
167
|
+
<select aria-label="Preview as" value={previewAudience} onChange={(e) => setPreviewAudience(e.target.value)}
|
|
168
|
+
className="rounded border border-base-200 bg-base-accent px-1 py-0.5 text-xs">
|
|
169
|
+
<option value="">Editor (base prompt only)</option>
|
|
170
|
+
{chatbot.audiences.map((n) => <option key={n} value={n}>{n}</option>)}
|
|
171
|
+
</select>
|
|
172
|
+
<span className="text-base-contrast-light">Prompt and task preview only — grounding stays editor-wide.</span>
|
|
173
|
+
</label>
|
|
174
|
+
)}
|
|
175
|
+
{showTest && platformEnabled && (
|
|
176
|
+
<ChatPanel
|
|
177
|
+
open onClose={() => setShowTest(false)} siteName={siteConfig.siteName}
|
|
178
|
+
tasks={chatbot.tasks
|
|
179
|
+
.filter((t) => previewAudience === "" || audiencePasses(t.access, previewAudience))
|
|
180
|
+
.map((t) => ({ id: t.id, label: t.label }))}
|
|
181
|
+
isEditor draftChatbot={chatbot}
|
|
182
|
+
simulateAudience={previewAudience || undefined}
|
|
183
|
+
/>
|
|
184
|
+
)}
|
|
185
|
+
</div>
|
|
186
|
+
</div>
|
|
187
|
+
);
|
|
188
|
+
}
|
|
@@ -6,15 +6,27 @@ import { FontPicker } from "../shared/FontPicker";
|
|
|
6
6
|
import { Input } from "../shared/Input";
|
|
7
7
|
import { Select } from "../shared/Select";
|
|
8
8
|
import { FormLabel } from "../shared/FormLabel";
|
|
9
|
+
import { useMediaLibrary } from "./MediaLibraryContext";
|
|
9
10
|
import { deriveContrast } from "../../lib/contrast";
|
|
10
11
|
import { buildGoogleFontsUrl } from "../../lib/google-fonts";
|
|
11
12
|
import type { SiteConfig } from "../../schemas/site-config";
|
|
13
|
+
import type { MediaItem } from "../../media/types";
|
|
12
14
|
|
|
13
15
|
interface Props {
|
|
14
16
|
siteConfig: SiteConfig;
|
|
15
17
|
onChange: (config: SiteConfig) => void;
|
|
16
18
|
}
|
|
17
19
|
|
|
20
|
+
// Smallest generated variant for the editor-preview thumbnail (mirrors
|
|
21
|
+
// DocInspector's thumbSrc) — avoids needing a MediaProvider in this context.
|
|
22
|
+
function thumbSrc(imageId: string, item: MediaItem | undefined): string | null {
|
|
23
|
+
if (!item || item.kind !== "image") return null;
|
|
24
|
+
if (item.mimeType === "image/svg+xml") return `/api/media/${imageId}/original.svg`;
|
|
25
|
+
const widths = item.variants.map((v) => v.width).sort((a, b) => a - b);
|
|
26
|
+
if (widths.length === 0) return null;
|
|
27
|
+
return `/api/media/${imageId}/${widths[0]}.webp`;
|
|
28
|
+
}
|
|
29
|
+
|
|
18
30
|
const darkModeOptions = [
|
|
19
31
|
{ label: "Light", value: "light" },
|
|
20
32
|
{ label: "Dark", value: "dark" },
|
|
@@ -33,11 +45,19 @@ const FAVICON_MAX_BYTES = 100 * 1024;
|
|
|
33
45
|
|
|
34
46
|
export function SiteSettingsDisplay({ siteConfig, onChange }: Props) {
|
|
35
47
|
const [faviconError, setFaviconError] = useState<string | null>(null);
|
|
48
|
+
const mediaLibrary = useMediaLibrary();
|
|
36
49
|
|
|
37
50
|
function update(patch: Partial<SiteConfig>) {
|
|
38
51
|
onChange({ ...siteConfig, ...patch });
|
|
39
52
|
}
|
|
40
53
|
|
|
54
|
+
function pickLogo(which: "light" | "dark") {
|
|
55
|
+
mediaLibrary?.openSelectModal((imageId) => {
|
|
56
|
+
const base = siteConfig.logo ?? { light: { imageId: "" }, dark: null, invertInDark: false, showInNav: true };
|
|
57
|
+
update({ logo: { ...base, [which]: { imageId } } });
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
|
|
41
61
|
function handleFaviconFile(file: File | undefined) {
|
|
42
62
|
if (!file) return;
|
|
43
63
|
if (!FAVICON_TYPES.includes(file.type)) {
|
|
@@ -154,6 +174,92 @@ export function SiteSettingsDisplay({ siteConfig, onChange }: Props) {
|
|
|
154
174
|
{faviconError && <p className="mt-1 text-xs text-red-600">{faviconError}</p>}
|
|
155
175
|
</div>
|
|
156
176
|
|
|
177
|
+
{(() => {
|
|
178
|
+
const logo = siteConfig.logo;
|
|
179
|
+
const lightSrc = logo ? thumbSrc(logo.light.imageId, mediaLibrary?.manifest.images[logo.light.imageId]) : null;
|
|
180
|
+
const darkSrc = logo?.dark ? thumbSrc(logo.dark.imageId, mediaLibrary?.manifest.images[logo.dark.imageId]) : null;
|
|
181
|
+
const needsDarkHint = logo != null && siteConfig.darkMode !== "light" && !logo.dark && !logo.invertInDark;
|
|
182
|
+
|
|
183
|
+
return (
|
|
184
|
+
<div>
|
|
185
|
+
<FormLabel>Logo</FormLabel>
|
|
186
|
+
<div className="flex items-center gap-3">
|
|
187
|
+
{logo && (lightSrc ? (
|
|
188
|
+
<img
|
|
189
|
+
src={lightSrc}
|
|
190
|
+
alt="Current logo"
|
|
191
|
+
className="h-8 w-auto max-w-[8rem] rounded border border-base-200 bg-base-accent object-contain p-1"
|
|
192
|
+
/>
|
|
193
|
+
) : (
|
|
194
|
+
<span className="text-xs text-base-contrast-light">Logo selected</span>
|
|
195
|
+
))}
|
|
196
|
+
<Button variant="secondary" size="sm" onClick={() => pickLogo("light")}>
|
|
197
|
+
{logo ? "Replace logo" : "Add logo"}
|
|
198
|
+
</Button>
|
|
199
|
+
{logo && (
|
|
200
|
+
<Button variant="ghost" tone="destructive" size="sm" aria-label="Remove logo" onClick={() => update({ logo: null })}>
|
|
201
|
+
Remove logo
|
|
202
|
+
</Button>
|
|
203
|
+
)}
|
|
204
|
+
</div>
|
|
205
|
+
<p className="mt-1 text-xs text-base-contrast/70">Shown in the site navigation in place of the site name.</p>
|
|
206
|
+
|
|
207
|
+
{logo && (
|
|
208
|
+
<div className="mt-3 space-y-3 border-l-2 border-base-200 pl-3">
|
|
209
|
+
<div>
|
|
210
|
+
<FormLabel>Dark variant</FormLabel>
|
|
211
|
+
<div className="flex items-center gap-3">
|
|
212
|
+
{logo.dark && (darkSrc ? (
|
|
213
|
+
<img
|
|
214
|
+
src={darkSrc}
|
|
215
|
+
alt="Current dark-mode logo"
|
|
216
|
+
className="h-8 w-auto max-w-[8rem] rounded border border-base-200 bg-base-accent object-contain p-1"
|
|
217
|
+
/>
|
|
218
|
+
) : (
|
|
219
|
+
<span className="text-xs text-base-contrast-light">Dark logo selected</span>
|
|
220
|
+
))}
|
|
221
|
+
<Button variant="secondary" size="sm" onClick={() => pickLogo("dark")}>
|
|
222
|
+
{logo.dark ? "Replace dark variant" : "Add dark variant"}
|
|
223
|
+
</Button>
|
|
224
|
+
{logo.dark && (
|
|
225
|
+
<Button
|
|
226
|
+
variant="ghost"
|
|
227
|
+
tone="destructive"
|
|
228
|
+
size="sm"
|
|
229
|
+
aria-label="Remove dark variant"
|
|
230
|
+
onClick={() => update({ logo: { ...logo, dark: null } })}
|
|
231
|
+
>
|
|
232
|
+
Remove
|
|
233
|
+
</Button>
|
|
234
|
+
)}
|
|
235
|
+
</div>
|
|
236
|
+
<Checkbox
|
|
237
|
+
className="mt-2"
|
|
238
|
+
checked={logo.invertInDark}
|
|
239
|
+
onChange={(v) => update({ logo: { ...logo, invertInDark: v } })}
|
|
240
|
+
label="Invert in dark mode"
|
|
241
|
+
description="Flips the light logo's colors instead of uploading a separate file."
|
|
242
|
+
disabled={Boolean(logo.dark)}
|
|
243
|
+
/>
|
|
244
|
+
</div>
|
|
245
|
+
|
|
246
|
+
<Checkbox
|
|
247
|
+
checked={logo.showInNav}
|
|
248
|
+
onChange={(v) => update({ logo: { ...logo, showInNav: v } })}
|
|
249
|
+
label="Show in navigation"
|
|
250
|
+
/>
|
|
251
|
+
|
|
252
|
+
{needsDarkHint && (
|
|
253
|
+
<p className="text-xs text-amber-600">
|
|
254
|
+
Add a dark variant or enable invert so the logo stays legible in dark mode.
|
|
255
|
+
</p>
|
|
256
|
+
)}
|
|
257
|
+
</div>
|
|
258
|
+
)}
|
|
259
|
+
</div>
|
|
260
|
+
);
|
|
261
|
+
})()}
|
|
262
|
+
|
|
157
263
|
<Select
|
|
158
264
|
label="Dark mode"
|
|
159
265
|
value={siteConfig.darkMode}
|
|
@@ -5,6 +5,7 @@ import { Tabs } from "../shared/Tabs";
|
|
|
5
5
|
import { SiteSettingsViewerAccess } from "./SiteSettingsViewerAccess";
|
|
6
6
|
import { SiteSettingsDisplay } from "./SiteSettingsDisplay";
|
|
7
7
|
import { SiteSettingsUsers } from "./SiteSettingsUsers";
|
|
8
|
+
import { SiteSettingsChatbot } from "./SiteSettingsChatbot";
|
|
8
9
|
import type { SiteConfig } from "../../schemas/site-config";
|
|
9
10
|
import type { Audience } from "../../auth/types";
|
|
10
11
|
|
|
@@ -14,6 +15,8 @@ interface Props {
|
|
|
14
15
|
siteConfig: SiteConfig;
|
|
15
16
|
onSiteConfigChange: (config: SiteConfig) => void;
|
|
16
17
|
onAudiencesChange: (audiences: Audience[]) => void;
|
|
18
|
+
audiences: Audience[];
|
|
19
|
+
chatbotPlatformEnabled: boolean;
|
|
17
20
|
capabilities: {
|
|
18
21
|
oauth: boolean;
|
|
19
22
|
emailPassword: boolean;
|
|
@@ -25,7 +28,7 @@ interface Props {
|
|
|
25
28
|
currentUser: { email: string; role: "owner" | "editor" } | null;
|
|
26
29
|
}
|
|
27
30
|
|
|
28
|
-
export function SiteSettingsModal({ isOpen, onClose, siteConfig, onSiteConfigChange, onAudiencesChange, capabilities, currentUser }: Props) {
|
|
31
|
+
export function SiteSettingsModal({ isOpen, onClose, siteConfig, onSiteConfigChange, onAudiencesChange, audiences, chatbotPlatformEnabled, capabilities, currentUser }: Props) {
|
|
29
32
|
const [signOutError, setSignOutError] = useState<string | null>(null);
|
|
30
33
|
|
|
31
34
|
async function handleSignOut() {
|
|
@@ -50,25 +53,30 @@ export function SiteSettingsModal({ isOpen, onClose, siteConfig, onSiteConfigCha
|
|
|
50
53
|
|
|
51
54
|
const allTabs = [
|
|
52
55
|
{
|
|
53
|
-
id: "
|
|
54
|
-
label: "
|
|
55
|
-
show:
|
|
56
|
+
id: "viewer-access",
|
|
57
|
+
label: "Audiences",
|
|
58
|
+
show: true,
|
|
56
59
|
content: (
|
|
57
60
|
<div data-testid="site-settings-tab-panel" className="flex flex-1 flex-col overflow-y-auto px-6 py-4">
|
|
58
|
-
<
|
|
61
|
+
<SiteSettingsViewerAccess
|
|
62
|
+
audienceManagement={capabilities.audienceManagement}
|
|
63
|
+
passwordToggle={capabilities.passwordToggle}
|
|
64
|
+
onAudiencesChange={onAudiencesChange}
|
|
65
|
+
/>
|
|
59
66
|
</div>
|
|
60
67
|
),
|
|
61
68
|
},
|
|
62
69
|
{
|
|
63
|
-
id: "
|
|
64
|
-
label: "
|
|
70
|
+
id: "chatbot",
|
|
71
|
+
label: "Chatbot",
|
|
65
72
|
show: true,
|
|
66
73
|
content: (
|
|
67
74
|
<div data-testid="site-settings-tab-panel" className="flex flex-1 flex-col overflow-y-auto px-6 py-4">
|
|
68
|
-
<
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
75
|
+
<SiteSettingsChatbot
|
|
76
|
+
siteConfig={siteConfig}
|
|
77
|
+
onSiteConfigChange={onSiteConfigChange}
|
|
78
|
+
audiences={audiences}
|
|
79
|
+
platformEnabled={chatbotPlatformEnabled}
|
|
72
80
|
/>
|
|
73
81
|
</div>
|
|
74
82
|
),
|
|
@@ -83,6 +91,16 @@ export function SiteSettingsModal({ isOpen, onClose, siteConfig, onSiteConfigCha
|
|
|
83
91
|
</div>
|
|
84
92
|
),
|
|
85
93
|
},
|
|
94
|
+
{
|
|
95
|
+
id: "users",
|
|
96
|
+
label: "Users",
|
|
97
|
+
show: capabilities.userManagement && currentUser?.role === "owner",
|
|
98
|
+
content: (
|
|
99
|
+
<div data-testid="site-settings-tab-panel" className="flex flex-1 flex-col overflow-y-auto px-6 py-4">
|
|
100
|
+
<SiteSettingsUsers currentUser={currentUser} />
|
|
101
|
+
</div>
|
|
102
|
+
),
|
|
103
|
+
},
|
|
86
104
|
];
|
|
87
105
|
|
|
88
106
|
const tabItems = allTabs.filter((t) => t.show).map(({ id, label, content }) => ({ id, label, content }));
|
|
@@ -13,7 +13,7 @@ import {
|
|
|
13
13
|
removePendingMediaDeletion,
|
|
14
14
|
} from "../lib/dexie";
|
|
15
15
|
import { generateVideoPoster } from "../media/videoPoster";
|
|
16
|
-
import { tallyImageIds, collectImageIds, clearImageIds } from "../lib/image-refs";
|
|
16
|
+
import { tallyImageIds, collectImageIds, clearImageIds, applyLogoDeletion } from "../lib/image-refs";
|
|
17
17
|
import {
|
|
18
18
|
createFolderId, uniqueFolderName, effectiveParentId, canMoveFolder,
|
|
19
19
|
descendantFolderIds, itemFolderId, planFolderUpload,
|
|
@@ -36,6 +36,10 @@ interface UseMediaPipelineInput {
|
|
|
36
36
|
// Surfaces non-error, informational notices (e.g. "N files already exist
|
|
37
37
|
// and were left where they are" on dedup skip) to the caller.
|
|
38
38
|
onUploadNotice?: (message: string) => void;
|
|
39
|
+
// Lets handleMediaDelete null out siteConfig.logo when its backing asset is
|
|
40
|
+
// deleted. Updater form (not a plain setter) so the caller can adapt its own
|
|
41
|
+
// value-based site-config change handler.
|
|
42
|
+
onSiteConfigChange?: (updater: (prev: SiteConfig) => SiteConfig) => void;
|
|
39
43
|
}
|
|
40
44
|
|
|
41
45
|
export function useMediaPipeline({
|
|
@@ -49,6 +53,7 @@ export function useMediaPipeline({
|
|
|
49
53
|
markSectionDirty,
|
|
50
54
|
onUploadError,
|
|
51
55
|
onUploadNotice,
|
|
56
|
+
onSiteConfigChange,
|
|
52
57
|
}: UseMediaPipelineInput) {
|
|
53
58
|
const [processingItems, setProcessingItems] = useState<QueueItem[]>([]);
|
|
54
59
|
const [pendingMediaItems, setPendingMediaItems] = useState<LibraryMediaItem[]>([]);
|
|
@@ -440,8 +445,15 @@ export function useMediaPipeline({
|
|
|
440
445
|
}),
|
|
441
446
|
);
|
|
442
447
|
|
|
448
|
+
if (siteConfig?.logo && onSiteConfigChange) {
|
|
449
|
+
const logoIds = collectImageIds(siteConfig.logo);
|
|
450
|
+
if (ids.some((id) => logoIds.has(id))) {
|
|
451
|
+
onSiteConfigChange((prev) => ({ ...prev, logo: applyLogoDeletion(prev.logo, idSet) }));
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
|
|
443
455
|
setLocalChangesExist(true);
|
|
444
|
-
}, [pendingMediaItems, markSectionDirty, setSections, setDirtySectionIds, setLocalChangesExist]);
|
|
456
|
+
}, [pendingMediaItems, markSectionDirty, setSections, setDirtySectionIds, setLocalChangesExist, siteConfig, onSiteConfigChange]);
|
|
445
457
|
|
|
446
458
|
const handleMediaAltChange = useCallback((id: string, alt: string) => {
|
|
447
459
|
setMediaManifest((prev) => {
|
|
@@ -585,8 +597,9 @@ export function useMediaPipeline({
|
|
|
585
597
|
const referenceCountMap = useMemo((): Record<string, number> => {
|
|
586
598
|
const counts = new Map<string, number>();
|
|
587
599
|
for (const { section } of sections) tallyImageIds(section, counts);
|
|
600
|
+
if (siteConfig) tallyImageIds(siteConfig, counts);
|
|
588
601
|
return Object.fromEntries(counts);
|
|
589
|
-
}, [sections]);
|
|
602
|
+
}, [sections, siteConfig]);
|
|
590
603
|
|
|
591
604
|
// --- Unified enqueue ---
|
|
592
605
|
|
package/src/lib/image-refs.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import type { SiteConfig } from "../schemas/site-config";
|
|
2
|
+
|
|
1
3
|
/**
|
|
2
4
|
* Structural walkers over section content for `imageId` references.
|
|
3
5
|
*
|
|
@@ -51,3 +53,20 @@ export function clearImageIds(value: unknown, ids: Set<string>): unknown {
|
|
|
51
53
|
}
|
|
52
54
|
return value;
|
|
53
55
|
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Applies a media-deletion id set to a site logo config, granularly: deleting
|
|
59
|
+
* the light variant's backing asset removes the logo entirely (no light = no
|
|
60
|
+
* logo to show), while deleting only the dark variant's asset keeps the
|
|
61
|
+
* light logo + flags and drops just the dark override. Returns the same
|
|
62
|
+
* `logo` reference unchanged when neither id was deleted.
|
|
63
|
+
*/
|
|
64
|
+
export function applyLogoDeletion(
|
|
65
|
+
logo: SiteConfig["logo"],
|
|
66
|
+
deletedIds: Set<string>,
|
|
67
|
+
): SiteConfig["logo"] {
|
|
68
|
+
if (!logo) return logo;
|
|
69
|
+
if (deletedIds.has(logo.light.imageId)) return null;
|
|
70
|
+
if (logo.dark && deletedIds.has(logo.dark.imageId)) return { ...logo, dark: null };
|
|
71
|
+
return logo;
|
|
72
|
+
}
|
package/src/media/resolve.ts
CHANGED
|
@@ -86,3 +86,37 @@ export function rebindImageRef<T extends { imageId?: string }>(ref: T, imageId:
|
|
|
86
86
|
for (const key of BAKED_MEDIA_KEYS) delete fresh[key];
|
|
87
87
|
return fresh as T;
|
|
88
88
|
}
|
|
89
|
+
|
|
90
|
+
export interface ResolvedLogo {
|
|
91
|
+
lightUrl: string;
|
|
92
|
+
darkUrl: string | null;
|
|
93
|
+
invertInDark: boolean;
|
|
94
|
+
showInNav: boolean;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Resolves a SiteConfig["logo"] entry into concrete URLs. Returns null when
|
|
99
|
+
* there is no logo configured, when the light variant's imageId can't be
|
|
100
|
+
* resolved against the manifest, or when the light variant resolves to a
|
|
101
|
+
* video (an `<img>` can't render mp4/webm) — callers should fall back to
|
|
102
|
+
* rendering the site name as text rather than a broken image. Still-image and
|
|
103
|
+
* animated (gif) picks are both fine — only a video pick is rejected, since
|
|
104
|
+
* both those tags resolve to `tag: "img"`.
|
|
105
|
+
*/
|
|
106
|
+
export function resolveLogo(
|
|
107
|
+
logo: { light: { imageId: string }; dark: { imageId: string } | null; invertInDark: boolean; showInNav: boolean } | null,
|
|
108
|
+
manifest: MediaManifest,
|
|
109
|
+
provider: MediaProvider,
|
|
110
|
+
): ResolvedLogo | null {
|
|
111
|
+
if (!logo) return null;
|
|
112
|
+
const light = resolveMedia({ imageId: logo.light.imageId }, manifest, [1], provider);
|
|
113
|
+
if (!light?.src || light.tag === "video") return null;
|
|
114
|
+
const dark = logo.dark ? resolveMedia({ imageId: logo.dark.imageId }, manifest, [1], provider) : null;
|
|
115
|
+
const darkUrl = dark && dark.tag !== "video" ? dark.src : null;
|
|
116
|
+
return {
|
|
117
|
+
lightUrl: light.src,
|
|
118
|
+
darkUrl,
|
|
119
|
+
invertInDark: logo.invertInDark,
|
|
120
|
+
showInNav: logo.showInNav,
|
|
121
|
+
};
|
|
122
|
+
}
|
|
@@ -211,6 +211,19 @@ export const SiteConfigSchema = z.object({
|
|
|
211
211
|
.refine((v) => v.startsWith("data:image/"), "Favicon must be an image data URL")
|
|
212
212
|
.nullable()
|
|
213
213
|
.default(null),
|
|
214
|
+
// Optional site logo, stored as media-library references (so it participates
|
|
215
|
+
// in usage tracking + GC protection, and its resolved URL is reusable for the
|
|
216
|
+
// Phase 3 social-preview image). Every field defaults so pre-existing
|
|
217
|
+
// site-config.json files keep parsing.
|
|
218
|
+
logo: z
|
|
219
|
+
.object({
|
|
220
|
+
light: z.object({ imageId: z.string() }),
|
|
221
|
+
dark: z.object({ imageId: z.string() }).nullable().default(null),
|
|
222
|
+
invertInDark: z.boolean().default(false),
|
|
223
|
+
showInNav: z.boolean().default(true),
|
|
224
|
+
})
|
|
225
|
+
.nullable()
|
|
226
|
+
.default(null),
|
|
214
227
|
media: MediaConfigSchema.default(MediaConfigSchema.parse({})),
|
|
215
228
|
// Brand chatbot config (editor-owned; the platform kill switch lives in the
|
|
216
229
|
// sites table, not here). Every field defaults so pre-existing
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"ChatbotConfigModal.d.ts","sourceRoot":"","sources":["../../../src/components/shell/ChatbotConfigModal.tsx"],"names":[],"mappings":"AAKA,OAAO,KAAK,EAAE,UAAU,EAA8B,MAAM,2BAA2B,CAAC;AACxF,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,kBAAkB,CAAC;AAGjD,UAAU,KAAK;IACb,MAAM,EAAE,OAAO,CAAC;IAChB,OAAO,EAAE,MAAM,IAAI,CAAC;IACpB,UAAU,EAAE,UAAU,CAAC;IACvB,kBAAkB,EAAE,CAAC,MAAM,EAAE,UAAU,KAAK,IAAI,CAAC;IACjD,SAAS,EAAE,QAAQ,EAAE,CAAC;IACtB,eAAe,EAAE,OAAO,CAAC;CAC1B;AAsBD,wBAAgB,kBAAkB,CAAC,EAAE,MAAM,EAAE,OAAO,EAAE,UAAU,EAAE,kBAAkB,EAAE,SAAS,EAAE,eAAe,EAAE,EAAE,KAAK,2CA0JxH"}
|