feat: Themes added, 4 themes available (3 dark, 1 light)
This commit is contained in:
@@ -0,0 +1,30 @@
|
||||
import { themeQuartz } from 'ag-grid-community';
|
||||
|
||||
// Shared AG-Grid theme for every logbook table (Recent QSOs, Worked-before,
|
||||
// Cluster, Net, QSL Manager). Colours are wired to the app's semantic CSS
|
||||
// variables (see style.css) instead of hardcoded hex, so the grids follow the
|
||||
// active theme (light-warm / dark-warm / graphite / high-contrast) at runtime —
|
||||
// flipping <html data-theme> re-resolves these var() references with no
|
||||
// re-render. Keep this the single source of truth; per-grid tweaks (e.g. row
|
||||
// height) chain a `.withParams({...})` on top.
|
||||
export const hamlogGridTheme = themeQuartz.withParams({
|
||||
fontFamily: 'inherit',
|
||||
fontSize: 12.5,
|
||||
backgroundColor: 'var(--card)',
|
||||
foregroundColor: 'var(--foreground)',
|
||||
headerBackgroundColor: 'var(--muted)',
|
||||
headerTextColor: 'var(--muted-foreground)',
|
||||
headerFontWeight: 600,
|
||||
oddRowBackgroundColor: 'color-mix(in srgb, var(--muted) 40%, var(--card))',
|
||||
rowHoverColor: 'color-mix(in srgb, var(--accent) 55%, var(--card))',
|
||||
selectedRowBackgroundColor: 'var(--accent)',
|
||||
borderColor: 'var(--border)',
|
||||
rowBorder: { color: 'var(--border)', width: 1 },
|
||||
columnBorder: { color: 'var(--border)', width: 1 },
|
||||
cellHorizontalPadding: 10,
|
||||
rowHeight: 30,
|
||||
headerHeight: 32,
|
||||
spacing: 4,
|
||||
accentColor: 'var(--primary)',
|
||||
iconSize: 12,
|
||||
});
|
||||
@@ -43,6 +43,9 @@ const en: Dict = {
|
||||
'lang.choose': 'Choose your language', 'lang.chooseHint': 'You can change this later in Settings → General.',
|
||||
'lang.english': 'English', 'lang.french': 'Français',
|
||||
'settings.language': 'Language', 'settings.languageHint': 'Interface language.',
|
||||
'settings.theme': 'Theme', 'settings.themeHint': 'Interface colour theme.',
|
||||
'theme.auto': 'Auto (system)', 'theme.light-warm': 'Warm light', 'theme.dark-warm': 'Warm dark',
|
||||
'theme.dark-graphite': 'Graphite dark', 'theme.high-contrast': 'High contrast',
|
||||
// Settings navigation (sidebar groups + section names)
|
||||
'nav.user': 'User Configuration', 'nav.software': 'Software Configuration', 'nav.hardware': 'Hardware Configuration', 'nav.lists': 'Lists',
|
||||
'sec.station': 'Station Information', 'sec.profiles': 'Profiles', 'sec.operating': 'Operating conditions',
|
||||
@@ -239,6 +242,9 @@ const fr: Dict = {
|
||||
'lang.choose': 'Choisissez votre langue', 'lang.chooseHint': 'Modifiable plus tard dans Réglages → Général.',
|
||||
'lang.english': 'English', 'lang.french': 'Français',
|
||||
'settings.language': 'Langue', 'settings.languageHint': "Langue de l'interface.",
|
||||
'settings.theme': 'Thème', 'settings.themeHint': "Thème de couleur de l'interface.",
|
||||
'theme.auto': 'Auto (système)', 'theme.light-warm': 'Clair chaud', 'theme.dark-warm': 'Sombre chaud',
|
||||
'theme.dark-graphite': 'Sombre graphite', 'theme.high-contrast': 'Contraste élevé',
|
||||
'nav.user': 'Configuration utilisateur', 'nav.software': 'Configuration logicielle', 'nav.hardware': 'Configuration matérielle', 'nav.lists': 'Listes',
|
||||
'sec.station': 'Informations station', 'sec.profiles': 'Profils', 'sec.operating': "Conditions d'opération",
|
||||
'sec.confirmations': 'Confirmations', 'sec.external': 'Services externes',
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import { createContext, useContext, useState, useEffect, useCallback, type ReactNode } from 'react';
|
||||
import { writeUiPref } from './uiPref';
|
||||
|
||||
// Theme system. Each choice maps to a `data-theme` value on <html> that the
|
||||
// CSS variables in style.css key off of. 'auto' follows the OS light/dark
|
||||
// preference. The choice is persisted (localStorage + portable UI pref, so it
|
||||
// travels with the data/ folder like the language).
|
||||
export type ThemeChoice = 'auto' | 'light-warm' | 'dark-warm' | 'dark-graphite' | 'high-contrast';
|
||||
|
||||
// Selectable, concrete themes (excludes 'auto') in display order.
|
||||
export const CONCRETE_THEMES: Exclude<ThemeChoice, 'auto'>[] = [
|
||||
'light-warm', 'dark-warm', 'dark-graphite', 'high-contrast',
|
||||
];
|
||||
|
||||
export const LS_KEY = 'opslog.theme';
|
||||
const DEFAULT: ThemeChoice = 'light-warm';
|
||||
const ALL: ThemeChoice[] = ['auto', ...CONCRETE_THEMES];
|
||||
|
||||
function systemDark(): boolean {
|
||||
try { return window.matchMedia('(prefers-color-scheme: dark)').matches; } catch { return false; }
|
||||
}
|
||||
|
||||
// Resolve 'auto' to a concrete data-theme value (graphite dark / warm light).
|
||||
function resolve(choice: ThemeChoice): string {
|
||||
if (choice === 'auto') return systemDark() ? 'dark-graphite' : 'light-warm';
|
||||
return choice;
|
||||
}
|
||||
|
||||
function readStored(): ThemeChoice {
|
||||
try {
|
||||
const v = localStorage.getItem(LS_KEY) as ThemeChoice | null;
|
||||
if (v && ALL.includes(v)) return v;
|
||||
} catch { /* private mode */ }
|
||||
return DEFAULT;
|
||||
}
|
||||
|
||||
// Stamp the resolved theme onto <html data-theme>.
|
||||
export function applyThemeToDom(choice: ThemeChoice): void {
|
||||
try { document.documentElement.setAttribute('data-theme', resolve(choice)); } catch { /* no DOM */ }
|
||||
}
|
||||
|
||||
// Call before the first render (main.tsx) so there is no flash of the default
|
||||
// palette while React boots.
|
||||
export function initTheme(): void { applyThemeToDom(readStored()); }
|
||||
|
||||
type Ctx = { theme: ThemeChoice; setTheme: (t: ThemeChoice) => void };
|
||||
const ThemeCtx = createContext<Ctx>({ theme: DEFAULT, setTheme: () => {} });
|
||||
export function useTheme(): Ctx { return useContext(ThemeCtx); }
|
||||
|
||||
export function ThemeProvider({ children }: { children: ReactNode }) {
|
||||
const [theme, setThemeState] = useState<ThemeChoice>(() => readStored());
|
||||
|
||||
const setTheme = useCallback((t: ThemeChoice) => {
|
||||
setThemeState(t);
|
||||
applyThemeToDom(t);
|
||||
writeUiPref(LS_KEY, t);
|
||||
}, []);
|
||||
|
||||
// While in 'auto', re-resolve when the OS light/dark preference flips.
|
||||
useEffect(() => {
|
||||
if (theme !== 'auto') return;
|
||||
const mq = window.matchMedia('(prefers-color-scheme: dark)');
|
||||
const onChange = () => applyThemeToDom('auto');
|
||||
mq.addEventListener('change', onChange);
|
||||
return () => mq.removeEventListener('change', onChange);
|
||||
}, [theme]);
|
||||
|
||||
// Keep the DOM attribute in sync with state.
|
||||
useEffect(() => { applyThemeToDom(theme); }, [theme]);
|
||||
|
||||
return <ThemeCtx.Provider value={{ theme, setTheme }}>{children}</ThemeCtx.Provider>;
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import { GetUIPref, SetUIPref } from '../../wailsjs/go/main/App';
|
||||
// Keys that must travel with data/ (DB is the portable source of truth; the
|
||||
// localStorage copy is just a fast, synchronous cache).
|
||||
const PORTABLE_KEYS = [
|
||||
'opslog.theme', // UI colour theme (light-warm / dark-warm / graphite / high-contrast / auto)
|
||||
'hamlog.qsoLimit', // QSO list page size
|
||||
'bandmap.side', // band map docked left / right
|
||||
'bandmap.show', // band map open / closed
|
||||
|
||||
Reference in New Issue
Block a user