mirror of
https://github.com/TautvydasDerzinskas/Thingport.git
synced 2026-10-18 20:59:55 -05:00
feat: add system adaptive theme
This commit is contained in:
1 parent
f903abcd29
commit
6e7323d78e
7 files changed
+58
-12
No files matched your search
@@ -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()))(); }, []);
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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." },
|
||||
];
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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)",
|
||||
|
||||
Reference in new issue
Block a user