fix(theme): make preference state deterministic
This commit is contained in:
@@ -1,6 +1,8 @@
|
|||||||
import type { Metadata, Viewport } from "next";
|
import type { Metadata, Viewport } from "next";
|
||||||
|
import Script from "next/script";
|
||||||
|
|
||||||
import "../src/index.css";
|
import "../src/index.css";
|
||||||
|
import { THEME_BOOTSTRAP_SCRIPT } from "../src/themeBootstrap";
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "Jobbjakt",
|
title: "Jobbjakt",
|
||||||
@@ -23,8 +25,9 @@ export const viewport: Viewport = {
|
|||||||
|
|
||||||
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<html lang="en">
|
<html lang="en" suppressHydrationWarning>
|
||||||
<head>
|
<head>
|
||||||
|
<Script id="jobtracker-theme-bootstrap" strategy="beforeInteractive" dangerouslySetInnerHTML={{ __html: THEME_BOOTSTRAP_SCRIPT }} />
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossOrigin="anonymous" />
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossOrigin="anonymous" />
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Archivo:wght@400;500;600;700;800&display=swap" rel="stylesheet" />
|
<link href="https://fonts.googleapis.com/css2?family=Archivo:wght@400;500;600;700;800&display=swap" rel="stylesheet" />
|
||||||
|
|||||||
+22
-16
@@ -1,8 +1,7 @@
|
|||||||
import React, { Suspense, lazy, useEffect, useMemo, useState } from "react";
|
import React, { Suspense, createContext, lazy, useCallback, useContext, useEffect, useMemo, useState } from "react";
|
||||||
|
|
||||||
import { Box, Button, CssBaseline, IconButton, Typography } from "@mui/material";
|
import { Box, Button, IconButton, Typography } from "@mui/material";
|
||||||
import useMediaQuery from "@mui/material/useMediaQuery";
|
import useMediaQuery from "@mui/material/useMediaQuery";
|
||||||
import { CssVarsProvider } from "@mui/material/styles";
|
|
||||||
|
|
||||||
import DashboardIcon from "@mui/icons-material/Dashboard";
|
import DashboardIcon from "@mui/icons-material/Dashboard";
|
||||||
import WorkOutlineIcon from "@mui/icons-material/WorkOutline";
|
import WorkOutlineIcon from "@mui/icons-material/WorkOutline";
|
||||||
@@ -20,7 +19,6 @@ import DescriptionOutlinedIcon from "@mui/icons-material/DescriptionOutlined";
|
|||||||
|
|
||||||
import { Navigate, Route, Routes, useLocation, useNavigate, createBrowserRouter, RouterProvider } from "react-router-dom";
|
import { Navigate, Route, Routes, useLocation, useNavigate, createBrowserRouter, RouterProvider } from "react-router-dom";
|
||||||
|
|
||||||
import { getTheme } from "./theme";
|
|
||||||
import { ToastProvider } from "./toast";
|
import { ToastProvider } from "./toast";
|
||||||
import { ConfirmProvider } from "./confirm";
|
import { ConfirmProvider } from "./confirm";
|
||||||
import { PromptProvider } from "./prompt";
|
import { PromptProvider } from "./prompt";
|
||||||
@@ -40,7 +38,8 @@ import { resolveCaptureUrl } from "./captureUrl";
|
|||||||
import { clearAuthClientState, setAuthUserKey } from "./auth";
|
import { clearAuthClientState, setAuthUserKey } from "./auth";
|
||||||
import { AccountPlanProvider } from "./accountPlan";
|
import { AccountPlanProvider } from "./accountPlan";
|
||||||
import AppShell, { NavItem } from "./layout/AppShell";
|
import AppShell, { NavItem } from "./layout/AppShell";
|
||||||
import { getThemeModePref, setThemeModePref, ThemeModePref } from "./themePrefs";
|
import { getThemeModePref, setThemeModePref, subscribeToThemePreferenceChanges, ThemeModePref } from "./themePrefs";
|
||||||
|
import JobTrackerThemeProvider from "./ThemeProvider";
|
||||||
|
|
||||||
const AddJobModal = lazy(() => import("./components/AddJobModal"));
|
const AddJobModal = lazy(() => import("./components/AddJobModal"));
|
||||||
const KanbanBoard = lazy(() => import("./components/KanbanBoard"));
|
const KanbanBoard = lazy(() => import("./components/KanbanBoard"));
|
||||||
@@ -66,6 +65,8 @@ const OperationsPage = lazy(() => import("./views/OperationsPage"));
|
|||||||
const NotFoundPage = lazy(() => import("./views/NotFoundPage"));
|
const NotFoundPage = lazy(() => import("./views/NotFoundPage"));
|
||||||
|
|
||||||
type AuthConfig = { requireAuth: boolean };
|
type AuthConfig = { requireAuth: boolean };
|
||||||
|
type ThemeControls = { themeMode: ThemeModePref; onThemeModeChange: (value: ThemeModePref) => void };
|
||||||
|
const ThemeControlsContext = createContext<ThemeControls | null>(null);
|
||||||
type MeResponse = {
|
type MeResponse = {
|
||||||
provider?: "local" | "google" | "external";
|
provider?: "local" | "google" | "external";
|
||||||
id?: string;
|
id?: string;
|
||||||
@@ -383,19 +384,23 @@ function Shell({ jobPageSize, setJobPageSize, jobColumns, setJobColumns, themeMo
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ThemeAwareShell(props: Omit<React.ComponentProps<typeof Shell>, "themeMode" | "onThemeModeChange">) {
|
||||||
|
const controls = useContext(ThemeControlsContext);
|
||||||
|
if (!controls) throw new Error("Theme controls are unavailable.");
|
||||||
|
return <Shell {...props} {...controls} />;
|
||||||
|
}
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
const systemPrefersDark = useMediaQuery("(prefers-color-scheme: dark)", { defaultMatches: true, noSsr: true });
|
const systemPrefersDark = useMediaQuery("(prefers-color-scheme: dark)", { defaultMatches: true, noSsr: true });
|
||||||
const [themeMode, setThemeMode] = useState<ThemeModePref>(() => getThemeModePref());
|
const [themeMode, setThemeMode] = useState<ThemeModePref>(() => getThemeModePref());
|
||||||
const effectiveMode: "light" | "dark" = themeMode === "light" ? "light" : themeMode === "dark" ? "dark" : systemPrefersDark ? "dark" : "light";
|
|
||||||
const theme = useMemo(() => getTheme(effectiveMode), [effectiveMode]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const sync = () => { setThemeMode(getThemeModePref()); };
|
const sync = () => { setThemeMode(getThemeModePref()); };
|
||||||
window.addEventListener("auth-changed", sync);
|
return subscribeToThemePreferenceChanges(sync);
|
||||||
return () => window.removeEventListener("auth-changed", sync);
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const onThemeModeChange = (v: ThemeModePref) => { setThemeModePref(v); setThemeMode(v); };
|
const onThemeModeChange = useCallback((v: ThemeModePref) => { setThemeModePref(v); setThemeMode(v); }, []);
|
||||||
|
const themeControls = useMemo(() => ({ themeMode, onThemeModeChange }), [onThemeModeChange, themeMode]);
|
||||||
|
|
||||||
const [jobPageSize, setJobPageSize] = useState<15 | 20 | 25>(() => {
|
const [jobPageSize, setJobPageSize] = useState<15 | 20 | 25>(() => {
|
||||||
const raw = window.localStorage.getItem("jobPageSize");
|
const raw = window.localStorage.getItem("jobPageSize");
|
||||||
@@ -423,19 +428,20 @@ export default function App() {
|
|||||||
{ path: "/confirm-email-change", element: <VerifyEmailPage emailChange />, errorElement: <RouteErrorPage /> },
|
{ path: "/confirm-email-change", element: <VerifyEmailPage emailChange />, errorElement: <RouteErrorPage /> },
|
||||||
{ path: "/microsoft-legacy-relink", element: <MicrosoftLegacyRelinkPage />, errorElement: <RouteErrorPage /> },
|
{ path: "/microsoft-legacy-relink", element: <MicrosoftLegacyRelinkPage />, errorElement: <RouteErrorPage /> },
|
||||||
{ path: "/cv/:slug", element: <PublicCvPage />, errorElement: <RouteErrorPage /> },
|
{ path: "/cv/:slug", element: <PublicCvPage />, errorElement: <RouteErrorPage /> },
|
||||||
{ path: "/*", element: <Shell jobPageSize={jobPageSize} setJobPageSize={setJobPageSize} jobColumns={jobColumns} setJobColumns={setJobColumns} themeMode={themeMode} onThemeModeChange={onThemeModeChange} />, errorElement: <RouteErrorPage /> },
|
{ path: "/*", element: <ThemeAwareShell jobPageSize={jobPageSize} setJobPageSize={setJobPageSize} jobColumns={jobColumns} setJobColumns={setJobColumns} />, errorElement: <RouteErrorPage /> },
|
||||||
], { future: { v7_relativeSplatPath: true } }), [jobColumns, jobPageSize, themeMode]);
|
], { future: { v7_relativeSplatPath: true } }), [jobColumns, jobPageSize]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ToastProvider>
|
<ToastProvider>
|
||||||
<ConfirmProvider>
|
<ConfirmProvider>
|
||||||
<PromptProvider>
|
<PromptProvider>
|
||||||
<CssVarsProvider key={effectiveMode} theme={theme as any} defaultMode={effectiveMode} disableTransitionOnChange>
|
<JobTrackerThemeProvider preference={themeMode} systemPrefersDark={systemPrefersDark}>
|
||||||
<CssBaseline enableColorScheme />
|
|
||||||
<I18nProvider>
|
<I18nProvider>
|
||||||
<RouterProvider router={router} future={{ v7_startTransition: true }} />
|
<ThemeControlsContext.Provider value={themeControls}>
|
||||||
|
<RouterProvider router={router} future={{ v7_startTransition: true }} />
|
||||||
|
</ThemeControlsContext.Provider>
|
||||||
</I18nProvider>
|
</I18nProvider>
|
||||||
</CssVarsProvider>
|
</JobTrackerThemeProvider>
|
||||||
</PromptProvider>
|
</PromptProvider>
|
||||||
</ConfirmProvider>
|
</ConfirmProvider>
|
||||||
</ToastProvider>
|
</ToastProvider>
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import React, { useLayoutEffect, useMemo } from "react";
|
||||||
|
import { CssBaseline } from "@mui/material";
|
||||||
|
import { CssVarsProvider, useColorScheme } from "@mui/material/styles";
|
||||||
|
|
||||||
|
import { getTheme } from "./theme";
|
||||||
|
import { resolveEffectiveThemeMode, ThemeModePref } from "./themePrefs";
|
||||||
|
|
||||||
|
function ThemeModeSynchronizer({ mode }: { mode: "light" | "dark" }) {
|
||||||
|
const { setMode } = useColorScheme();
|
||||||
|
useLayoutEffect(() => { setMode(mode); }, [mode, setMode]);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function JobTrackerThemeProvider({ preference, systemPrefersDark, children }: { preference: ThemeModePref; systemPrefersDark: boolean; children: React.ReactNode }) {
|
||||||
|
const mode = resolveEffectiveThemeMode(preference, systemPrefersDark);
|
||||||
|
const theme = useMemo(() => getTheme("light"), []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CssVarsProvider theme={theme as any} defaultMode={mode} storageManager={null} disableTransitionOnChange>
|
||||||
|
<ThemeModeSynchronizer mode={mode} />
|
||||||
|
<CssBaseline enableColorScheme />
|
||||||
|
{children}
|
||||||
|
</CssVarsProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ export const AUTH_REMEMBER_ME_KEY = "authRememberMe";
|
|||||||
const AUTH_PERSISTENCE_KEY = "authTokenPersistence";
|
const AUTH_PERSISTENCE_KEY = "authTokenPersistence";
|
||||||
const AUTH_USER_KEY = "authUserKey";
|
const AUTH_USER_KEY = "authUserKey";
|
||||||
const AUTH_CSRF_COOKIE = "XSRF-TOKEN";
|
const AUTH_CSRF_COOKIE = "XSRF-TOKEN";
|
||||||
|
export const AUTH_USER_CHANGED_EVENT = "auth-user-changed";
|
||||||
|
|
||||||
export type AuthPersistence = "local" | "session";
|
export type AuthPersistence = "local" | "session";
|
||||||
|
|
||||||
@@ -72,12 +73,14 @@ export function getAuthUserKey(): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function setAuthUserKey(value: string | null | undefined, emit = true) {
|
export function setAuthUserKey(value: string | null | undefined, emit = true) {
|
||||||
|
const previous = getAuthUserKey();
|
||||||
const next = typeof value === "string" ? value.trim() : "";
|
const next = typeof value === "string" ? value.trim() : "";
|
||||||
if (!next) {
|
if (!next) {
|
||||||
safeRemove(window.localStorage, AUTH_USER_KEY);
|
safeRemove(window.localStorage, AUTH_USER_KEY);
|
||||||
} else {
|
} else {
|
||||||
safeSet(window.localStorage, AUTH_USER_KEY, next);
|
safeSet(window.localStorage, AUTH_USER_KEY, next);
|
||||||
}
|
}
|
||||||
|
if (previous !== (next || "anon")) window.dispatchEvent(new Event(AUTH_USER_CHANGED_EVENT));
|
||||||
if (emit) emitAuthChanged();
|
if (emit) emitAuthChanged();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -89,6 +92,7 @@ export function clearAuthClientState(emit = true) {
|
|||||||
// the user is logged out (login page, expired session).
|
// the user is logged out (login page, expired session).
|
||||||
const had = safeGet(window.localStorage, AUTH_USER_KEY) != null;
|
const had = safeGet(window.localStorage, AUTH_USER_KEY) != null;
|
||||||
safeRemove(window.localStorage, AUTH_USER_KEY);
|
safeRemove(window.localStorage, AUTH_USER_KEY);
|
||||||
|
if (had) window.dispatchEvent(new Event(AUTH_USER_CHANGED_EVENT));
|
||||||
if (emit && had) emitAuthChanged();
|
if (emit && had) emitAuthChanged();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -110,7 +110,7 @@ export default function SettingsView({
|
|||||||
{t("settingsSubtitle")}
|
{t("settingsSubtitle")}
|
||||||
</Typography>
|
</Typography>
|
||||||
|
|
||||||
<Tabs value={tab} onChange={(_, v) => setTab(v)} sx={{ mb: 1 }}>
|
<Tabs value={tab} onChange={(_, v) => setTab(v)} variant="scrollable" scrollButtons="auto" allowScrollButtonsMobile sx={{ mb: 1 }}>
|
||||||
<Tab label={t("settingsTabGeneral")} />
|
<Tab label={t("settingsTabGeneral")} />
|
||||||
<Tab label={t("settingsTabFollowUps")} />
|
<Tab label={t("settingsTabFollowUps")} />
|
||||||
<Tab label={t("settingsTabNotifications")} />
|
<Tab label={t("settingsTabNotifications")} />
|
||||||
|
|||||||
@@ -0,0 +1,123 @@
|
|||||||
|
import React, { useEffect, useState } from "react";
|
||||||
|
import "@testing-library/jest-dom";
|
||||||
|
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||||
|
|
||||||
|
import { clearAuthClientState, setAuthUserKey } from "./auth";
|
||||||
|
import JobTrackerThemeProvider from "./ThemeProvider";
|
||||||
|
import { THEME_BOOTSTRAP_SCRIPT } from "./themeBootstrap";
|
||||||
|
import {
|
||||||
|
getThemeModePref,
|
||||||
|
resolveEffectiveThemeMode,
|
||||||
|
setThemeModePref,
|
||||||
|
subscribeToThemePreferenceChanges,
|
||||||
|
} from "./themePrefs";
|
||||||
|
|
||||||
|
function setSystemDark(matches: boolean) {
|
||||||
|
Object.defineProperty(window, "matchMedia", {
|
||||||
|
configurable: true,
|
||||||
|
value: jest.fn().mockImplementation(() => ({
|
||||||
|
matches,
|
||||||
|
media: "(prefers-color-scheme: dark)",
|
||||||
|
onchange: null,
|
||||||
|
addEventListener: jest.fn(),
|
||||||
|
removeEventListener: jest.fn(),
|
||||||
|
addListener: jest.fn(),
|
||||||
|
removeListener: jest.fn(),
|
||||||
|
dispatchEvent: jest.fn(),
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("deterministic theme state", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
window.localStorage.clear();
|
||||||
|
document.documentElement.removeAttribute("data-color-scheme");
|
||||||
|
document.documentElement.style.colorScheme = "";
|
||||||
|
setSystemDark(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses user preference, then anonymous preference, then the documented System default", () => {
|
||||||
|
expect(getThemeModePref()).toBe("system");
|
||||||
|
setThemeModePref("light");
|
||||||
|
expect(getThemeModePref()).toBe("light");
|
||||||
|
|
||||||
|
setAuthUserKey("user-a", false);
|
||||||
|
expect(getThemeModePref()).toBe("light");
|
||||||
|
setThemeModePref("dark");
|
||||||
|
expect(getThemeModePref()).toBe("dark");
|
||||||
|
|
||||||
|
setAuthUserKey("user-b", false);
|
||||||
|
expect(getThemeModePref()).toBe("light");
|
||||||
|
window.localStorage.removeItem("themeMode:anon");
|
||||||
|
expect(getThemeModePref()).toBe("system");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("switches to the correct scoped preference on login and logout without a refresh", () => {
|
||||||
|
window.localStorage.setItem("themeMode:anon", "light");
|
||||||
|
window.localStorage.setItem("themeMode:user-a", "dark");
|
||||||
|
const observed: string[] = [];
|
||||||
|
const unsubscribe = subscribeToThemePreferenceChanges(() => observed.push(getThemeModePref()));
|
||||||
|
|
||||||
|
setAuthUserKey("user-a", false);
|
||||||
|
clearAuthClientState(false);
|
||||||
|
|
||||||
|
expect(observed).toEqual(["dark", "light"]);
|
||||||
|
unsubscribe();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("synchronizes relevant cross-tab storage changes without writing them back", () => {
|
||||||
|
setAuthUserKey("user-a", false);
|
||||||
|
const sync = jest.fn();
|
||||||
|
const unsubscribe = subscribeToThemePreferenceChanges(sync);
|
||||||
|
|
||||||
|
window.dispatchEvent(new StorageEvent("storage", { key: "themeMode:user-a", newValue: "dark" }));
|
||||||
|
window.dispatchEvent(new StorageEvent("storage", { key: "unrelated", newValue: "value" }));
|
||||||
|
window.dispatchEvent(new StorageEvent("storage", { key: "authUserKey", newValue: "user-b" }));
|
||||||
|
|
||||||
|
expect(sync).toHaveBeenCalledTimes(2);
|
||||||
|
unsubscribe();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses system preference only for System mode", () => {
|
||||||
|
expect(resolveEffectiveThemeMode("light", true)).toBe("light");
|
||||||
|
expect(resolveEffectiveThemeMode("dark", false)).toBe("dark");
|
||||||
|
expect(resolveEffectiveThemeMode("system", true)).toBe("dark");
|
||||||
|
expect(resolveEffectiveThemeMode("system", false)).toBe("light");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("applies the saved preference before the client application renders", () => {
|
||||||
|
window.localStorage.setItem("themeMode:anon", "light");
|
||||||
|
setSystemDark(true);
|
||||||
|
|
||||||
|
new Function(THEME_BOOTSTRAP_SCRIPT)();
|
||||||
|
|
||||||
|
expect(document.documentElement).toHaveAttribute("data-color-scheme", "light");
|
||||||
|
expect(document.documentElement.style.colorScheme).toBe("light");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("changes color scheme without remounting application state", async () => {
|
||||||
|
let mounts = 0;
|
||||||
|
function StatefulProbe() {
|
||||||
|
const [count, setCount] = useState(0);
|
||||||
|
useEffect(() => { mounts += 1; }, []);
|
||||||
|
return <button onClick={() => setCount((value) => value + 1)}>Count {count}</button>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const view = render(
|
||||||
|
<JobTrackerThemeProvider preference="light" systemPrefersDark>
|
||||||
|
<StatefulProbe />
|
||||||
|
</JobTrackerThemeProvider>,
|
||||||
|
);
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Count 0" }));
|
||||||
|
|
||||||
|
view.rerender(
|
||||||
|
<JobTrackerThemeProvider preference="dark" systemPrefersDark={false}>
|
||||||
|
<StatefulProbe />
|
||||||
|
</JobTrackerThemeProvider>,
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(() => expect(document.documentElement).toHaveAttribute("data-color-scheme", "dark"));
|
||||||
|
expect(screen.getByRole("button", { name: "Count 1" })).toBeInTheDocument();
|
||||||
|
expect(mounts).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
export const THEME_BOOTSTRAP_SCRIPT = `(() => {
|
||||||
|
try {
|
||||||
|
const read = (key) => {
|
||||||
|
const value = window.localStorage.getItem(key);
|
||||||
|
return value === "light" || value === "dark" || value === "system" ? value : null;
|
||||||
|
};
|
||||||
|
const userKey = window.localStorage.getItem("authUserKey") || "anon";
|
||||||
|
const preference = read("themeMode:" + userKey) || (userKey !== "anon" ? read("themeMode:anon") : null) || "system";
|
||||||
|
const mode = preference === "dark" || (preference === "system" && window.matchMedia("(prefers-color-scheme: dark)").matches) ? "dark" : "light";
|
||||||
|
document.documentElement.setAttribute("data-color-scheme", mode);
|
||||||
|
document.documentElement.style.colorScheme = mode;
|
||||||
|
} catch {
|
||||||
|
const mode = window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
|
||||||
|
document.documentElement.setAttribute("data-color-scheme", mode);
|
||||||
|
document.documentElement.style.colorScheme = mode;
|
||||||
|
}
|
||||||
|
})();`;
|
||||||
@@ -1,6 +1,10 @@
|
|||||||
import { getAuthUserKey } from "./auth";
|
import { AUTH_USER_CHANGED_EVENT, getAuthUserKey } from "./auth";
|
||||||
|
|
||||||
export type ThemeModePref = "system" | "light" | "dark";
|
export type ThemeModePref = "system" | "light" | "dark";
|
||||||
|
export type EffectiveThemeMode = "light" | "dark";
|
||||||
|
|
||||||
|
export const DEFAULT_THEME_MODE: ThemeModePref = "system";
|
||||||
|
const THEME_KEY_PREFIX = "themeMode:";
|
||||||
|
|
||||||
export function getUserKeyFromToken(): string {
|
export function getUserKeyFromToken(): string {
|
||||||
return getAuthUserKey();
|
return getAuthUserKey();
|
||||||
@@ -10,12 +14,47 @@ function k(base: string) {
|
|||||||
return `${base}:${getUserKeyFromToken()}`;
|
return `${base}:${getUserKeyFromToken()}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function readThemeMode(key: string): ThemeModePref | null {
|
||||||
|
try {
|
||||||
|
const raw = window.localStorage.getItem(key);
|
||||||
|
return raw === "light" || raw === "dark" || raw === "system" ? raw : null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function getThemeModePref(): ThemeModePref {
|
export function getThemeModePref(): ThemeModePref {
|
||||||
const raw = window.localStorage.getItem(k("themeMode"));
|
const userKey = getUserKeyFromToken();
|
||||||
if (raw === "light" || raw === "dark" || raw === "system") return raw;
|
const savedUserMode = readThemeMode(`${THEME_KEY_PREFIX}${userKey}`);
|
||||||
return "system";
|
if (savedUserMode) return savedUserMode;
|
||||||
|
if (userKey !== "anon") {
|
||||||
|
const anonymousMode = readThemeMode(`${THEME_KEY_PREFIX}anon`);
|
||||||
|
if (anonymousMode) return anonymousMode;
|
||||||
|
}
|
||||||
|
return DEFAULT_THEME_MODE;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function setThemeModePref(v: ThemeModePref) {
|
export function setThemeModePref(v: ThemeModePref) {
|
||||||
window.localStorage.setItem(k("themeMode"), v);
|
try {
|
||||||
|
window.localStorage.setItem(k("themeMode"), v);
|
||||||
|
} catch {
|
||||||
|
// Keep the in-memory selection even when storage is unavailable.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveEffectiveThemeMode(preference: ThemeModePref, systemPrefersDark: boolean): EffectiveThemeMode {
|
||||||
|
if (preference === "light" || preference === "dark") return preference;
|
||||||
|
return systemPrefersDark ? "dark" : "light";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function subscribeToThemePreferenceChanges(sync: () => void) {
|
||||||
|
const onStorage = (event: StorageEvent) => {
|
||||||
|
if (event.key === "authUserKey" || event.key?.startsWith(THEME_KEY_PREFIX)) sync();
|
||||||
|
};
|
||||||
|
window.addEventListener(AUTH_USER_CHANGED_EVENT, sync);
|
||||||
|
window.addEventListener("storage", onStorage);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener(AUTH_USER_CHANGED_EVENT, sync);
|
||||||
|
window.removeEventListener("storage", onStorage);
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user