From 239963ed89ccf4605a57d0e9ce09b1ecedf7e3fd Mon Sep 17 00:00:00 2001 From: Joerg Date: Fri, 23 Jan 2026 11:46:41 +0100 Subject: [PATCH] feat: implement theme switching system with light and dark modes Add complete theme switching system supporting Light Mode, Dark Mode, and System preference (follows OS setting). Uses CSS custom properties for all colors and Svelte store for state management with localStorage persistence. New files: - src/frontend/src/lib/stores/theme.js: Theme state management store - src/frontend/src/app.css: CSS variables for light/dark themes - src/frontend/src/lib/components/ThemeSwitcher.svelte: Theme switcher UI Updated all components to use CSS variables instead of hardcoded colors: - Main pages (home, awards, QSOs, settings, auth) - Admin dashboard and award management pages - Shared components (BackButton, ErrorDisplay, Loading) Features: - localStorage persistence for user preference - System preference detection via matchMedia API - FOUC prevention with inline script in app.html - Smooth theme transitions across entire application Co-Authored-By: Claude --- src/frontend/src/app.css | 186 ++++++++++++++++ src/frontend/src/app.html | 8 + .../src/lib/components/BackButton.svelte | 8 +- .../src/lib/components/ErrorDisplay.svelte | 8 +- .../src/lib/components/Loading.svelte | 2 +- .../src/lib/components/ThemeSwitcher.svelte | 152 +++++++++++++ src/frontend/src/lib/stores/theme.js | 56 +++++ src/frontend/src/routes/+layout.svelte | 59 +++-- src/frontend/src/routes/+page.svelte | 148 ++++++------- src/frontend/src/routes/admin/+page.svelte | 129 +++++------ .../src/routes/admin/awards/+page.svelte | 53 ++--- .../src/routes/admin/awards/[id]/+page.svelte | 94 ++++---- .../awards/components/FilterBuilder.svelte | 47 ++-- .../awards/components/TestAwardModal.svelte | 111 +++++----- .../routes/admin/awards/create/+page.svelte | 94 ++++---- src/frontend/src/routes/awards/+page.svelte | 65 +++--- .../src/routes/awards/[id]/+page.svelte | 208 +++++++++--------- src/frontend/src/routes/qsos/+page.svelte | 200 +++++++++-------- .../routes/qsos/components/QSOStats.svelte | 10 +- .../routes/qsos/components/SyncButton.svelte | 4 +- 20 files changed, 1042 insertions(+), 600 deletions(-) create mode 100644 src/frontend/src/app.css create mode 100644 src/frontend/src/lib/components/ThemeSwitcher.svelte create mode 100644 src/frontend/src/lib/stores/theme.js diff --git a/src/frontend/src/app.css b/src/frontend/src/app.css new file mode 100644 index 0000000..cfb18a4 --- /dev/null +++ b/src/frontend/src/app.css @@ -0,0 +1,186 @@ +/* Quickawards Theme System - CSS Variables */ + +/* Light Mode (default) */ +:root, [data-theme="light"] { + /* Backgrounds */ + --bg-body: #f5f5f5; + --bg-card: #ffffff; + --bg-navbar: #2c3e50; + --bg-footer: #2c3e50; + --bg-input: #ffffff; + --bg-hover: rgba(255, 255, 255, 0.1); + --bg-secondary: #f8f9fa; + --bg-tertiary: #e9ecef; + + /* Text */ + --text-primary: #333333; + --text-secondary: #666666; + --text-muted: #999999; + --text-inverted: #ffffff; + --text-link: #4a90e2; + + /* Primary colors */ + --color-primary: #4a90e2; + --color-primary-hover: #357abd; + --color-primary-light: rgba(74, 144, 226, 0.1); + + /* Secondary colors */ + --color-secondary: #6c757d; + --color-secondary-hover: #5a6268; + + /* Semantic colors */ + --color-success: #065f46; + --color-success-bg: #d1fae5; + --color-success-light: #10b981; + + --color-warning: #ffc107; + --color-warning-hover: #e0a800; + --color-warning-bg: #fff3cd; + --color-warning-text: #856404; + + --color-error: #dc3545; + --color-error-hover: #c82333; + --color-error-bg: #fee2e2; + --color-error-text: #991b1b; + + --color-info: #1e40af; + --color-info-bg: #dbeafe; + --color-info-text: #1e40af; + + /* Badge/status colors */ + --badge-pending-bg: #fef3c7; + --badge-pending-text: #92400e; + --badge-running-bg: #dbeafe; + --badge-running-text: #1e40af; + --badge-completed-bg: #d1fae5; + --badge-completed-text: #065f46; + --badge-failed-bg: #fee2e2; + --badge-failed-text: #991b1b; + --badge-cancelled-bg: #f3e8ff; + --badge-cancelled-text: #6b21a8; + --badge-purple-bg: #8b5cf6; + --badge-purple-text: #ffffff; + + /* Borders */ + --border-color: #e0e0e0; + --border-color-light: #e9ecef; + --border-radius: 4px; + --border-radius-lg: 8px; + --border-radius-pill: 12px; + + /* Shadows */ + --shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.1); + --shadow-md: 0 2px 8px rgba(0, 0, 0, 0.12); + + /* Focus */ + --focus-ring: 0 0 0 2px rgba(74, 144, 226, 0.2); + + /* Logout button */ + --color-logout: #ff6b6b; + --color-logout-hover: #ff5252; + --color-logout-bg: rgba(255, 107, 107, 0.1); + + /* Admin link */ + --color-admin-bg: #ffc107; + --color-admin-hover: #e0a800; + --color-admin-text: #000000; + + /* Impersonation banner */ + --impersonation-bg: #fff3cd; + --impersonation-border: #ffc107; + --impersonation-text: #856404; + + /* Gradient colors */ + --gradient-primary: linear-gradient(90deg, #4a90e2 0%, #357abd 100%); + --gradient-purple: linear-gradient(90deg, #8b5cf6, #a78bfa); + --gradient-scheduled: linear-gradient(to right, #f8f7ff, white); +} + +/* Dark Mode */ +[data-theme="dark"] { + /* Backgrounds */ + --bg-body: #1a1a1a; + --bg-card: #2d2d2d; + --bg-navbar: #1f2937; + --bg-footer: #1f2937; + --bg-input: #2d2d2d; + --bg-hover: rgba(255, 255, 255, 0.1); + --bg-secondary: #252525; + --bg-tertiary: #333333; + + /* Text */ + --text-primary: #e0e0e0; + --text-secondary: #a0a0a0; + --text-muted: #707070; + --text-inverted: #ffffff; + --text-link: #5ba3f5; + + /* Primary colors */ + --color-primary: #5ba3f5; + --color-primary-hover: #4a8ae4; + --color-primary-light: rgba(91, 163, 245, 0.15); + + /* Secondary colors */ + --color-secondary: #6b7280; + --color-secondary-hover: #4b5563; + + /* Semantic colors */ + --color-success: #10b981; + --color-success-bg: #064e3b; + --color-success-light: #10b981; + + --color-warning: #fbbf24; + --color-warning-hover: #f59e0b; + --color-warning-bg: #451a03; + --color-warning-text: #fef3c7; + + --color-error: #f87171; + --color-error-hover: #ef4444; + --color-error-bg: #7f1d1d; + --color-error-text: #fecaca; + + --color-info: #3b82f6; + --color-info-bg: #1e3a8a; + --color-info-text: #93c5fd; + + /* Badge/status colors */ + --badge-pending-bg: #451a03; + --badge-pending-text: #fef3c7; + --badge-running-bg: #1e3a8a; + --badge-running-text: #93c5fd; + --badge-completed-bg: #064e3b; + --badge-completed-text: #6ee7b7; + --badge-failed-bg: #7f1d1d; + --badge-failed-text: #fecaca; + --badge-cancelled-bg: #3b0a4d; + --badge-cancelled-text: #d8b4fe; + --badge-purple-bg: #7c3aed; + --badge-purple-text: #ffffff; + + /* Borders */ + --border-color: #404040; + --border-color-light: #4a4a4a; + + /* Focus */ + --focus-ring: 0 0 0 2px rgba(91, 163, 245, 0.2); + + /* Logout button */ + --color-logout: #f87171; + --color-logout-hover: #ef4444; + --color-logout-bg: rgba(248, 113, 113, 0.15); + + /* Admin link */ + --color-admin-bg: #f59e0b; + --color-admin-hover: #d97706; + --color-admin-text: #000000; + + /* Impersonation banner */ + --impersonation-bg: #451a03; + --impersonation-border: #f59e0b; + --impersonation-text: #fef3c7; + + /* Gradient colors */ + --gradient-primary: linear-gradient(90deg, #5ba3f5 0%, #4a8ae4 100%); + --gradient-purple: linear-gradient(90deg, #7c3aed, #8b5cf6); + --gradient-scheduled: linear-gradient(to right, #2d1f3d, #2d2d2d); +} diff --git a/src/frontend/src/app.html b/src/frontend/src/app.html index f273cc5..06153b9 100644 --- a/src/frontend/src/app.html +++ b/src/frontend/src/app.html @@ -4,6 +4,14 @@ %sveltekit.head% +
%sveltekit.body%
diff --git a/src/frontend/src/lib/components/BackButton.svelte b/src/frontend/src/lib/components/BackButton.svelte index 23ed783..8b856fa 100644 --- a/src/frontend/src/lib/components/BackButton.svelte +++ b/src/frontend/src/lib/components/BackButton.svelte @@ -11,10 +11,10 @@ diff --git a/src/frontend/src/lib/components/Loading.svelte b/src/frontend/src/lib/components/Loading.svelte index 20eaa4a..2c043ff 100644 --- a/src/frontend/src/lib/components/Loading.svelte +++ b/src/frontend/src/lib/components/Loading.svelte @@ -11,6 +11,6 @@ text-align: center; padding: 3rem; font-size: 1.1rem; - color: #666; + color: var(--text-secondary); } diff --git a/src/frontend/src/lib/components/ThemeSwitcher.svelte b/src/frontend/src/lib/components/ThemeSwitcher.svelte new file mode 100644 index 0000000..bbba761 --- /dev/null +++ b/src/frontend/src/lib/components/ThemeSwitcher.svelte @@ -0,0 +1,152 @@ + + + + + + +
+ + + {#if isOpen} +
+ {#each themes as t} + + {/each} +
+ {/if} +
+ + diff --git a/src/frontend/src/lib/stores/theme.js b/src/frontend/src/lib/stores/theme.js new file mode 100644 index 0000000..be52eae --- /dev/null +++ b/src/frontend/src/lib/stores/theme.js @@ -0,0 +1,56 @@ +import { writable, derived } from 'svelte/store'; +import { browser } from '$app/environment'; + +/** + * Theme store + * Manages theme state (light, dark, system) with localStorage persistence + */ +function createThemeStore() { + // Initialize state from localStorage + const initialState = browser ? localStorage.getItem('theme') || 'light' : 'light'; + + const { subscribe, set, update } = writable(initialState); + + // Listen for system preference changes + let mediaQuery; + if (browser) { + mediaQuery = window.matchMedia('(prefers-color-scheme: dark)'); + const handleMediaChange = () => { + // Trigger update to recompute isDark + update((n) => n); + }; + mediaQuery.addEventListener('change', handleMediaChange); + } + + // Derived store for whether dark mode should be active + const isDark = derived(initialState, ($theme) => { + if (!browser) return false; + if ($theme === 'dark') return true; + if ($theme === 'light') return false; + // system preference + return window.matchMedia('(prefers-color-scheme: dark)').matches; + }); + + return { + subscribe, + isDark, + setTheme: (theme) => { + if (browser) { + localStorage.setItem('theme', theme); + // Apply data-theme attribute to document + const isDarkMode = theme === 'dark' || (theme === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches); + document.documentElement.setAttribute('data-theme', isDarkMode ? 'dark' : 'light'); + } + set(theme); + }, + // Initialize theme on client-side + init: () => { + if (!browser) return; + const theme = localStorage.getItem('theme') || 'light'; + const isDarkMode = theme === 'dark' || (theme === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches); + document.documentElement.setAttribute('data-theme', isDarkMode ? 'dark' : 'light'); + }, + }; +} + +export const theme = createThemeStore(); diff --git a/src/frontend/src/routes/+layout.svelte b/src/frontend/src/routes/+layout.svelte index 40626f8..3a49c9e 100644 --- a/src/frontend/src/routes/+layout.svelte +++ b/src/frontend/src/routes/+layout.svelte @@ -1,11 +1,20 @@