src/components/appearance-provider.tsx
Linked repository file from this build.
"use client";
/* oxlint-disable react/set-state-in-effect -- Synchronize local UI with browser storage and preferences. */
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import {
applyAppearance,
defaultAppearance,
normalizeAppearance,
readStoredAppearance,
storeAppearance,
type Appearance,
} from "@/lib/appearance";
interface AppearanceContextValue {
appearance: Appearance;
layoutChanging: boolean;
sidebarOpen: boolean;
setSidebarOpen(value: boolean): void;
/** Ready once the stored preference has been read on the client. */
ready: boolean;
/** User-initiated change: applies, stores on the device and notifies account sync. */
change(patch: Partial<Appearance>): void;
/** Remote-initiated change (account preferences): applies and stores without re-notifying. */
sync(value: unknown): void;
}
const AppearanceContext = createContext<AppearanceContextValue | null>(null);
const appearanceSubscribers = new Set<(appearance: Appearance) => void>();
export function subscribeAppearanceChanges(subscriber: (appearance: Appearance) => void) {
appearanceSubscribers.add(subscriber);
return () => {
appearanceSubscribers.delete(subscriber);
};
}
export function AppearanceProvider({ children }: { children: React.ReactNode }) {
const [appearance, setAppearance] = useState<Appearance>(defaultAppearance);
const currentAppearance = useRef(defaultAppearance);
const [ready, setReady] = useState(false);
const [sidebarOpen, setSidebarOpen] = useState(false);
const [layoutChanging, setLayoutChanging] = useState(false);
const layoutLock = useRef(false);
useEffect(() => {
if (!layoutChanging) return;
let active = true;
let frame = requestAnimationFrame(() => {
frame = requestAnimationFrame(async () => {
const animations = [...document.querySelectorAll(".app-nav, .app-shell")].flatMap(
(element) => element.getAnimations(),
);
await Promise.allSettled(animations.map((animation) => animation.finished));
if (active) {
layoutLock.current = false;
setLayoutChanging(false);
}
});
});
return () => {
active = false;
cancelAnimationFrame(frame);
};
}, [layoutChanging]);
useEffect(() => {
const stored = readStoredAppearance();
currentAppearance.current = stored;
setAppearance(stored);
applyAppearance(stored);
setReady(true);
}, []);
useEffect(() => {
const media = window.matchMedia("(prefers-color-scheme: dark)");
const update = () => applyAppearance(appearance);
media.addEventListener("change", update);
return () => media.removeEventListener("change", update);
}, [appearance]);
const change = useCallback((patch: Partial<Appearance>) => {
if (patch.layout && patch.layout !== currentAppearance.current.layout) {
if (layoutLock.current || document.querySelector('.app-main[aria-busy="true"]')) return;
layoutLock.current = true;
setLayoutChanging(true);
}
const next = normalizeAppearance({ ...currentAppearance.current, ...patch });
currentAppearance.current = next;
setAppearance(next);
applyAppearance(next);
storeAppearance(next);
for (const subscriber of appearanceSubscribers) subscriber(next);
}, []);
const sync = useCallback((value: unknown) => {
const next = normalizeAppearance(value);
currentAppearance.current = next;
setAppearance(next);
applyAppearance(next);
storeAppearance(next);
}, []);
const context = useMemo(
() => ({ appearance, ready, change, sync, sidebarOpen, setSidebarOpen, layoutChanging }),
[appearance, ready, change, sync, sidebarOpen, layoutChanging],
);
return <AppearanceContext.Provider value={context}>{children}</AppearanceContext.Provider>;
}
export function useAppearance(): AppearanceContextValue {
const context = useContext(AppearanceContext);
if (!context) throw new Error("useAppearance requires AppearanceProvider.");
return context;
}