feat: vylepšená podpora themes
CI / Generate TypeScript types (push) Successful in 1m3s
CI / Server unit tests (push) Successful in 30s
CI / Build server (push) Successful in 25s
CI / Build client (push) Successful in 36s
CI / Playwright E2E tests (push) Successful in 1m20s
CI / Build and push Docker image (push) Successful in 2m46s
CI / Notify (push) Successful in 1s

This commit is contained in:
2026-05-20 12:51:46 +02:00
parent 640c7ed41d
commit a26d6cf85c
5 changed files with 303 additions and 129 deletions
-62
View File
@@ -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);
}
}
}
}
// ============================================
+12 -35
View File
@@ -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<boolean>(false);
const [featureVotes, setFeatureVotes] = useState<FeatureRequest[] | undefined>([]);
// 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) {
<button
className="theme-toggle"
onClick={toggleTheme}
title={effectiveTheme === 'dark' ? 'Přepnout na světlý režim' : 'Přepnout na tmavý režim'}
title={effectiveDark ? 'Přepnout na světlý režim' : 'Přepnout na tmavý režim'}
aria-label="Přepnout světlý/tmavý režim"
>
<FontAwesomeIcon icon={effectiveTheme === 'dark' ? faSun : faMoon} />
<FontAwesomeIcon icon={effectiveDark ? faSun : faMoon} />
</button>
<NavDropdown
align="end"
title={<FontAwesomeIcon icon={faPalette} />}
id="color-theme-dropdown"
className="theme-toggle"
>
<NavDropdown.Item active={settings?.colorTheme === 'green'} onClick={() => settings?.setColorTheme('green' as ColorTheme)}>🟢 Zelený</NavDropdown.Item>
<NavDropdown.Item active={settings?.colorTheme === 'blue'} onClick={() => settings?.setColorTheme('blue' as ColorTheme)}>🔵 Modrý</NavDropdown.Item>
<NavDropdown.Item active={settings?.colorTheme === 'purple'} onClick={() => settings?.setColorTheme('purple' as ColorTheme)}>🟣 Fialový</NavDropdown.Item>
</NavDropdown>
<HuePicker
accentHue={settings?.accentHue ?? 142}
isDark={effectiveDark}
onChange={hue => settings?.setAccentHue(hue)}
/>
<NavDropdown align="end" title={auth?.login} id="basic-nav-dropdown">
<NavDropdown.Item onClick={() => setSettingsModalOpen(true)}>Nastavení</NavDropdown.Item>
<NavDropdown.Item onClick={() => setRefreshMenuModalOpen(true)}>Přenačtení menu</NavDropdown.Item>
+138
View File
@@ -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);
}
}
+71
View File
@@ -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 (
<Dropdown align="end" autoClose="outside" className="hue-picker-dropdown">
<Dropdown.Toggle
as="button"
className="theme-toggle"
aria-label="Barva zvýraznění"
title="Barva zvýraznění"
>
<FontAwesomeIcon icon={faPalette} />
</Dropdown.Toggle>
<Dropdown.Menu className="hue-picker-panel">
<div className="hue-picker-inner">
<div className="hue-picker-label">Barva zvýraznění</div>
<input
type="range"
min={0}
max={360}
value={accentHue}
onChange={e => onChange(parseInt(e.target.value, 10))}
className="hue-slider"
aria-label="Odstín barvy zvýraznění"
/>
<div className="hue-presets">
{PRESETS.map(p => (
<button
key={p.hue}
className={`hue-swatch${accentHue === p.hue ? ' active' : ''}`}
style={{ background: swatchColor(p.hue, isDark) }}
title={p.label}
onClick={() => onChange(p.hue)}
aria-label={p.label}
/>
))}
</div>
<div className="hue-preview">
<div
className="hue-preview-chip"
style={{ background: swatchColor(accentHue, isDark) }}
/>
<span>Aktuální barva zvýraznění</span>
</div>
</div>
</Dropdown.Menu>
</Dropdown>
);
}
+82 -32
View File
@@ -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<string | undefined>();
const [hideSoups, setHideSoups] = useState<boolean | undefined>();
const [themePreference, setTheme] = useState<ThemePreference>(getInitialTheme);
const [colorTheme, setColor] = useState<ColorTheme>(getInitialColorTheme);
const [accentHue, setHue] = useState<number>(getInitialAccentHue);
const [effectiveDark, setEffectiveDark] = useState<boolean>(() => {
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,
}
}