CI / Generate TypeScript types (push) Successful in 11s
CI / Server unit tests (push) Successful in 27s
CI / Build server (push) Successful in 29s
CI / Build client (push) Successful in 40s
CI / Playwright E2E tests (push) Successful in 1m57s
CI / Build and push Docker image (push) Successful in 46s
CI / Notify (push) Successful in 2s
331 lines
11 KiB
TypeScript
331 lines
11 KiB
TypeScript
import React, { ReactNode, useContext, useEffect, useState } from "react"
|
||
|
||
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 ACCENT_HUE_KEY = 'accent_hue';
|
||
const LEGACY_COLOR_THEME_KEY = 'color_theme';
|
||
const STYLE_THEME_KEY = 'style_theme';
|
||
const SHOW_BUTTERFLIES_KEY = 'show_butterflies';
|
||
const AUTO_ORDER_CHOICE_KEY = 'auto_order_choice';
|
||
|
||
export type ThemePreference = 'system' | 'light' | 'dark';
|
||
|
||
/** Vizuální styl aplikace (nezávislý na světlém/tmavém režimu). */
|
||
export type StyleTheme = 'simple' | 'umamusume' | 'mlp' | 'hellgirl' | 'kon' | 'bocchi' | 'evangelion' | 'yurucamp' | 'spyfamily' | 'apothecary' | 'frieren' | 'mlpg5' | 'aggretsuko' | 'sumikko' | 'gudetama' | 'miku' | 'dungeonmeshi' | 'chiikawa' | 'totoro';
|
||
|
||
export const STYLE_THEMES: { value: StyleTheme, label: string }[] = [
|
||
{ value: 'simple', label: 'Jednoduchý' },
|
||
{ value: 'umamusume', label: 'Umamusume' },
|
||
{ value: 'mlp', label: 'My Little Pony (G4)' },
|
||
{ value: 'mlpg5', label: 'My Little Pony (G5)' },
|
||
{ value: 'hellgirl', label: 'Hell Girl' },
|
||
{ value: 'kon', label: 'K-ON!' },
|
||
{ value: 'bocchi', label: 'Bocchi the Rock!' },
|
||
{ value: 'evangelion', label: 'Neon Genesis Evangelion' },
|
||
{ value: 'yurucamp', label: 'Yuru Camp△' },
|
||
{ value: 'spyfamily', label: 'Spy × Family' },
|
||
{ value: 'apothecary', label: 'Apothecary Diaries' },
|
||
{ value: 'frieren', label: 'Frieren' },
|
||
{ value: 'aggretsuko', label: 'Aggretsuko' },
|
||
{ value: 'sumikko', label: 'Sumikko Gurashi' },
|
||
{ value: 'gudetama', label: 'Gudetama' },
|
||
{ value: 'miku', label: 'Hatsune Miku' },
|
||
{ value: 'dungeonmeshi', label: 'Dungeon Meshi' },
|
||
{ value: 'chiikawa', label: 'Chiikawa' },
|
||
{ value: 'totoro', label: 'Můj soused Totoro' },
|
||
];
|
||
|
||
export type SettingsContextProps = {
|
||
bankAccount?: string,
|
||
holderName?: string,
|
||
hideSoups?: boolean,
|
||
themePreference: ThemePreference,
|
||
accentHue: number,
|
||
effectiveDark: boolean,
|
||
styleTheme: StyleTheme,
|
||
showButterflies: boolean,
|
||
autoOrderChoice: boolean,
|
||
setBankAccountNumber: (accountNumber?: string) => void,
|
||
setBankAccountHolderName: (holderName?: string) => void,
|
||
setHideSoupsOption: (hideSoups?: boolean) => void,
|
||
setThemePreference: (theme: ThemePreference) => void,
|
||
setAccentHue: (hue: number) => void,
|
||
setStyleTheme: (theme: StyleTheme) => void,
|
||
setShowButterflies: (show: boolean) => void,
|
||
setAutoOrderChoice: (enabled: boolean) => void,
|
||
}
|
||
|
||
type ContextProps = {
|
||
children: ReactNode
|
||
}
|
||
|
||
const settingsContext = React.createContext<SettingsContextProps | null>(null);
|
||
|
||
export function ProvideSettings(props: Readonly<ContextProps>) {
|
||
const settings = useProvideSettings();
|
||
return <settingsContext.Provider value={settings}>{props.children}</settingsContext.Provider>
|
||
}
|
||
|
||
export const useSettings = () => {
|
||
return useContext(settingsContext);
|
||
}
|
||
|
||
function getInitialTheme(): ThemePreference {
|
||
try {
|
||
const saved = localStorage.getItem(THEME_KEY) as ThemePreference | null;
|
||
if (saved && ['system', 'light', 'dark'].includes(saved)) {
|
||
return saved;
|
||
}
|
||
} catch (e) {
|
||
// localStorage nedostupný
|
||
}
|
||
return 'system';
|
||
}
|
||
|
||
function getInitialStyleTheme(): StyleTheme {
|
||
try {
|
||
const saved = localStorage.getItem(STYLE_THEME_KEY);
|
||
const known = STYLE_THEMES.find(t => t.value === saved);
|
||
if (known) {
|
||
return known.value;
|
||
}
|
||
} catch {
|
||
// localStorage nedostupný
|
||
}
|
||
return 'simple';
|
||
}
|
||
|
||
function getInitialShowButterflies(): boolean {
|
||
try {
|
||
return localStorage.getItem(SHOW_BUTTERFLIES_KEY) !== 'false';
|
||
} catch {
|
||
// localStorage nedostupný
|
||
}
|
||
return true;
|
||
}
|
||
|
||
function getInitialAutoOrderChoice(): boolean {
|
||
try {
|
||
return localStorage.getItem(AUTO_ORDER_CHOICE_KEY) === 'true';
|
||
} catch {
|
||
// localStorage nedostupný
|
||
}
|
||
return false;
|
||
}
|
||
|
||
function getInitialAccentHue(): number {
|
||
try {
|
||
const saved = localStorage.getItem(ACCENT_HUE_KEY);
|
||
if (saved !== null) {
|
||
const n = parseInt(saved, 10);
|
||
if (!isNaN(n) && n >= 0 && n <= 360) return n;
|
||
}
|
||
// 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 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;
|
||
}
|
||
|
||
const ACCENT_PROPERTIES = ['--luncher-primary', '--luncher-primary-hover', '--luncher-primary-light', '--luncher-action-icon', '--luncher-success'];
|
||
|
||
function clearAccentColors(): void {
|
||
ACCENT_PROPERTIES.forEach(p => document.documentElement.style.removeProperty(p));
|
||
}
|
||
|
||
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 {
|
||
const [bankAccount, setBankAccount] = useState<string | undefined>();
|
||
const [holderName, setHolderName] = useState<string | undefined>();
|
||
const [hideSoups, setHideSoups] = useState<boolean | undefined>();
|
||
const [themePreference, setTheme] = useState<ThemePreference>(getInitialTheme);
|
||
const [accentHue, setHue] = useState<number>(getInitialAccentHue);
|
||
const [styleTheme, setStyle] = useState<StyleTheme>(getInitialStyleTheme);
|
||
const [showButterflies, setShowButterfliesState] = useState<boolean>(getInitialShowButterflies);
|
||
const [autoOrderChoice, setAutoOrderChoiceState] = useState<boolean>(getInitialAutoOrderChoice);
|
||
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);
|
||
if (accountNumber) {
|
||
setBankAccount(accountNumber);
|
||
}
|
||
const holderName = localStorage.getItem(BANK_ACCOUNT_HOLDER_KEY);
|
||
if (holderName) {
|
||
setHolderName(holderName);
|
||
}
|
||
const hideSoups = localStorage.getItem(HIDE_SOUPS_KEY);
|
||
if (hideSoups !== null) {
|
||
setHideSoups(hideSoups === 'true');
|
||
}
|
||
}, [])
|
||
|
||
useEffect(() => {
|
||
if (bankAccount) {
|
||
localStorage.setItem(BANK_ACCOUNT_NUMBER_KEY, bankAccount)
|
||
} else {
|
||
localStorage.removeItem(BANK_ACCOUNT_NUMBER_KEY);
|
||
}
|
||
}, [bankAccount]);
|
||
|
||
useEffect(() => {
|
||
if (holderName) {
|
||
localStorage.setItem(BANK_ACCOUNT_HOLDER_KEY, holderName);
|
||
} else {
|
||
localStorage.removeItem(BANK_ACCOUNT_HOLDER_KEY);
|
||
}
|
||
}, [holderName]);
|
||
|
||
useEffect(() => {
|
||
if (hideSoups) {
|
||
localStorage.setItem(HIDE_SOUPS_KEY, hideSoups ? 'true' : 'false');
|
||
} else {
|
||
localStorage.removeItem(HIDE_SOUPS_KEY);
|
||
}
|
||
}, [hideSoups]);
|
||
|
||
useEffect(() => {
|
||
localStorage.setItem(THEME_KEY, themePreference);
|
||
}, [themePreference]);
|
||
|
||
useEffect(() => {
|
||
const applyTheme = (dark: boolean) => {
|
||
document.documentElement.setAttribute('data-bs-theme', dark ? 'dark' : 'light');
|
||
setEffectiveDark(dark);
|
||
};
|
||
if (themePreference === 'system') {
|
||
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 === 'dark');
|
||
}
|
||
}, [themePreference]);
|
||
|
||
useEffect(() => {
|
||
localStorage.setItem(STYLE_THEME_KEY, styleTheme);
|
||
document.documentElement.setAttribute('data-luncher-theme', styleTheme);
|
||
}, [styleTheme]);
|
||
|
||
useEffect(() => {
|
||
localStorage.setItem(SHOW_BUTTERFLIES_KEY, showButterflies ? 'true' : 'false');
|
||
}, [showButterflies]);
|
||
|
||
useEffect(() => {
|
||
localStorage.setItem(AUTO_ORDER_CHOICE_KEY, autoOrderChoice ? 'true' : 'false');
|
||
}, [autoOrderChoice]);
|
||
|
||
// Aplikuje accent barvy při změně hue nebo přepnutí světlý/tmavý.
|
||
// Tematické styly (Umamusume, My Little Pony, Hell Girl, K-ON!, Bocchi the Rock!, Evangelion, Yuru Camp△, Spy × Family, Apothecary Diaries, Frieren, My Little Pony G5, Aggretsuko, Sumikko Gurashi, Gudetama, Hatsune Miku, Dungeon Meshi, Chiikawa, Můj soused Totoro) mají vlastní pevnou paletu, accent barvu proto uplatňuje jen jednoduchý styl.
|
||
useEffect(() => {
|
||
localStorage.setItem(ACCENT_HUE_KEY, String(accentHue));
|
||
if (styleTheme === 'simple') {
|
||
applyAccentColors(accentHue, effectiveDark);
|
||
} else {
|
||
clearAccentColors();
|
||
}
|
||
}, [accentHue, effectiveDark, styleTheme]);
|
||
|
||
function setBankAccountNumber(bankAccount?: string) {
|
||
setBankAccount(bankAccount);
|
||
}
|
||
|
||
function setBankAccountHolderName(holderName?: string) {
|
||
setHolderName(holderName);
|
||
}
|
||
|
||
function setHideSoupsOption(hideSoups?: boolean) {
|
||
setHideSoups(hideSoups);
|
||
}
|
||
|
||
function setThemePreference(theme: ThemePreference) {
|
||
setTheme(theme);
|
||
}
|
||
|
||
function setAccentHue(hue: number) {
|
||
setHue(hue);
|
||
}
|
||
|
||
function setStyleTheme(theme: StyleTheme) {
|
||
setStyle(theme);
|
||
}
|
||
|
||
function setShowButterflies(show: boolean) {
|
||
setShowButterfliesState(show);
|
||
}
|
||
|
||
function setAutoOrderChoice(enabled: boolean) {
|
||
setAutoOrderChoiceState(enabled);
|
||
}
|
||
|
||
return {
|
||
bankAccount,
|
||
holderName,
|
||
hideSoups,
|
||
themePreference,
|
||
accentHue,
|
||
effectiveDark,
|
||
setBankAccountNumber,
|
||
setBankAccountHolderName,
|
||
setHideSoupsOption,
|
||
setThemePreference,
|
||
setAccentHue,
|
||
styleTheme,
|
||
setStyleTheme,
|
||
showButterflies,
|
||
setShowButterflies,
|
||
autoOrderChoice,
|
||
setAutoOrderChoice,
|
||
}
|
||
}
|