@drawnagency/primitives 0.1.86 → 0.1.88
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-YKOLOZB2.js → chunk-DRCPDOW3.js} +14 -0
- package/dist/{chunk-MBJUWOM2.js → chunk-EO7K45GV.js} +1 -1
- package/dist/{chunk-2CVP2AGK.js → chunk-H2FENAQH.js} +6 -6
- 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-665DW3HR.js → chunk-SK6GGTTO.js} +220 -207
- package/dist/{chunk-EP3LVOXZ.js → chunk-WA3HFN54.js} +1 -1
- package/dist/{chunk-YAPG44NS.js → chunk-XDG2K6UT.js} +1 -1
- package/dist/{chunk-E3MTIMOG.js → chunk-XPUBUR64.js} +10 -0
- package/dist/components/brandguide/Colors.d.ts.map +1 -1
- package/dist/components/brandguide/ColorsEditable.d.ts +20 -0
- package/dist/components/brandguide/ColorsEditable.d.ts.map +1 -0
- package/dist/components/brandguide/ColorsView.d.ts +9 -0
- package/dist/components/brandguide/ColorsView.d.ts.map +1 -0
- package/dist/components/brandguide/colors-shared.d.ts +4 -0
- package/dist/components/brandguide/colors-shared.d.ts.map +1 -0
- package/dist/components/editor/index.js +4 -4
- package/dist/components/primitives/index.js +5 -5
- package/dist/components/sections/Colors/index.d.ts.map +1 -1
- package/dist/components/sections/SectionIsland.d.ts +8 -0
- package/dist/components/sections/SectionIsland.d.ts.map +1 -0
- 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/SiteSettingsDisplay.d.ts.map +1 -1
- package/dist/hooks/useCollabSession.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/lib/registry.d.ts +7 -0
- package/dist/lib/registry.d.ts.map +1 -1
- 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/components/brandguide/Colors.tsx +2 -275
- package/src/components/brandguide/ColorsEditable.tsx +155 -0
- package/src/components/brandguide/ColorsView.tsx +100 -0
- package/src/components/brandguide/colors-shared.ts +17 -0
- package/src/components/sections/Colors/index.tsx +1 -0
- package/src/components/sections/SectionIsland.tsx +21 -0
- package/src/components/shared/Navigation.tsx +20 -2
- package/src/components/shell/EditorShell.tsx +4 -0
- package/src/components/shell/SiteSettingsDisplay.tsx +106 -0
- package/src/hooks/useCollabSession.ts +27 -0
- package/src/hooks/useMediaPipeline.ts +16 -3
- package/src/lib/image-refs.ts +19 -0
- package/src/lib/registry.ts +7 -0
- package/src/media/resolve.ts +34 -0
- package/src/schemas/site-config.ts +13 -0
|
@@ -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,13 @@ import type {
|
|
|
5
5
|
} from "../collab/types";
|
|
6
6
|
|
|
7
7
|
const REJOIN_DELAYS_MS = [5_000, 15_000, 30_000, 60_000];
|
|
8
|
+
// Circuit breaker: after this many consecutive failed rejoins (~3.5 min of
|
|
9
|
+
// backoff), stop the auto-rejoin loop and stay in solo mode rather than retry
|
|
10
|
+
// forever — an unbounded reconnect loop is itself a way to hammer the backend.
|
|
11
|
+
// Refocusing the tab (visibilitychange → visible) resets the breaker and
|
|
12
|
+
// retries, so a user returning to a long-idle/offline tab reconnects without a
|
|
13
|
+
// manual reload.
|
|
14
|
+
const MAX_REJOIN_ATTEMPTS = 6;
|
|
8
15
|
|
|
9
16
|
export interface UseCollabSessionArgs {
|
|
10
17
|
provider: CollabProvider | null;
|
|
@@ -129,14 +136,34 @@ export function useCollabSession(args: UseCollabSessionArgs): UseCollabSessionRe
|
|
|
129
136
|
// race two concurrent joins into two sessions, and the orphaned timer
|
|
130
137
|
// would survive unmount (the cleanup only clears the tracked one).
|
|
131
138
|
if (timer) clearTimeout(timer);
|
|
139
|
+
// Circuit breaker: give up after a capped number of consecutive failures
|
|
140
|
+
// rather than rejoin forever. onVisible() below rearms it on refocus.
|
|
141
|
+
if (attempt >= MAX_REJOIN_ATTEMPTS) {
|
|
142
|
+
console.warn(`Collab: stopped auto-rejoining after ${attempt} consecutive failures; staying solo until the tab is refocused.`);
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
132
145
|
const delay = REJOIN_DELAYS_MS[Math.min(attempt, REJOIN_DELAYS_MS.length - 1)];
|
|
133
146
|
attempt += 1;
|
|
134
147
|
timer = setTimeout(() => { timer = null; void join(); }, delay);
|
|
135
148
|
}
|
|
136
149
|
|
|
150
|
+
// Rearm the breaker (and reconnect immediately) when the user tabs back to
|
|
151
|
+
// a disconnected editor — the common recovery from a laptop-sleep / network
|
|
152
|
+
// blip that exhausted the rejoin budget. No-op while already live/connecting.
|
|
153
|
+
function onVisible() {
|
|
154
|
+
if (cancelled) return;
|
|
155
|
+
if (typeof document !== "undefined" && document.visibilityState !== "visible") return;
|
|
156
|
+
if (sessionRef.current || joining) return;
|
|
157
|
+
if (timer) { clearTimeout(timer); timer = null; }
|
|
158
|
+
attempt = 0;
|
|
159
|
+
void join();
|
|
160
|
+
}
|
|
161
|
+
if (typeof document !== "undefined") document.addEventListener("visibilitychange", onVisible);
|
|
162
|
+
|
|
137
163
|
void join();
|
|
138
164
|
return () => {
|
|
139
165
|
cancelled = true;
|
|
166
|
+
if (typeof document !== "undefined") document.removeEventListener("visibilitychange", onVisible);
|
|
140
167
|
if (timer) clearTimeout(timer);
|
|
141
168
|
void sessionRef.current?.disconnect().catch(() => {});
|
|
142
169
|
sessionRef.current = null;
|
|
@@ -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/lib/registry.ts
CHANGED
|
@@ -176,6 +176,12 @@ export interface SectionDefinition<T = unknown> {
|
|
|
176
176
|
* top-level `description`. Presentation/metadata only — not validated.
|
|
177
177
|
*/
|
|
178
178
|
authoringNote?: string;
|
|
179
|
+
/**
|
|
180
|
+
* When true, this section must run client-side on the viewer page: the viewer
|
|
181
|
+
* renderer hydrates it as an Astro island (`client:visible`) instead of
|
|
182
|
+
* emitting static HTML. Opt-in per type; default (absent/false) stays zero-JS.
|
|
183
|
+
*/
|
|
184
|
+
interactive?: boolean;
|
|
179
185
|
}
|
|
180
186
|
|
|
181
187
|
// --- defineSection ---
|
|
@@ -204,6 +210,7 @@ type DefineSectionInput<S extends ZodType> = {
|
|
|
204
210
|
settingsTabs?: { label: string; fields: string[] }[];
|
|
205
211
|
presets?: SectionPreset[];
|
|
206
212
|
authoringNote?: string;
|
|
213
|
+
interactive?: boolean;
|
|
207
214
|
};
|
|
208
215
|
|
|
209
216
|
export function defineSection<S extends ZodType>(
|
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
|