194 lines
5.4 KiB
TypeScript
194 lines
5.4 KiB
TypeScript
import { createContext, createEffect, createMemo, createSignal, onMount, useContext, type JSX } from "solid-js"
|
|
import { createTheme, ThemeProvider as MuiThemeProvider } from "@suid/material/styles"
|
|
import CssBaseline from "@suid/material/CssBaseline"
|
|
import { useConfig } from "../stores/preferences"
|
|
|
|
interface ThemeContextValue {
|
|
isDark: () => boolean
|
|
toggleTheme: () => void
|
|
setTheme: (dark: boolean) => void
|
|
}
|
|
|
|
const ThemeContext = createContext<ThemeContextValue>()
|
|
|
|
function applyTheme(dark: boolean) {
|
|
if (typeof document === "undefined") return
|
|
if (dark) {
|
|
document.documentElement.setAttribute("data-theme", "dark")
|
|
return
|
|
}
|
|
|
|
document.documentElement.removeAttribute("data-theme")
|
|
}
|
|
|
|
interface ResolvedPaletteColors {
|
|
backgroundDefault: string
|
|
backgroundPaper: string
|
|
primary: string
|
|
primaryContrast: string
|
|
textPrimary: string
|
|
textSecondary: string
|
|
divider: string
|
|
}
|
|
|
|
const lightPaletteFallbacks: ResolvedPaletteColors = {
|
|
backgroundDefault: "#ffffff",
|
|
backgroundPaper: "#f5f5f5",
|
|
primary: "#0066ff",
|
|
primaryContrast: "#ffffff",
|
|
textPrimary: "#1a1a1a",
|
|
textSecondary: "#666666",
|
|
divider: "#e0e0e0",
|
|
}
|
|
|
|
const darkPaletteFallbacks: ResolvedPaletteColors = {
|
|
backgroundDefault: "#1a1a1a",
|
|
backgroundPaper: "#2a2a2a",
|
|
primary: "#0080ff",
|
|
primaryContrast: "#1a1a1a",
|
|
textPrimary: "#cfd4dc",
|
|
textSecondary: "#999999",
|
|
divider: "#3a3a3a",
|
|
}
|
|
|
|
const readCssVar = (token: string, fallback: string, rootStyle: CSSStyleDeclaration | null) => {
|
|
if (!rootStyle) return fallback
|
|
const value = rootStyle.getPropertyValue(token)
|
|
if (!value) return fallback
|
|
const trimmed = value.trim()
|
|
return trimmed || fallback
|
|
}
|
|
|
|
const resolvePaletteColors = (dark: boolean): ResolvedPaletteColors => {
|
|
const fallbackSet = dark ? darkPaletteFallbacks : lightPaletteFallbacks
|
|
const rootStyle = typeof window !== "undefined" ? getComputedStyle(document.documentElement) : null
|
|
|
|
return {
|
|
backgroundDefault: readCssVar("--surface-base", fallbackSet.backgroundDefault, rootStyle),
|
|
backgroundPaper: readCssVar("--surface-secondary", fallbackSet.backgroundPaper, rootStyle),
|
|
primary: readCssVar("--accent-primary", fallbackSet.primary, rootStyle),
|
|
primaryContrast: readCssVar("--text-inverted", fallbackSet.primaryContrast, rootStyle),
|
|
textPrimary: readCssVar("--text-primary", fallbackSet.textPrimary, rootStyle),
|
|
textSecondary: readCssVar("--text-secondary", fallbackSet.textSecondary, rootStyle),
|
|
divider: readCssVar("--border-base", fallbackSet.divider, rootStyle),
|
|
}
|
|
}
|
|
|
|
export function ThemeProvider(props: { children: JSX.Element }) {
|
|
const mediaQuery = typeof window !== "undefined" ? window.matchMedia("(prefers-color-scheme: dark)") : null
|
|
const { themePreference, setThemePreference } = useConfig()
|
|
const [isDark, setIsDarkSignal] = createSignal(true)
|
|
|
|
const resolveDarkTheme = () => {
|
|
themePreference()
|
|
return true
|
|
}
|
|
|
|
const applyResolvedTheme = () => {
|
|
const dark = resolveDarkTheme()
|
|
setIsDarkSignal(dark)
|
|
applyTheme(dark)
|
|
}
|
|
|
|
createEffect(() => {
|
|
applyResolvedTheme()
|
|
})
|
|
|
|
onMount(() => {
|
|
if (!mediaQuery) return
|
|
const handleSystemThemeChange = () => {
|
|
applyResolvedTheme()
|
|
}
|
|
|
|
mediaQuery.addEventListener("change", handleSystemThemeChange)
|
|
|
|
return () => {
|
|
mediaQuery.removeEventListener("change", handleSystemThemeChange)
|
|
}
|
|
})
|
|
|
|
const setTheme = (_dark: boolean) => {
|
|
setThemePreference("dark")
|
|
}
|
|
|
|
const toggleTheme = () => {
|
|
setTheme(true)
|
|
}
|
|
|
|
const muiTheme = createMemo(() => {
|
|
const paletteColors = resolvePaletteColors(isDark())
|
|
return createTheme({
|
|
palette: {
|
|
mode: isDark() ? "dark" : "light",
|
|
primary: {
|
|
main: paletteColors.primary,
|
|
contrastText: paletteColors.primaryContrast,
|
|
},
|
|
secondary: {
|
|
main: paletteColors.primary,
|
|
},
|
|
background: {
|
|
default: paletteColors.backgroundDefault,
|
|
paper: paletteColors.backgroundPaper,
|
|
},
|
|
text: {
|
|
primary: paletteColors.textPrimary,
|
|
secondary: paletteColors.textSecondary,
|
|
},
|
|
divider: paletteColors.divider,
|
|
},
|
|
typography: {
|
|
fontFamily: "var(--font-family-sans)",
|
|
},
|
|
shape: {
|
|
borderRadius: 8,
|
|
},
|
|
components: {
|
|
MuiDrawer: {
|
|
styleOverrides: {
|
|
paper: {
|
|
backgroundColor: paletteColors.backgroundPaper,
|
|
color: paletteColors.textPrimary,
|
|
},
|
|
},
|
|
},
|
|
MuiAppBar: {
|
|
styleOverrides: {
|
|
root: {
|
|
backgroundColor: paletteColors.backgroundPaper,
|
|
color: paletteColors.textPrimary,
|
|
boxShadow: "none",
|
|
borderBottom: `1px solid ${paletteColors.divider}`,
|
|
zIndex: 10,
|
|
},
|
|
},
|
|
},
|
|
MuiToolbar: {
|
|
styleOverrides: {
|
|
root: {
|
|
minHeight: "56px",
|
|
},
|
|
},
|
|
},
|
|
} as any,
|
|
})
|
|
})
|
|
|
|
return (
|
|
<ThemeContext.Provider value={{ isDark, toggleTheme, setTheme }}>
|
|
<MuiThemeProvider theme={muiTheme()}>
|
|
<CssBaseline />
|
|
{props.children}
|
|
</MuiThemeProvider>
|
|
</ThemeContext.Provider>
|
|
)
|
|
}
|
|
|
|
export function useTheme() {
|
|
const context = useContext(ThemeContext)
|
|
if (!context) {
|
|
throw new Error("useTheme must be used within ThemeProvider")
|
|
}
|
|
return context
|
|
}
|