This repository has been archived on 2026-08-17. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
beenvoice-web/src/components/providers/appearance-provider.tsx
T

110 lines
2.8 KiB
TypeScript

"use client";
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useState,
} from "react";
import { defaultColorMode, isColorMode, type ColorMode } from "~/lib/appearance";
export type AppearancePreferences = {
colorMode: ColorMode;
};
export type AppearancePatch = Partial<AppearancePreferences>;
export type AppearanceContextValue = AppearancePreferences & {
updateAppearance: (patch: AppearancePatch) => void;
isUpdating: boolean;
};
export const STORAGE_KEY = "bv.appearance";
export const defaultAppearance: AppearancePreferences = {
colorMode: defaultColorMode,
};
export const AppearanceContext =
createContext<AppearanceContextValue | null>(null);
export function readStoredColorMode(): ColorMode | null {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return null;
const parsed = JSON.parse(raw) as { colorMode?: unknown };
return isColorMode(parsed.colorMode) ? parsed.colorMode : null;
} catch {
return null;
}
}
export function writeStoredColorMode(colorMode: ColorMode) {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify({ colorMode }));
} catch {
// Storage can be unavailable in private browsing or locked-down contexts.
}
}
export function applyColorMode(colorMode: ColorMode) {
if (typeof document === "undefined") return;
const root = document.documentElement;
root.dataset.colorMode = colorMode;
root.classList.toggle("dark", colorMode === "dark");
}
/** Local-only appearance provider for marketing and auth pages (no tRPC). */
export function AppearanceProvider({
children,
}: {
children: React.ReactNode;
}) {
// Lazy initializer so the first render already matches what the inline
// appearance-init script set on <html> — a separate mount effect here
// would run one render behind it, transiently flashing (and persisting)
// colorMode back to the default before the effect's own state update lands.
const [colorMode, setColorMode] = useState<ColorMode>(
() => readStoredColorMode() ?? defaultColorMode,
);
useEffect(() => {
applyColorMode(colorMode);
writeStoredColorMode(colorMode);
}, [colorMode]);
const updateAppearance = useCallback((patch: AppearancePatch) => {
if (patch.colorMode) {
setColorMode(patch.colorMode);
applyColorMode(patch.colorMode);
writeStoredColorMode(patch.colorMode);
}
}, []);
const value = useMemo<AppearanceContextValue>(
() => ({
colorMode,
updateAppearance,
isUpdating: false,
}),
[colorMode, updateAppearance],
);
return (
<AppearanceContext.Provider value={value}>
{children}
</AppearanceContext.Provider>
);
}
export function useAppearance() {
const ctx = useContext(AppearanceContext);
if (!ctx) {
throw new Error("useAppearance must be used within an AppearanceProvider");
}
return ctx;
}