Files
silo-server/web/src/hooks/useCustomTheme.ts
T
Quick 90224c583f fix(settings): clear the remaining review findings
Small, unrelated except that each was raised on #479.

compileObjectSchemas parsed every non-directory file under schemas/ as a JSON
Schema, so a stray editor backup or .DS_Store would panic the server at
startup through MustLoad. schema_ref can only name a .json file; anything
else is skipped.

cmd/silo used MustLoad while the ETag check beside it and every other startup
failure use log.Fatalf. It now fails the same way, so a bad contract prints
an error instead of a stack trace.

TestRegistryDefaultsMatchTheContract called scalarDefault before handling
null, and scalarDefault rejects null as non-scalar — so the subtest skipped
and the comparison after it was unreachable. A nullable contract default
could disagree with a non-empty registry default and nothing failed.
Confirmed by injecting that drift, which now reports it.

The three appearance providers each adapted the auth context to
AppearanceAuth with identical code, putting the shape of auth back in three
places that widening cache ownership would have to find. useAppearanceCacheOwner
now does it once.

useTheme.test.ts cleared storage.KEYS between cases, but appearanceCache
writes namespaced keys and an owner pointer that are not in that list, so
both survived and the suite was order-dependent. It clears the store, as
storage.test.ts already did.

The abs_smart_collection_store comment is reworded rather than given back its
SQL quotes: gofmt folds a pair of apostrophes in a doc comment into a
typographic quote, which is how it became one in the first place.

Reported by CodeRabbit review on #479.
2026-07-27 00:40:04 +00:00

191 lines
6.2 KiB
TypeScript

