diff --git a/client/src/App.scss b/client/src/App.scss index a0d7f1f..2e5a34b 100644 --- a/client/src/App.scss +++ b/client/src/App.scss @@ -87,42 +87,6 @@ --luncher-shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.5), 0 4px 6px -4px rgb(0 0 0 / 0.4); } -// Modrý motiv – světlý -[data-luncher-color="blue"][data-bs-theme="light"] { - --luncher-primary: #2563eb; - --luncher-primary-hover: #1d4ed8; - --luncher-primary-light: #dbeafe; - --luncher-action-icon: #2563eb; - --luncher-success: #2563eb; -} - -// Modrý motiv – tmavý -[data-luncher-color="blue"][data-bs-theme="dark"] { - --luncher-primary: #3b82f6; - --luncher-primary-hover: #60a5fa; - --luncher-primary-light: #172554; - --luncher-action-icon: #3b82f6; - --luncher-success: #3b82f6; -} - -// Fialový motiv – světlý -[data-luncher-color="purple"][data-bs-theme="light"] { - --luncher-primary: #7c3aed; - --luncher-primary-hover: #6d28d9; - --luncher-primary-light: #ede9fe; - --luncher-action-icon: #7c3aed; - --luncher-success: #7c3aed; -} - -// Fialový motiv – tmavý -[data-luncher-color="purple"][data-bs-theme="dark"] { - --luncher-primary: #a78bfa; - --luncher-primary-hover: #c4b5fd; - --luncher-primary-light: #2e1065; - --luncher-action-icon: #a78bfa; - --luncher-success: #a78bfa; -} - // ============================================ // BASE STYLES // ============================================ @@ -263,32 +227,6 @@ body { transform: rotate(15deg); } - // Přizpůsobení pro NavDropdown (palette ikona) - &.nav-item.dropdown { - .dropdown-toggle { - background: transparent; - border: none; - color: var(--luncher-navbar-text); - padding: 8px 12px; - font-size: 1.1rem; - display: flex; - align-items: center; - - &::after { - display: none; - } - - &:hover { - background: rgba(255, 255, 255, 0.1); - transform: scale(1.1); - } - - &:focus { - outline: none; - box-shadow: 0 0 0 2px rgba(255, 255, 255, 0.3); - } - } - } } // ============================================ diff --git a/client/src/components/Header.tsx b/client/src/components/Header.tsx index 7e9a3e9..07dc519 100644 --- a/client/src/components/Header.tsx +++ b/client/src/components/Header.tsx @@ -2,7 +2,8 @@ import { useEffect, useState } from "react"; import { Navbar, Nav, NavDropdown, Modal, Button } from "react-bootstrap"; import { useAuth } from "../context/auth"; import SettingsModal from "./modals/SettingsModal"; -import { useSettings, ThemePreference, ColorTheme } from "../context/settings"; +import { useSettings, ThemePreference } from "../context/settings"; +import HuePicker from "./HuePicker"; import FeaturesVotingModal from "./modals/FeaturesVotingModal"; import PizzaCalculatorModal from "./modals/PizzaCalculatorModal"; import RefreshMenuModal from "./modals/RefreshMenuModal"; @@ -13,7 +14,7 @@ import { useNavigate } from "react-router"; import { STATS_URL, OBJEDNANI_URL } from "../AppRoutes"; import { FeatureRequest, getVotes, updateVote, LunchChoices, getChangelogs } from "../../../types"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import { faSun, faMoon, faPalette } from "@fortawesome/free-solid-svg-icons"; +import { faSun, faMoon } from "@fortawesome/free-solid-svg-icons"; import { formatDateString } from "../Utils"; const LAST_SEEN_CHANGELOG_KEY = "lastChangelogDate"; @@ -40,25 +41,7 @@ export default function Header({ choices, dayIndex }: Props) { const [clearMockModalOpen, setClearMockModalOpen] = useState(false); const [featureVotes, setFeatureVotes] = useState([]); - // Zjistíme aktuální efektivní téma (pro zobrazení správné ikony) - const [effectiveTheme, setEffectiveTheme] = useState<'light' | 'dark'>('light'); - - useEffect(() => { - const updateEffectiveTheme = () => { - if (settings?.themePreference === 'system') { - const isDark = window.matchMedia('(prefers-color-scheme: dark)').matches; - setEffectiveTheme(isDark ? 'dark' : 'light'); - } else { - setEffectiveTheme(settings?.themePreference || 'light'); - } - }; - - updateEffectiveTheme(); - - const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)'); - mediaQuery.addEventListener('change', updateEffectiveTheme); - return () => mediaQuery.removeEventListener('change', updateEffectiveTheme); - }, [settings?.themePreference]); + const effectiveDark = settings?.effectiveDark ?? false; useEffect(() => { if (auth?.login) { @@ -110,8 +93,7 @@ export default function Header({ choices, dayIndex }: Props) { } const toggleTheme = () => { - // Přepínáme mezi light a dark (ignorujeme system pro jednoduchost) - const newTheme: ThemePreference = effectiveTheme === 'dark' ? 'light' : 'dark'; + const newTheme: ThemePreference = effectiveDark ? 'light' : 'dark'; settings?.setThemePreference(newTheme); } @@ -195,21 +177,16 @@ export default function Header({ choices, dayIndex }: Props) { - } - id="color-theme-dropdown" - className="theme-toggle" - > - settings?.setColorTheme('green' as ColorTheme)}>🟢 Zelený - settings?.setColorTheme('blue' as ColorTheme)}>🔵 Modrý - settings?.setColorTheme('purple' as ColorTheme)}>🟣 Fialový - + settings?.setAccentHue(hue)} + /> setSettingsModalOpen(true)}>Nastavení setRefreshMenuModalOpen(true)}>Přenačtení menu diff --git a/client/src/components/HuePicker.scss b/client/src/components/HuePicker.scss new file mode 100644 index 0000000..b153abc --- /dev/null +++ b/client/src/components/HuePicker.scss @@ -0,0 +1,138 @@ +.hue-picker-dropdown { + .dropdown-toggle { + background: transparent !important; + border: none !important; + color: var(--luncher-navbar-text) !important; + padding: 8px 12px; + font-size: 1.1rem; + display: flex; + align-items: center; + cursor: pointer; + border-radius: var(--luncher-radius-sm); + transition: var(--luncher-transition); + + &::after { + display: none; + } + + &:hover { + background: rgba(255, 255, 255, 0.1) !important; + transform: scale(1.1); + } + + &:focus { + outline: none; + box-shadow: 0 0 0 2px rgba(255, 255, 255, 0.3) !important; + } + } +} + +.hue-picker-panel { + padding: 0 !important; + min-width: 240px; + + .hue-picker-inner { + padding: 14px 16px; + } + + .hue-picker-label { + font-size: 0.75rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.5px; + color: var(--luncher-text-secondary); + margin-bottom: 12px; + } +} + +.hue-slider { + -webkit-appearance: none; + appearance: none; + width: 100%; + height: 12px; + border-radius: 6px; + background: linear-gradient( + to right, + hsl(0 70% 50%), hsl(30 70% 50%), hsl(60 70% 50%), hsl(90 70% 50%), + hsl(120 70% 50%), hsl(150 70% 50%), hsl(180 70% 50%), hsl(210 70% 50%), + hsl(240 70% 50%), hsl(270 70% 50%), hsl(300 70% 50%), hsl(330 70% 50%), hsl(360 70% 50%) + ); + outline: none; + cursor: pointer; + margin-bottom: 14px; + + &::-webkit-slider-thumb { + -webkit-appearance: none; + appearance: none; + width: 20px; + height: 20px; + border-radius: 50%; + background: white; + border: 2px solid rgba(0, 0, 0, 0.25); + cursor: pointer; + box-shadow: 0 1px 4px rgba(0, 0, 0, 0.3); + transition: transform 0.15s ease; + + &:hover { + transform: scale(1.15); + } + } + + &::-moz-range-thumb { + width: 20px; + height: 20px; + border-radius: 50%; + background: white; + border: 2px solid rgba(0, 0, 0, 0.25); + cursor: pointer; + box-shadow: 0 1px 4px rgba(0, 0, 0, 0.3); + } +} + +.hue-presets { + display: flex; + gap: 8px; + margin-bottom: 14px; + + .hue-swatch { + width: 26px; + height: 26px; + border-radius: 50%; + border: 2px solid transparent; + cursor: pointer; + padding: 0; + transition: transform 0.15s ease, border-color 0.15s ease; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2); + + &:hover { + transform: scale(1.15); + } + + &.active { + border-color: var(--luncher-text); + transform: scale(1.1); + } + } +} + +.hue-preview { + display: flex; + align-items: center; + gap: 10px; + padding-top: 4px; + border-top: 1px solid var(--luncher-border); + + .hue-preview-chip { + width: 32px; + height: 32px; + border-radius: var(--luncher-radius-sm); + flex-shrink: 0; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2); + transition: background 0.2s ease; + } + + span { + font-size: 0.8rem; + color: var(--luncher-text-secondary); + } +} diff --git a/client/src/components/HuePicker.tsx b/client/src/components/HuePicker.tsx new file mode 100644 index 0000000..f289eb4 --- /dev/null +++ b/client/src/components/HuePicker.tsx @@ -0,0 +1,71 @@ +import { Dropdown } from 'react-bootstrap'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { faPalette } from '@fortawesome/free-solid-svg-icons'; +import './HuePicker.scss'; + +const PRESETS = [ + { hue: 142, label: 'Zelená' }, + { hue: 217, label: 'Modrá' }, + { hue: 263, label: 'Fialová' }, + { hue: 0, label: 'Červená' }, + { hue: 28, label: 'Oranžová' }, + { hue: 340, label: 'Růžová' }, +]; + +type Props = { + accentHue: number; + isDark: boolean; + onChange: (hue: number) => void; +}; + +function swatchColor(hue: number, isDark: boolean): string { + return `hsl(${hue} 70% ${isDark ? 55 : 38}%)`; +} + +export default function HuePicker({ accentHue, isDark, onChange }: Props) { + return ( + + + + + +
+
Barva zvýraznění
+ onChange(parseInt(e.target.value, 10))} + className="hue-slider" + aria-label="Odstín barvy zvýraznění" + /> +
+ {PRESETS.map(p => ( +
+
+
+ Aktuální barva zvýraznění +
+
+ + + ); +} diff --git a/client/src/context/settings.tsx b/client/src/context/settings.tsx index ea3796f..f650191 100644 --- a/client/src/context/settings.tsx +++ b/client/src/context/settings.tsx @@ -4,22 +4,23 @@ const BANK_ACCOUNT_NUMBER_KEY = 'bank_account_number'; const BANK_ACCOUNT_HOLDER_KEY = 'bank_account_holder_name'; const HIDE_SOUPS_KEY = 'hide_soups'; const THEME_KEY = 'theme_preference'; -const COLOR_THEME_KEY = 'color_theme'; +const ACCENT_HUE_KEY = 'accent_hue'; +const LEGACY_COLOR_THEME_KEY = 'color_theme'; export type ThemePreference = 'system' | 'light' | 'dark'; -export type ColorTheme = 'green' | 'blue' | 'purple'; export type SettingsContextProps = { bankAccount?: string, holderName?: string, hideSoups?: boolean, themePreference: ThemePreference, - colorTheme: ColorTheme, + accentHue: number, + effectiveDark: boolean, setBankAccountNumber: (accountNumber?: string) => void, setBankAccountHolderName: (holderName?: string) => void, setHideSoupsOption: (hideSoups?: boolean) => void, setThemePreference: (theme: ThemePreference) => void, - setColorTheme: (color: ColorTheme) => void, + setAccentHue: (hue: number) => void, } type ContextProps = { @@ -49,16 +50,58 @@ function getInitialTheme(): ThemePreference { return 'system'; } -function getInitialColorTheme(): ColorTheme { +function getInitialAccentHue(): number { try { - const saved = localStorage.getItem(COLOR_THEME_KEY) as ColorTheme | null; - if (saved && ['green', 'blue', 'purple'].includes(saved)) { - return saved; + const saved = localStorage.getItem(ACCENT_HUE_KEY); + if (saved !== null) { + const n = parseInt(saved, 10); + if (!isNaN(n) && n >= 0 && n <= 360) return n; } - } catch (e) { + // Migrace ze starého string formátu (green/blue/purple) + const old = localStorage.getItem(LEGACY_COLOR_THEME_KEY); + if (old === 'blue') return 217; + if (old === 'purple') return 263; + } catch { // localStorage nedostupný } - return 'green'; + return 142; +} + +// Převod HSL na relativní jas dle WCAG (pro výpočet kontrastu s bílým textem) +function hslToRelativeLuminance(h: number, s: number, l: number): number { + const sn = s / 100, ln = l / 100; + const a = sn * Math.min(ln, 1 - ln); + const ch = (n: number) => { + const k = (n + h / 30) % 12; + return ln - a * Math.max(Math.min(k - 3, 9 - k, 1), -1); + }; + const toLinear = (c: number) => c <= 0.04045 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4); + return 0.2126 * toLinear(ch(0)) + 0.7152 * toLinear(ch(8)) + 0.0722 * toLinear(ch(4)); +} + +// Najde nejnižší světlost, při které má barva dostatečný kontrast s bílým textem (WCAG AA 4.5:1) +function adjustedL(hue: number, sat: number, targetL: number): number { + let l = targetL; + while (l >= 5) { + const lum = hslToRelativeLuminance(hue, sat, l); + if (1.05 / (lum + 0.05) >= 4.5) return l; + l -= 1; + } + return l; +} + +function applyAccentColors(hue: number, isDark: boolean): void { + const sat = 70; + const baseL = adjustedL(hue, sat, isDark ? 55 : 38); + const hoverL = isDark ? Math.min(baseL + 10, 80) : Math.max(baseL - 10, 10); + const root = document.documentElement; + root.style.setProperty('--luncher-primary', `hsl(${hue} ${sat}% ${baseL}%)`); + root.style.setProperty('--luncher-primary-hover', `hsl(${hue} ${sat}% ${hoverL}%)`); + root.style.setProperty('--luncher-primary-light', isDark + ? `hsl(${hue} 60% 12%)` + : `hsl(${hue} 60% 92%)`); + root.style.setProperty('--luncher-action-icon', `hsl(${hue} ${sat}% ${baseL}%)`); + root.style.setProperty('--luncher-success', `hsl(${hue} ${sat}% ${baseL}%)`); } function useProvideSettings(): SettingsContextProps { @@ -66,7 +109,15 @@ function useProvideSettings(): SettingsContextProps { const [holderName, setHolderName] = useState(); const [hideSoups, setHideSoups] = useState(); const [themePreference, setTheme] = useState(getInitialTheme); - const [colorTheme, setColor] = useState(getInitialColorTheme); + const [accentHue, setHue] = useState(getInitialAccentHue); + const [effectiveDark, setEffectiveDark] = useState(() => { + try { + const pref = localStorage.getItem(THEME_KEY) as ThemePreference | null; + if (pref === 'dark') return true; + if (pref === 'light') return false; + } catch { /* noop */ } + return window.matchMedia?.('(prefers-color-scheme: dark)').matches ?? false; + }); useEffect(() => { const accountNumber = localStorage.getItem(BANK_ACCOUNT_NUMBER_KEY); @@ -112,29 +163,27 @@ function useProvideSettings(): SettingsContextProps { }, [themePreference]); useEffect(() => { - localStorage.setItem(COLOR_THEME_KEY, colorTheme); - document.documentElement.setAttribute('data-luncher-color', colorTheme); - }, [colorTheme]); - - useEffect(() => { - const applyTheme = (theme: 'light' | 'dark') => { - document.documentElement.setAttribute('data-bs-theme', theme); + const applyTheme = (dark: boolean) => { + document.documentElement.setAttribute('data-bs-theme', dark ? 'dark' : 'light'); + setEffectiveDark(dark); }; - if (themePreference === 'system') { - const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)'); - applyTheme(mediaQuery.matches ? 'dark' : 'light'); - - const handler = (e: MediaQueryListEvent) => { - applyTheme(e.matches ? 'dark' : 'light'); - }; - mediaQuery.addEventListener('change', handler); - return () => mediaQuery.removeEventListener('change', handler); + const mq = window.matchMedia('(prefers-color-scheme: dark)'); + applyTheme(mq.matches); + const handler = (e: MediaQueryListEvent) => applyTheme(e.matches); + mq.addEventListener('change', handler); + return () => mq.removeEventListener('change', handler); } else { - applyTheme(themePreference); + applyTheme(themePreference === 'dark'); } }, [themePreference]); + // Aplikuje accent barvy při změně hue nebo přepnutí světlý/tmavý + useEffect(() => { + localStorage.setItem(ACCENT_HUE_KEY, String(accentHue)); + applyAccentColors(accentHue, effectiveDark); + }, [accentHue, effectiveDark]); + function setBankAccountNumber(bankAccount?: string) { setBankAccount(bankAccount); } @@ -151,8 +200,8 @@ function useProvideSettings(): SettingsContextProps { setTheme(theme); } - function setColorTheme(color: ColorTheme) { - setColor(color); + function setAccentHue(hue: number) { + setHue(hue); } return { @@ -160,11 +209,12 @@ function useProvideSettings(): SettingsContextProps { holderName, hideSoups, themePreference, - colorTheme, + accentHue, + effectiveDark, setBankAccountNumber, setBankAccountHolderName, setHideSoupsOption, setThemePreference, - setColorTheme, + setAccentHue, } }