feat: complete release readiness work #28
@@ -1,6 +1,8 @@
|
||||
import type { Metadata, Viewport } from "next";
|
||||
import Script from "next/script";
|
||||
|
||||
import "../src/index.css";
|
||||
import { THEME_BOOTSTRAP_SCRIPT } from "../src/themeBootstrap";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Jobbjakt",
|
||||
@@ -23,8 +25,9 @@ export const viewport: Viewport = {
|
||||
|
||||
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<html lang="en" suppressHydrationWarning>
|
||||
<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.gstatic.com" crossOrigin="anonymous" />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Archivo:wght@400;500;600;700;800&display=swap" rel="stylesheet" />
|
||||
|
||||
+21
-15
@@ -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 { CssVarsProvider } from "@mui/material/styles";
|
||||
|
||||
import DashboardIcon from "@mui/icons-material/Dashboard";
|
||||
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 { getTheme } from "./theme";
|
||||
import { ToastProvider } from "./toast";
|
||||
import { ConfirmProvider } from "./confirm";
|
||||
import { PromptProvider } from "./prompt";
|
||||
@@ -40,7 +38,8 @@ import { resolveCaptureUrl } from "./captureUrl";
|
||||
import { clearAuthClientState, setAuthUserKey } from "./auth";
|
||||
import { AccountPlanProvider } from "./accountPlan";
|
||||
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 KanbanBoard = lazy(() => import("./components/KanbanBoard"));
|
||||
@@ -66,6 +65,8 @@ const OperationsPage = lazy(() => import("./views/OperationsPage"));
|
||||
const NotFoundPage = lazy(() => import("./views/NotFoundPage"));
|
||||
|
||||
type AuthConfig = { requireAuth: boolean };
|
||||
type ThemeControls = { themeMode: ThemeModePref; onThemeModeChange: (value: ThemeModePref) => void };
|
||||
const ThemeControlsContext = createContext<ThemeControls | null>(null);
|
||||
type MeResponse = {
|
||||
provider?: "local" | "google" | "external";
|
||||
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() {
|
||||
const systemPrefersDark = useMediaQuery("(prefers-color-scheme: dark)", { defaultMatches: true, noSsr: true });
|
||||
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(() => {
|
||||
const sync = () => { setThemeMode(getThemeModePref()); };
|
||||
window.addEventListener("auth-changed", sync);
|
||||
return () => window.removeEventListener("auth-changed", sync);
|
||||
return subscribeToThemePreferenceChanges(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 raw = window.localStorage.getItem("jobPageSize");
|
||||
@@ -423,19 +428,20 @@ export default function App() {
|
||||
{ path: "/confirm-email-change", element: <VerifyEmailPage emailChange />, errorElement: <RouteErrorPage /> },
|
||||
{ path: "/microsoft-legacy-relink", element: <MicrosoftLegacyRelinkPage />, 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 /> },
|
||||
], { future: { v7_relativeSplatPath: true } }), [jobColumns, jobPageSize, themeMode]);
|
||||
{ path: "/*", element: <ThemeAwareShell jobPageSize={jobPageSize} setJobPageSize={setJobPageSize} jobColumns={jobColumns} setJobColumns={setJobColumns} />, errorElement: <RouteErrorPage /> },
|
||||
], { future: { v7_relativeSplatPath: true } }), [jobColumns, jobPageSize]);
|
||||
|
||||
return (
|
||||
<ToastProvider>
|
||||
<ConfirmProvider>
|
||||
<PromptProvider>
|
||||
<CssVarsProvider key={effectiveMode} theme={theme as any} defaultMode={effectiveMode} disableTransitionOnChange>
|
||||
<CssBaseline enableColorScheme />
|
||||
<JobTrackerThemeProvider preference={themeMode} systemPrefersDark={systemPrefersDark}>
|
||||
<I18nProvider>
|
||||
<ThemeControlsContext.Provider value={themeControls}>
|
||||
<RouterProvider router={router} future={{ v7_startTransition: true }} />
|
||||
</ThemeControlsContext.Provider>
|
||||
</I18nProvider>
|
||||
</CssVarsProvider>
|
||||
</JobTrackerThemeProvider>
|
||||
</PromptProvider>
|
||||
</ConfirmProvider>
|
||||
</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_USER_KEY = "authUserKey";
|
||||
const AUTH_CSRF_COOKIE = "XSRF-TOKEN";
|
||||
export const AUTH_USER_CHANGED_EVENT = "auth-user-changed";
|
||||
|
||||
export type AuthPersistence = "local" | "session";
|
||||
|
||||
@@ -72,12 +73,14 @@ export function getAuthUserKey(): string {
|
||||
}
|
||||
|
||||
export function setAuthUserKey(value: string | null | undefined, emit = true) {
|
||||
const previous = getAuthUserKey();
|
||||
const next = typeof value === "string" ? value.trim() : "";
|
||||
if (!next) {
|
||||
safeRemove(window.localStorage, AUTH_USER_KEY);
|
||||
} else {
|
||||
safeSet(window.localStorage, AUTH_USER_KEY, next);
|
||||
}
|
||||
if (previous !== (next || "anon")) window.dispatchEvent(new Event(AUTH_USER_CHANGED_EVENT));
|
||||
if (emit) emitAuthChanged();
|
||||
}
|
||||
|
||||
@@ -89,6 +92,7 @@ export function clearAuthClientState(emit = true) {
|
||||
// the user is logged out (login page, expired session).
|
||||
const had = safeGet(window.localStorage, AUTH_USER_KEY) != null;
|
||||
safeRemove(window.localStorage, AUTH_USER_KEY);
|
||||
if (had) window.dispatchEvent(new Event(AUTH_USER_CHANGED_EVENT));
|
||||
if (emit && had) emitAuthChanged();
|
||||
}
|
||||
|
||||
|
||||
@@ -110,7 +110,7 @@ export default function SettingsView({
|
||||
{t("settingsSubtitle")}
|
||||
</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("settingsTabFollowUps")} />
|
||||
<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 EffectiveThemeMode = "light" | "dark";
|
||||
|
||||
export const DEFAULT_THEME_MODE: ThemeModePref = "system";
|
||||
const THEME_KEY_PREFIX = "themeMode:";
|
||||
|
||||
export function getUserKeyFromToken(): string {
|
||||
return getAuthUserKey();
|
||||
@@ -10,12 +14,47 @@ function k(base: string) {
|
||||
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 {
|
||||
const raw = window.localStorage.getItem(k("themeMode"));
|
||||
if (raw === "light" || raw === "dark" || raw === "system") return raw;
|
||||
return "system";
|
||||
const userKey = getUserKeyFromToken();
|
||||
const savedUserMode = readThemeMode(`${THEME_KEY_PREFIX}${userKey}`);
|
||||
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) {
|
||||
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