From fbaa6a6cf1f4bffb36f9a20289a7914e511ed9e0 Mon Sep 17 00:00:00 2001 From: Michael Landry Date: Sun, 28 Sep 2025 08:01:54 -0400 Subject: [PATCH] Rework theme provider to provide the actual Theme object throughtout the app, in addition to the name Split name out of the Theme struct to simplify custom theme saving/reading --- client/app/components/ActivityGrid.tsx | 38 ++-- .../components/themeSwitcher/ThemeOption.tsx | 9 +- .../themeSwitcher/ThemeSwitcher.tsx | 22 +-- client/app/providers/ThemeProvider.tsx | 86 +++++---- client/app/routes/ThemeHelper.tsx | 4 - client/app/styles/themes.css.ts | 171 ++++++++---------- 6 files changed, 169 insertions(+), 161 deletions(-) diff --git a/client/app/components/ActivityGrid.tsx b/client/app/components/ActivityGrid.tsx index 4782ae3..966b4b5 100644 --- a/client/app/components/ActivityGrid.tsx +++ b/client/app/components/ActivityGrid.tsx @@ -4,15 +4,31 @@ import Popup from "./Popup" import { useState } from "react" import { useTheme } from "~/hooks/useTheme" import ActivityOptsSelector from "./ActivityOptsSelector" -import { themes } from "~/styles/themes.css" +import type { Theme } from "~/styles/themes.css" + +function getPrimaryColor(theme: Theme): string { + const value = theme.primary; + const rgbMatch = value.match(/^rgb\(\s*(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(\d{1,3})\s*\)$/); + if (rgbMatch) { + const [, r, g, b] = rgbMatch.map(Number); + return ( + '#' + + [r, g, b] + .map((n) => n.toString(16).padStart(2, '0')) + .join('') + ); + } + + return value; +} interface Props { - step?: string - range?: number - month?: number - year?: number - artistId?: number - albumId?: number + step?: string + range?: number + month?: number + year?: number + artistId?: number + albumId?: number trackId?: number configurable?: boolean autoAdjust?: boolean @@ -49,9 +65,9 @@ export default function ActivityGrid({ }); - const { theme } = useTheme(); - const currentTheme = themes.find(t => t.name === theme); - const color = currentTheme?.primary || '#f5a97f'; + const { theme, themeName } = useTheme(); + const color = getPrimaryColor(theme); + if (isPending) { return ( @@ -108,7 +124,7 @@ export default function ActivityGrid({ } v = Math.min(v, t) - if (theme === "pearl") { + if (themeName === "pearl") { // special case for the only light theme lol // could be generalized by pragmatically comparing the // lightness of the bg vs the primary but eh diff --git a/client/app/components/themeSwitcher/ThemeOption.tsx b/client/app/components/themeSwitcher/ThemeOption.tsx index 224fcce..51b9acf 100644 --- a/client/app/components/themeSwitcher/ThemeOption.tsx +++ b/client/app/components/themeSwitcher/ThemeOption.tsx @@ -1,19 +1,20 @@ -import type { Theme } from "~/providers/ThemeProvider"; +import type { Theme } from "~/styles/themes.css"; interface Props { theme: Theme + themeName: string setTheme: Function } -export default function ThemeOption({ theme, setTheme }: Props) { +export default function ThemeOption({ theme, themeName, setTheme }: Props) { const capitalizeFirstLetter = (s: string) => { return s.charAt(0).toUpperCase() + s.slice(1); } return ( -
setTheme(theme.name)} className="rounded-md p-3 sm:p-5 hover:cursor-pointer flex gap-4 items-center border-2" style={{background: theme.bg, color: theme.fg, borderColor: theme.bgSecondary}}> -
{capitalizeFirstLetter(theme.name)}
+
setTheme(themeName)} className="rounded-md p-3 sm:p-5 hover:cursor-pointer flex gap-4 items-center border-2" style={{background: theme.bg, color: theme.fg, borderColor: theme.bgSecondary}}> +
{capitalizeFirstLetter(themeName)}
diff --git a/client/app/components/themeSwitcher/ThemeSwitcher.tsx b/client/app/components/themeSwitcher/ThemeSwitcher.tsx index 14eda1e..fdc8709 100644 --- a/client/app/components/themeSwitcher/ThemeSwitcher.tsx +++ b/client/app/components/themeSwitcher/ThemeSwitcher.tsx @@ -6,7 +6,7 @@ import ThemeOption from './ThemeOption'; import { AsyncButton } from '../AsyncButton'; export function ThemeSwitcher() { - const { theme, setTheme } = useTheme(); + const { theme, themeName, setTheme } = useTheme(); const initialTheme = { bg: "#1e1816", bgSecondary: "#2f2623", @@ -30,30 +30,22 @@ export function ThemeSwitcher() { const handleCustomTheme = () => { console.log(custom) try { - const theme = JSON.parse(custom) - theme.name = "custom" - setCustomTheme(theme) - delete theme.name - setCustom(JSON.stringify(theme, null, " ")) - console.log(theme) + const themeData = JSON.parse(custom) + setCustomTheme(themeData) + setCustom(JSON.stringify(themeData, null, " ")) + console.log(themeData) } catch(err) { console.log(err) } } - useEffect(() => { - if (theme) { - setTheme(theme) - } - }, [theme]); - return (

Select Theme

- {themes.map((t) => ( - + {Object.entries(themes).map(([name, themeData]) => ( + ))}
diff --git a/client/app/providers/ThemeProvider.tsx b/client/app/providers/ThemeProvider.tsx index 52d9ef9..1a4f9e8 100644 --- a/client/app/providers/ThemeProvider.tsx +++ b/client/app/providers/ThemeProvider.tsx @@ -1,9 +1,10 @@ import { createContext, useEffect, useState, useCallback, type ReactNode } from 'react'; -import { type Theme } from '~/styles/themes.css'; +import { type Theme, themes } from '~/styles/themes.css'; import { themeVars } from '~/styles/vars.css'; interface ThemeContextValue { - theme: string; + themeName: string; + theme: Theme; setTheme: (theme: string) => void; setCustomTheme: (theme: Theme) => void; getCustomTheme: () => Theme | undefined; @@ -29,6 +30,18 @@ function clearCustomThemeVars() { } } +function getStoredCustomTheme(): Theme | undefined { + const themeStr = localStorage.getItem('custom-theme'); + if (!themeStr) return undefined; + try { + const parsed = JSON.parse(themeStr); + const { name, ...theme } = parsed; + return theme as Theme; + } catch { + return undefined; + } +} + export function ThemeProvider({ theme: initialTheme, children, @@ -36,57 +49,60 @@ export function ThemeProvider({ theme: string; children: ReactNode; }) { - const [theme, setThemeName] = useState(initialTheme); + const [themeName, setThemeName] = useState(initialTheme); + const [currentTheme, setCurrentTheme] = useState(() => { + if (initialTheme === 'custom') { + const customTheme = getStoredCustomTheme(); + return customTheme || themes.yuu; + } + return themes[initialTheme] || themes.yuu; + }); - const setTheme = (theme: string) => { - setThemeName(theme) + const setTheme = (newThemeName: string) => { + setThemeName(newThemeName); + if (newThemeName === 'custom') { + const customTheme = getStoredCustomTheme(); + if (customTheme) { + setCurrentTheme(customTheme); + } else { + // Fallback to default theme if no custom theme found + setThemeName('yuu'); + setCurrentTheme(themes.yuu); + } + } else { + const foundTheme = themes[newThemeName]; + if (foundTheme) { + setCurrentTheme(foundTheme); + } + } } const setCustomTheme = useCallback((customTheme: Theme) => { localStorage.setItem('custom-theme', JSON.stringify(customTheme)); applyCustomThemeVars(customTheme); - setTheme('custom'); + setThemeName('custom'); + setCurrentTheme(customTheme); }, []); - const getCustomTheme = (): Theme | undefined => { - const themeStr = localStorage.getItem('custom-theme'); - if (!themeStr) { - return undefined - } - try { - let theme = JSON.parse(themeStr) as Theme - return theme - } catch (err) { - return undefined - } + const getCustomTheme = (): Theme | undefined => { + return getStoredCustomTheme(); } useEffect(() => { const root = document.documentElement; - root.setAttribute('data-theme', theme); - localStorage.setItem('theme', theme) - console.log(theme) + root.setAttribute('data-theme', themeName); + localStorage.setItem('theme', themeName); - if (theme === 'custom') { - const saved = localStorage.getItem('custom-theme'); - if (saved) { - try { - const parsed = JSON.parse(saved) as Theme; - applyCustomThemeVars(parsed); - } catch (err) { - console.error('Invalid custom theme in localStorage', err); - } - } else { - setTheme('yuu') - } + if (themeName === 'custom') { + applyCustomThemeVars(currentTheme); } else { - clearCustomThemeVars() + clearCustomThemeVars(); } - }, [theme]); + }, [themeName, currentTheme]); return ( - + {children} ); diff --git a/client/app/routes/ThemeHelper.tsx b/client/app/routes/ThemeHelper.tsx index 6452a08..fc5b7e4 100644 --- a/client/app/routes/ThemeHelper.tsx +++ b/client/app/routes/ThemeHelper.tsx @@ -12,7 +12,6 @@ import { themes, type Theme } from "~/styles/themes.css" export default function ThemeHelper() { const initialTheme = { - name: "custom", bg: "#1e1816", bgSecondary: "#2f2623", bgTertiary: "#453733", @@ -36,9 +35,6 @@ export default function ThemeHelper() { console.log(custom) try { const theme = JSON.parse(custom) as Theme - if (theme.name !== "custom") { - throw new Error("theme name must be 'custom'") - } console.log(theme) setCustomTheme(theme) } catch(err) { diff --git a/client/app/styles/themes.css.ts b/client/app/styles/themes.css.ts index eee6b27..22f48f0 100644 --- a/client/app/styles/themes.css.ts +++ b/client/app/styles/themes.css.ts @@ -2,11 +2,10 @@ import { globalStyle } from "@vanilla-extract/css" import { themeVars } from "./vars.css" export type Theme = { - name: string, - bg: string - bgSecondary: string + bg: string + bgSecondary: string bgTertiary: string - fg: string + fg: string fgSecondary: string fgTertiary: string primary: string @@ -23,9 +22,8 @@ export const THEME_KEYS = [ '--color' ] -export const themes: Theme[] = [ - { - name: "yuu", +export const themes: Record = { + yuu: { bg: "#1e1816", bgSecondary: "#2f2623", bgTertiary: "#453733", @@ -41,8 +39,7 @@ export const themes: Theme[] = [ success: "#8fc48f", info: "#87b8dd", }, - { - name: "varia", + varia: { bg: "rgb(25, 25, 29)", bgSecondary: "#222222", bgTertiary: "#333333", @@ -58,8 +55,7 @@ export const themes: Theme[] = [ success: "#4caf50", info: "#2196f3", }, - { - name: "midnight", + midnight: { bg: "rgb(8, 15, 24)", bgSecondary: "rgb(15, 27, 46)", bgTertiary: "rgb(15, 41, 70)", @@ -75,8 +71,7 @@ export const themes: Theme[] = [ success: "#4caf50", info: "#2196f3", }, - { - name: "catppuccin", + catppuccin: { bg: "#1e1e2e", bgSecondary: "#181825", bgTertiary: "#11111b", @@ -92,8 +87,7 @@ export const themes: Theme[] = [ success: "#a6e3a1", info: "#89dceb", }, - { - name: "autumn", + autumn: { bg: "rgb(44, 25, 18)", bgSecondary: "rgb(70, 40, 18)", bgTertiary: "#4b2f1c", @@ -109,8 +103,7 @@ export const themes: Theme[] = [ success: "#6b8e23", info: "#c084fc", }, - { - name: "black", + black: { bg: "#000000", bgSecondary: "#1a1a1a", bgTertiary: "#2a2a2a", @@ -126,8 +119,7 @@ export const themes: Theme[] = [ success: "#4caf50", info: "#2196f3", }, - { - name: "wine", + wine: { bg: "#23181E", bgSecondary: "#2C1C25", bgTertiary: "#422A37", @@ -143,97 +135,92 @@ export const themes: Theme[] = [ success: "#bbf7d0", info: "#bae6fd", }, - { - name: "pearl", - bg: "#FFFFFF", - bgSecondary: "#EEEEEE", - bgTertiary: "#E0E0E0", - fg: "#333333", - fgSecondary: "#555555", + pearl: { + bg: "#FFFFFF", + bgSecondary: "#EEEEEE", + bgTertiary: "#E0E0E0", + fg: "#333333", + fgSecondary: "#555555", fgTertiary: "#777777", - primary: "#007BFF", + primary: "#007BFF", primaryDim: "#0056B3", - accent: "#28A745", - accentDim: "#1E7E34", - error: "#DC3545", - warning: "#FFC107", - success: "#28A745", - info: "#17A2B8", + accent: "#28A745", + accentDim: "#1E7E34", + error: "#DC3545", + warning: "#FFC107", + success: "#28A745", + info: "#17A2B8", }, - { - name: "asuka", - bg: "#3B1212", - bgSecondary: "#471B1B", - bgTertiary: "#020202", - fg: "#F1E9E6", - fgSecondary: "#CCB6AE", + asuka: { + bg: "#3B1212", + bgSecondary: "#471B1B", + bgTertiary: "#020202", + fg: "#F1E9E6", + fgSecondary: "#CCB6AE", fgTertiary: "#9F8176", - primary: "#F1E9E6", + primary: "#F1E9E6", primaryDim: "#CCB6AE", - accent: "#41CE41", - accentDim: "#3BA03B", - error: "#DC143C", - warning: "#FFD700", - success: "#32CD32", - info: "#1E90FF", + accent: "#41CE41", + accentDim: "#3BA03B", + error: "#DC143C", + warning: "#FFD700", + success: "#32CD32", + info: "#1E90FF", }, - { - name: "urim", - bg: "#101713", - bgSecondary: "#1B2921", - bgTertiary: "#273B30", - fg: "#D2E79E", - fgSecondary: "#B4DA55", + urim: { + bg: "#101713", + bgSecondary: "#1B2921", + bgTertiary: "#273B30", + fg: "#D2E79E", + fgSecondary: "#B4DA55", fgTertiary: "#7E9F2A", - primary: "#ead500", + primary: "#ead500", primaryDim: "#C1B210", - accent: "#28A745", - accentDim: "#1E7E34", - error: "#EE5237", - warning: "#FFC107", - success: "#28A745", - info: "#17A2B8", + accent: "#28A745", + accentDim: "#1E7E34", + error: "#EE5237", + warning: "#FFC107", + success: "#28A745", + info: "#17A2B8", }, - { - name: "match", - bg: "#071014", - bgSecondary: "#0A181E", - bgTertiary: "#112A34", - fg: "#ebeaeb", - fgSecondary: "#BDBDBD", + match: { + bg: "#071014", + bgSecondary: "#0A181E", + bgTertiary: "#112A34", + fg: "#ebeaeb", + fgSecondary: "#BDBDBD", fgTertiary: "#A2A2A2", - primary: "#fda827", + primary: "#fda827", primaryDim: "#C78420", - accent: "#277CFD", - accentDim: "#1F60C1", - error: "#F14426", - warning: "#FFC107", - success: "#28A745", - info: "#17A2B8", + accent: "#277CFD", + accentDim: "#1F60C1", + error: "#F14426", + warning: "#FFC107", + success: "#28A745", + info: "#17A2B8", }, - { - name: "lemon", - bg: "#1a171a", - bgSecondary: "#2E272E", - bgTertiary: "#443844", - fg: "#E6E2DC", - fgSecondary: "#B2ACA1", + lemon: { + bg: "#1a171a", + bgSecondary: "#2E272E", + bgTertiary: "#443844", + fg: "#E6E2DC", + fgSecondary: "#B2ACA1", fgTertiary: "#968F82", - primary: "#f5c737", + primary: "#f5c737", primaryDim: "#C29D2F", - accent: "#277CFD", - accentDim: "#1F60C1", - error: "#F14426", - warning: "#FFC107", - success: "#28A745", - info: "#17A2B8", + accent: "#277CFD", + accentDim: "#1F60C1", + error: "#F14426", + warning: "#FFC107", + success: "#28A745", + info: "#17A2B8", } -]; +}; export default themes -themes.forEach((theme) => { - const selector = `[data-theme="${theme.name}"]` +Object.entries(themes).forEach(([name, theme]) => { + const selector = `[data-theme="${name}"]` globalStyle(selector, { vars: {