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
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:
@@ -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);
|
--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
|
// BASE STYLES
|
||||||
// ============================================
|
// ============================================
|
||||||
@@ -263,32 +227,6 @@ body {
|
|||||||
transform: rotate(15deg);
|
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================
|
// ============================================
|
||||||
|
|||||||
@@ -2,7 +2,8 @@ import { useEffect, useState } from "react";
|
|||||||
import { Navbar, Nav, NavDropdown, Modal, Button } from "react-bootstrap";
|
import { Navbar, Nav, NavDropdown, Modal, Button } from "react-bootstrap";
|
||||||
import { useAuth } from "../context/auth";
|
import { useAuth } from "../context/auth";
|
||||||
import SettingsModal from "./modals/SettingsModal";
|
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 FeaturesVotingModal from "./modals/FeaturesVotingModal";
|
||||||
import PizzaCalculatorModal from "./modals/PizzaCalculatorModal";
|
import PizzaCalculatorModal from "./modals/PizzaCalculatorModal";
|
||||||
import RefreshMenuModal from "./modals/RefreshMenuModal";
|
import RefreshMenuModal from "./modals/RefreshMenuModal";
|
||||||
@@ -13,7 +14,7 @@ import { useNavigate } from "react-router";
|
|||||||
import { STATS_URL, OBJEDNANI_URL } from "../AppRoutes";
|
import { STATS_URL, OBJEDNANI_URL } from "../AppRoutes";
|
||||||
import { FeatureRequest, getVotes, updateVote, LunchChoices, getChangelogs } from "../../../types";
|
import { FeatureRequest, getVotes, updateVote, LunchChoices, getChangelogs } from "../../../types";
|
||||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
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";
|
import { formatDateString } from "../Utils";
|
||||||
|
|
||||||
const LAST_SEEN_CHANGELOG_KEY = "lastChangelogDate";
|
const LAST_SEEN_CHANGELOG_KEY = "lastChangelogDate";
|
||||||
@@ -40,25 +41,7 @@ export default function Header({ choices, dayIndex }: Props) {
|
|||||||
const [clearMockModalOpen, setClearMockModalOpen] = useState<boolean>(false);
|
const [clearMockModalOpen, setClearMockModalOpen] = useState<boolean>(false);
|
||||||
const [featureVotes, setFeatureVotes] = useState<FeatureRequest[] | undefined>([]);
|
const [featureVotes, setFeatureVotes] = useState<FeatureRequest[] | undefined>([]);
|
||||||
|
|
||||||
// Zjistíme aktuální efektivní téma (pro zobrazení správné ikony)
|
const effectiveDark = settings?.effectiveDark ?? false;
|
||||||
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]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (auth?.login) {
|
if (auth?.login) {
|
||||||
@@ -110,8 +93,7 @@ export default function Header({ choices, dayIndex }: Props) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const toggleTheme = () => {
|
const toggleTheme = () => {
|
||||||
// Přepínáme mezi light a dark (ignorujeme system pro jednoduchost)
|
const newTheme: ThemePreference = effectiveDark ? 'light' : 'dark';
|
||||||
const newTheme: ThemePreference = effectiveTheme === 'dark' ? 'light' : 'dark';
|
|
||||||
settings?.setThemePreference(newTheme);
|
settings?.setThemePreference(newTheme);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -195,21 +177,16 @@ export default function Header({ choices, dayIndex }: Props) {
|
|||||||
<button
|
<button
|
||||||
className="theme-toggle"
|
className="theme-toggle"
|
||||||
onClick={toggleTheme}
|
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"
|
aria-label="Přepnout světlý/tmavý režim"
|
||||||
>
|
>
|
||||||
<FontAwesomeIcon icon={effectiveTheme === 'dark' ? faSun : faMoon} />
|
<FontAwesomeIcon icon={effectiveDark ? faSun : faMoon} />
|
||||||
</button>
|
</button>
|
||||||
<NavDropdown
|
<HuePicker
|
||||||
align="end"
|
accentHue={settings?.accentHue ?? 142}
|
||||||
title={<FontAwesomeIcon icon={faPalette} />}
|
isDark={effectiveDark}
|
||||||
id="color-theme-dropdown"
|
onChange={hue => settings?.setAccentHue(hue)}
|
||||||
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>
|
|
||||||
<NavDropdown align="end" title={auth?.login} id="basic-nav-dropdown">
|
<NavDropdown align="end" title={auth?.login} id="basic-nav-dropdown">
|
||||||
<NavDropdown.Item onClick={() => setSettingsModalOpen(true)}>Nastavení</NavDropdown.Item>
|
<NavDropdown.Item onClick={() => setSettingsModalOpen(true)}>Nastavení</NavDropdown.Item>
|
||||||
<NavDropdown.Item onClick={() => setRefreshMenuModalOpen(true)}>Přenačtení menu</NavDropdown.Item>
|
<NavDropdown.Item onClick={() => setRefreshMenuModalOpen(true)}>Přenačtení menu</NavDropdown.Item>
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -4,22 +4,23 @@ const BANK_ACCOUNT_NUMBER_KEY = 'bank_account_number';
|
|||||||
const BANK_ACCOUNT_HOLDER_KEY = 'bank_account_holder_name';
|
const BANK_ACCOUNT_HOLDER_KEY = 'bank_account_holder_name';
|
||||||
const HIDE_SOUPS_KEY = 'hide_soups';
|
const HIDE_SOUPS_KEY = 'hide_soups';
|
||||||
const THEME_KEY = 'theme_preference';
|
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 ThemePreference = 'system' | 'light' | 'dark';
|
||||||
export type ColorTheme = 'green' | 'blue' | 'purple';
|
|
||||||
|
|
||||||
export type SettingsContextProps = {
|
export type SettingsContextProps = {
|
||||||
bankAccount?: string,
|
bankAccount?: string,
|
||||||
holderName?: string,
|
holderName?: string,
|
||||||
hideSoups?: boolean,
|
hideSoups?: boolean,
|
||||||
themePreference: ThemePreference,
|
themePreference: ThemePreference,
|
||||||
colorTheme: ColorTheme,
|
accentHue: number,
|
||||||
|
effectiveDark: boolean,
|
||||||
setBankAccountNumber: (accountNumber?: string) => void,
|
setBankAccountNumber: (accountNumber?: string) => void,
|
||||||
setBankAccountHolderName: (holderName?: string) => void,
|
setBankAccountHolderName: (holderName?: string) => void,
|
||||||
setHideSoupsOption: (hideSoups?: boolean) => void,
|
setHideSoupsOption: (hideSoups?: boolean) => void,
|
||||||
setThemePreference: (theme: ThemePreference) => void,
|
setThemePreference: (theme: ThemePreference) => void,
|
||||||
setColorTheme: (color: ColorTheme) => void,
|
setAccentHue: (hue: number) => void,
|
||||||
}
|
}
|
||||||
|
|
||||||
type ContextProps = {
|
type ContextProps = {
|
||||||
@@ -49,16 +50,58 @@ function getInitialTheme(): ThemePreference {
|
|||||||
return 'system';
|
return 'system';
|
||||||
}
|
}
|
||||||
|
|
||||||
function getInitialColorTheme(): ColorTheme {
|
function getInitialAccentHue(): number {
|
||||||
try {
|
try {
|
||||||
const saved = localStorage.getItem(COLOR_THEME_KEY) as ColorTheme | null;
|
const saved = localStorage.getItem(ACCENT_HUE_KEY);
|
||||||
if (saved && ['green', 'blue', 'purple'].includes(saved)) {
|
if (saved !== null) {
|
||||||
return saved;
|
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ý
|
// 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 {
|
function useProvideSettings(): SettingsContextProps {
|
||||||
@@ -66,7 +109,15 @@ function useProvideSettings(): SettingsContextProps {
|
|||||||
const [holderName, setHolderName] = useState<string | undefined>();
|
const [holderName, setHolderName] = useState<string | undefined>();
|
||||||
const [hideSoups, setHideSoups] = useState<boolean | undefined>();
|
const [hideSoups, setHideSoups] = useState<boolean | undefined>();
|
||||||
const [themePreference, setTheme] = useState<ThemePreference>(getInitialTheme);
|
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(() => {
|
useEffect(() => {
|
||||||
const accountNumber = localStorage.getItem(BANK_ACCOUNT_NUMBER_KEY);
|
const accountNumber = localStorage.getItem(BANK_ACCOUNT_NUMBER_KEY);
|
||||||
@@ -112,29 +163,27 @@ function useProvideSettings(): SettingsContextProps {
|
|||||||
}, [themePreference]);
|
}, [themePreference]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
localStorage.setItem(COLOR_THEME_KEY, colorTheme);
|
const applyTheme = (dark: boolean) => {
|
||||||
document.documentElement.setAttribute('data-luncher-color', colorTheme);
|
document.documentElement.setAttribute('data-bs-theme', dark ? 'dark' : 'light');
|
||||||
}, [colorTheme]);
|
setEffectiveDark(dark);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const applyTheme = (theme: 'light' | 'dark') => {
|
|
||||||
document.documentElement.setAttribute('data-bs-theme', theme);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
if (themePreference === 'system') {
|
if (themePreference === 'system') {
|
||||||
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
|
const mq = window.matchMedia('(prefers-color-scheme: dark)');
|
||||||
applyTheme(mediaQuery.matches ? 'dark' : 'light');
|
applyTheme(mq.matches);
|
||||||
|
const handler = (e: MediaQueryListEvent) => applyTheme(e.matches);
|
||||||
const handler = (e: MediaQueryListEvent) => {
|
mq.addEventListener('change', handler);
|
||||||
applyTheme(e.matches ? 'dark' : 'light');
|
return () => mq.removeEventListener('change', handler);
|
||||||
};
|
|
||||||
mediaQuery.addEventListener('change', handler);
|
|
||||||
return () => mediaQuery.removeEventListener('change', handler);
|
|
||||||
} else {
|
} else {
|
||||||
applyTheme(themePreference);
|
applyTheme(themePreference === 'dark');
|
||||||
}
|
}
|
||||||
}, [themePreference]);
|
}, [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) {
|
function setBankAccountNumber(bankAccount?: string) {
|
||||||
setBankAccount(bankAccount);
|
setBankAccount(bankAccount);
|
||||||
}
|
}
|
||||||
@@ -151,8 +200,8 @@ function useProvideSettings(): SettingsContextProps {
|
|||||||
setTheme(theme);
|
setTheme(theme);
|
||||||
}
|
}
|
||||||
|
|
||||||
function setColorTheme(color: ColorTheme) {
|
function setAccentHue(hue: number) {
|
||||||
setColor(color);
|
setHue(hue);
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -160,11 +209,12 @@ function useProvideSettings(): SettingsContextProps {
|
|||||||
holderName,
|
holderName,
|
||||||
hideSoups,
|
hideSoups,
|
||||||
themePreference,
|
themePreference,
|
||||||
colorTheme,
|
accentHue,
|
||||||
|
effectiveDark,
|
||||||
setBankAccountNumber,
|
setBankAccountNumber,
|
||||||
setBankAccountHolderName,
|
setBankAccountHolderName,
|
||||||
setHideSoupsOption,
|
setHideSoupsOption,
|
||||||
setThemePreference,
|
setThemePreference,
|
||||||
setColorTheme,
|
setAccentHue,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user