feat: add system adaptive theme

This commit is contained in:
Tautvydas Derzinskas committed 2026-09-11 15:14:52 +02:00
1 parent f903abcd29
commit 6e7323d78e
7 files changed
+58 -12

No files matched your search

+5 -3
View File
@@ -27,6 +27,8 @@ import { authApi, type AuthUser } from "./api/auth";
import { settingsApi, type PreviewMode } from "./api/settings";
import { clearToken, clearUser, readToken, readUser, storeToken, storeUser } from "./utils/auth";
import { type AppSettings, loadSettings, saveSettings } from "./utils/settings";
import { useResolvedTheme } from "./hooks/useResolvedTheme";
import type { ResolvedTheme } from "./constants/settingsOptions";
import { buildTheme } from "./theme";
const DEFAULT_REFRESH_SECONDS = 6 * 60 * 60; // 6 hours
@@ -39,7 +41,7 @@ type AppShellProps = {
setSettings: React.Dispatch<React.SetStateAction<AppSettings>>;
previewMode: PreviewMode;
setPreviewMode: (mode: PreviewMode) => void;
resolvedTheme: AppSettings["theme"]["selected"];
resolvedTheme: ResolvedTheme;
muiTheme: ReturnType<typeof buildTheme>;
onUnauthorized: () => void;
onLogout: () => void;
@@ -78,7 +80,7 @@ function AppShell({
return (
<AppLayout
muiTheme={muiTheme}
resolvedTheme={resolvedTheme}
themeSelection={settings.theme.selected}
apiUp={apiUp}
folderId={folderId}
onPrintsChanged={handlePrintsChanged}
@@ -179,7 +181,7 @@ export default function App() {
const [sessionExpired, setSessionExpired] = React.useState(false);
const [settings, setSettings] = React.useState<AppSettings>(() => loadSettings());
const [previewMode, setPreviewMode] = React.useState<PreviewMode>("automatic");
const resolvedTheme = settings.theme.selected;
const resolvedTheme = useResolvedTheme(settings.theme.selected);
const muiTheme = React.useMemo(() => buildTheme(resolvedTheme), [resolvedTheme]);
const isAdmin = user?.role === "ADMIN";
React.useEffect(() => { (async ()=> setHealth(await healthApi.get()))(); }, []);
+7 -4
View File
@@ -13,12 +13,15 @@ import { ToastProvider } from "../ToastProvider";
import { PageHeaderContext, type PageHeader } from "./PageHeaderContext";
import { ImportJobProvider } from "./ImportJobContext";
import { NotificationsProvider, useNotifications } from "./NotificationsContext";
import type { ResolvedTheme, ThemeSelection } from "../../constants/settingsOptions";
import type { ThemeSelection } from "../../constants/settingsOptions";
import type { AuthUser } from "../../api/auth";
type AppLayoutProps = {
muiTheme: Theme;
resolvedTheme: ResolvedTheme;
/** The user's raw persisted choice (light/dark/system), for UserMenu's theme submenu to show
* which one is checked -- NOT the resolved light/dark palette, which lives in muiTheme
* instead and has already collapsed "system" into a concrete value by this point. */
themeSelection: ThemeSelection;
apiUp: boolean | null;
folderId: string | null;
onPrintsChanged: () => void;
@@ -105,7 +108,7 @@ type ShellProps = Omit<AppLayoutProps, "muiTheme">;
* Split out from AppLayout because that wiring needs useNotifications(), which only works
* below the provider AppLayout itself renders. */
function AppLayoutShell({
resolvedTheme,
themeSelection,
apiUp,
folderId,
onPrintsChanged,
@@ -153,7 +156,7 @@ function AppLayoutShell({
onUploaded={onPrintsChanged}
onUnauthorized={onUnauthorized}
user={user}
theme={resolvedTheme}
theme={themeSelection}
onThemeChange={onThemeChange}
onOpenProfile={onOpenProfile}
onLogout={onLogout}
@@ -166,6 +166,11 @@ export function UserMenu({ user, theme, onThemeChange, onOpenProfile, onLogout }
<ListItemText>{t(`userMenu.${mode}`)}</ListItemText>
</MenuItem>
))}
<Divider />
<MenuItem selected={theme === "system"} onClick={() => handleThemeSelect("system")}>
<ListItemIcon>{theme === "system" && <CheckIcon fontSize="small" />}</ListItemIcon>
<ListItemText>{t("userMenu.system")}</ListItemText>
</MenuItem>
</Menu>
</>
);
+9 -5
View File
@@ -15,14 +15,18 @@ export const SLICER_OPTIONS: SlicerOption[] = [
{ id: "other", label: "Other / Manual" },
];
/** The user's persisted theme choice. There's no "system" option -- the user picks explicitly. */
export type ThemeSelection = "light" | "dark";
/** Alias kept for call sites written against the pre-reduction light/dark/neon/purple/blue
* palette -- now identical to ThemeSelection since every selection is already resolved. */
export type ResolvedTheme = ThemeSelection;
/** The user's persisted theme choice -- "system" tracks the OS/browser's prefers-color-scheme
* instead of a fixed pick. Never used directly for rendering; see useResolvedTheme, which turns
* this into a concrete ResolvedTheme. */
export type ThemeSelection = "light" | "dark" | "system";
/** The actual palette to render -- always a concrete light/dark, even when the user's
* ThemeSelection is "system". Every call site that picks an asset variant or palette value
* (BrandMark, ModelViewer, theme.ts's buildTheme, ...) wants this, not the raw selection. */
export type ResolvedTheme = "light" | "dark";
export type ThemeOption = { id: ThemeSelection; label: string; description: string };
export const THEME_OPTIONS: ThemeOption[] = [
{ id: "light", label: "Light", description: "Bright backgrounds, dark text." },
{ id: "dark", label: "Dark", description: "Dimmed panels for low light." },
{ id: "system", label: "Adapt to system", description: "Follows your device's light/dark setting." },
];
+30
View File
@@ -0,0 +1,30 @@
import { useEffect, useState } from "react";
import type { ResolvedTheme, ThemeSelection } from "../constants/settingsOptions";
const DARK_QUERY = "(prefers-color-scheme: dark)";
function systemPrefersDark(): boolean {
return typeof window !== "undefined" && typeof window.matchMedia === "function"
? window.matchMedia(DARK_QUERY).matches
: false;
}
/** Turns the user's persisted ThemeSelection into a concrete ResolvedTheme for rendering. "light"
* and "dark" pass straight through; "system" tracks window.matchMedia's prefers-color-scheme
* live, so switching the OS theme while "Adapt to system" is selected updates the app
* immediately, with no reload. */
export function useResolvedTheme(selection: ThemeSelection): ResolvedTheme {
const [systemDark, setSystemDark] = useState(systemPrefersDark);
useEffect(() => {
if (selection !== "system" || typeof window === "undefined" || typeof window.matchMedia !== "function") return;
const query = window.matchMedia(DARK_QUERY);
const handleChange = (e: MediaQueryListEvent) => setSystemDark(e.matches);
setSystemDark(query.matches);
query.addEventListener("change", handleChange);
return () => query.removeEventListener("change", handleChange);
}, [selection]);
if (selection === "system") return systemDark ? "dark" : "light";
return selection;
}
+1
View File
@@ -202,6 +202,7 @@
"theme": "Theme",
"light": "Light",
"dark": "Dark",
"system": "Adapt to system",
"serviceConnected": "{{service}}: connected",
"serviceNotConnected": "{{service}}: not connected",
"makerworldDisabledReason": "MakerWorld: you haven't added a cookie in your profile yet",
+1
View File
@@ -202,6 +202,7 @@
"theme": "Tema",
"light": "Šviesi",
"dark": "Tamsi",
"system": "Pagal sistemą",
"serviceConnected": "{{service}}: prijungta",
"serviceNotConnected": "{{service}}: neprijungta",
"makerworldDisabledReason": "MakerWorld: profilyje dar nepridėjote slapuko (cookie)",