import { useCallback, useEffect, useRef, useState } from "react";
import { useSettings, useSetSetting } from "@/hooks/queries/settings";
import { useAppearanceCacheOwner } from "@/hooks/themePreferences";
import { appearanceCache, storage } from "@/utils/storage";
import { parseVarsJson } from "@/lib/themeExport";
import { sanitizeCss } from "@/lib/cssSanitizer";
import type { ThemeToken } from "@/lib/themeTokens";
export type ThemeVarOverrides = Partial<Record<ThemeToken, string>>;
interface UseCustomThemeResult {
vars: ThemeVarOverrides;
customCss: string;
/** Update a single token (instant, debounced persist). */
setVar: (token: ThemeToken, value: string) => void;
/** Remove a single token override. */
resetVar: (token: ThemeToken) => void;
/** Replace all variable overrides at once. */
setAllVars: (vars: ThemeVarOverrides) => void;
/** Update the raw CSS. */
setCustomCss: (css: string) => void;
/** Reset all custom overrides. */
resetAll: () => void;
/** Import a full set of overrides (from file or catalog). */
importOverrides: (vars: ThemeVarOverrides, css: string) => void;
/** Whether local state differs from last-persisted state. */
isDirty: boolean;
}
export function useCustomTheme(): UseCustomThemeResult {
// Owner of the localStorage warm start; null while auth bootstraps or when
// nobody is signed in, which keeps the last look on the login screen.
const cacheOwner = useAppearanceCacheOwner();
const loadApi = cacheOwner !== null;
// API values
const { data: apiSettings } = useSettings({ enabled: loadApi });
const apiVars = apiSettings?.ui_custom_theme_vars;
const apiCss = apiSettings?.ui_custom_css;
const settingMutation = useSetSetting();
// Local draft state (for instant updates without waiting for API)
const [localVars, setLocalVars] = useState<ThemeVarOverrides>(() =>
parseVarsJson(appearanceCache.get(storage.KEYS.UI_CUSTOM_THEME_VARS, cacheOwner)),
);
const [localCss, setLocalCss] = useState<string>(
() => appearanceCache.get(storage.KEYS.UI_CUSTOM_CSS, cacheOwner) ?? "",
);
const [isDirty, setIsDirty] = useState(false);
// Debounce timers
const varsTimerRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
const cssTimerRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
// A debounced persist closes over the account it was scheduled for. Drop any
// pending write when the account changes or the editor unmounts: otherwise a
// timer armed by the previous account fires against the new account's session
// and stores their predecessor's CSS under their name.
useEffect(() => {
return () => {
clearTimeout(varsTimerRef.current);
clearTimeout(cssTimerRef.current);
};
}, [cacheOwner]);
// This state was seeded for whoever was signed in when the hook mounted.
// Re-seed from the new owner's namespace when the account changes, so an
// account switch without a reload stops rendering the previous account's
// tokens even before their own settings arrive. Adjusted during render, so
// there is no frame in which the new account sees the old one's theme.
const [seededOwner, setSeededOwner] = useState(cacheOwner);
if (seededOwner !== cacheOwner) {
setSeededOwner(cacheOwner);
setLocalVars(parseVarsJson(appearanceCache.get(storage.KEYS.UI_CUSTOM_THEME_VARS, cacheOwner)));
setLocalCss(appearanceCache.get(storage.KEYS.UI_CUSTOM_CSS, cacheOwner) ?? "");
setIsDirty(false);
}
// Sync API values into local state when they arrive
useEffect(() => {
if (loadApi && apiVars !== undefined) {
const parsed = parseVarsJson(apiVars);
setLocalVars(parsed);
appearanceCache.set(storage.KEYS.UI_CUSTOM_THEME_VARS, JSON.stringify(parsed), cacheOwner);
}
}, [loadApi, apiVars, cacheOwner]);
useEffect(() => {
if (loadApi && apiCss !== undefined && apiCss !== null) {
setLocalCss(apiCss);
appearanceCache.set(storage.KEYS.UI_CUSTOM_CSS, apiCss, cacheOwner);
}
}, [loadApi, apiCss, cacheOwner]);
const persistVars = useCallback(
(vars: ThemeVarOverrides) => {
const json = JSON.stringify(vars);
appearanceCache.set(storage.KEYS.UI_CUSTOM_THEME_VARS, json, cacheOwner);
settingMutation.mutate({ key: "ui_custom_theme_vars", value: json });
},
[settingMutation, cacheOwner],
);
const persistCss = useCallback(
(css: string) => {
const safe = sanitizeCss(css);
appearanceCache.set(storage.KEYS.UI_CUSTOM_CSS, safe, cacheOwner);
settingMutation.mutate({ key: "ui_custom_css", value: safe });
},
[settingMutation, cacheOwner],
);
const setVar = useCallback(
(token: ThemeToken, value: string) => {
const next = { ...localVars, [token]: value };
setLocalVars(next);
setIsDirty(true);
clearTimeout(varsTimerRef.current);
varsTimerRef.current = setTimeout(() => {
persistVars(next);
setIsDirty(false);
}, 500);
},
[localVars, persistVars],
);
const resetVar = useCallback(
(token: ThemeToken) => {
const next = { ...localVars };
delete next[token];
setLocalVars(next);
persistVars(next);
setIsDirty(false);
},
[localVars, persistVars],
);
const setAllVars = useCallback(
(vars: ThemeVarOverrides) => {
setLocalVars(vars);
persistVars(vars);
setIsDirty(false);
},
[persistVars],
);
const setCustomCss = useCallback(
(css: string) => {
setLocalCss(css);
setIsDirty(true);
clearTimeout(cssTimerRef.current);
cssTimerRef.current = setTimeout(() => {
persistCss(css);
setIsDirty(false);
}, 1000);
},
[persistCss],
);
const resetAll = useCallback(() => {
setLocalVars({});
setLocalCss("");
persistVars({});
persistCss("");
setIsDirty(false);
}, [persistVars, persistCss]);
const importOverrides = useCallback(
(vars: ThemeVarOverrides, css: string) => {
setLocalVars(vars);
setLocalCss(css);
persistVars(vars);
persistCss(css);
setIsDirty(false);
},
[persistVars, persistCss],
);
return {
vars: localVars,
customCss: localCss,
setVar,
resetVar,
setAllVars,
setCustomCss,
resetAll,
importOverrides,
isDirty,
};
}