diff --git a/client/index.html b/client/index.html index 036447b..ed985c6 100644 --- a/client/index.html +++ b/client/index.html @@ -6,6 +6,9 @@ Food Tracer + + + diff --git a/client/src/App.scss b/client/src/App.scss index 5408f04..5a60594 100644 --- a/client/src/App.scss +++ b/client/src/App.scss @@ -182,9 +182,11 @@ body { padding: 6px 14px; font-weight: 500; font-size: 0.9rem; + text-decoration: none; transition: var(--ft-transition); &:hover { + color: var(--ft-navbar-text); background: rgb(255 255 255 / 0.1); } diff --git a/client/src/AppRoutes.tsx b/client/src/AppRoutes.tsx index 15c832a..2e35818 100644 --- a/client/src/AppRoutes.tsx +++ b/client/src/AppRoutes.tsx @@ -2,16 +2,20 @@ import { Routes, Route, Navigate } from "react-router-dom"; import { ToastContainer } from "react-toastify"; import { useAuth } from "./context/auth"; import Header from "./components/Header"; +import HellGirlFlames from "./components/HellGirlFlames"; import Login from "./Login"; import DayPage from "./pages/DayPage"; import StatsPage from "./pages/StatsPage"; import WorkoutsPage from "./pages/WorkoutsPage"; import ProfilePage from "./pages/ProfilePage"; import MealGroupsPage from "./pages/MealGroupsPage"; +import { useSettings } from "./context/settings"; +import { StyleTheme } from "./enums"; import { DEN_URL, JIDLA_URL, PROFIL_URL, STATISTIKY_URL, TRENINKY_URL } from "./routes"; export default function AppRoutes() { const auth = useAuth(); + const settings = useSettings(); if (!auth?.login) { return ; @@ -20,6 +24,7 @@ export default function AppRoutes() { return (
+ {settings?.styleTheme === StyleTheme.HELL_GIRL && }
} /> diff --git a/client/src/components/Header.tsx b/client/src/components/Header.tsx index faf977d..562ccb1 100644 --- a/client/src/components/Header.tsx +++ b/client/src/components/Header.tsx @@ -1,6 +1,6 @@ import { useState } from "react"; import { Navbar, Nav, NavDropdown } from "react-bootstrap"; -import { useLocation, useNavigate } from "react-router"; +import { Link, useLocation } from "react-router"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { faSun, faMoon, faGear } from "@fortawesome/free-solid-svg-icons"; import { useAuth } from "../context/auth"; @@ -21,7 +21,6 @@ export default function Header() { const [settingsOpen, setSettingsOpen] = useState(false); const auth = useAuth(); const settings = useSettings(); - const navigate = useNavigate(); const location = useLocation(); const effectiveDark = settings?.effectiveDark ?? false; @@ -37,18 +36,18 @@ export default function Header() { return ( - Food Tracer + Food Tracer
- {(entry.nutrientsPer100g || entry.multiplier != null) && ( + {(nutrientsStay || entry.multiplier != null) && (

{entry.multiplier != null && `Násobek ${formatMultiplier(entry.multiplier)}`} - {entry.multiplier != null && entry.nutrientsPer100g && ' a '} - {entry.nutrientsPer100g && (entry.multiplier != null ? 'živiny' : 'Živiny')} + {entry.multiplier != null && nutrientsStay && ' a '} + {nutrientsStay && (entry.multiplier != null ? 'živiny' : 'Živiny')} {' '}zůstávají u původního jídla, novému je doplníte v úpravě.

)} diff --git a/client/src/components/modals/SettingsModal.tsx b/client/src/components/modals/SettingsModal.tsx index 79bfe44..c2a0289 100644 --- a/client/src/components/modals/SettingsModal.tsx +++ b/client/src/components/modals/SettingsModal.tsx @@ -4,6 +4,8 @@ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { faTrash, faPlus } from "@fortawesome/free-solid-svg-icons"; import { UserSettings, getSettings, saveSourceRate } from "../../../../types"; import { formatPriceInput, parsePriceInput } from "../../Utils"; +import { STYLE_THEME_NAMES, StyleTheme } from "../../enums"; +import { useSettings } from "../../context/settings"; type Props = { isOpen: boolean, @@ -11,7 +13,7 @@ type Props = { }; /** - * Nastavení sazeb za 100 g u podniků, které účtují podle váhy. + * Nastavení vzhledu a sazeb za 100 g u podniků, které účtují podle váhy. * * Sazba slouží jako výchozí hodnota při zadávání jídla — u konkrétní položky * ji jde přepsat, protože salát bývá účtovaný jinak než hlavní jídlo. @@ -21,6 +23,7 @@ export default function SettingsModal({ isOpen, onClose }: Readonly) { const [newSource, setNewSource] = useState(''); const [newRate, setNewRate] = useState(''); const [saving, setSaving] = useState(false); + const appSettings = useSettings(); useEffect(() => { if (!isOpen) return; @@ -52,6 +55,19 @@ export default function SettingsModal({ isOpen, onClose }: Readonly) { Nastavení +

Vzhled

+ + Styl + appSettings?.setStyleTheme(event.target.value as StyleTheme)} + > + {Object.values(StyleTheme).map(value => ( + + ))} + + +

Cena za 100 g

U podniků, které účtují podle váhy, se z ceny jídla a této sazby dopočítá diff --git a/client/src/context/settings.tsx b/client/src/context/settings.tsx index 93c41db..a412c27 100644 --- a/client/src/context/settings.tsx +++ b/client/src/context/settings.tsx @@ -1,15 +1,19 @@ import React, { ReactNode, useCallback, useContext, useEffect, useMemo, useState } from "react"; +import { StyleTheme } from "../enums"; /** Volba motivu — buď pevná, nebo řízená nastavením operačního systému. */ export type ThemePreference = 'light' | 'dark' | 'system'; const THEME_KEY = 'themePreference'; +const STYLE_THEME_KEY = 'styleTheme'; export type SettingsContextProps = { themePreference: ThemePreference, /** Skutečně použitý motiv po vyhodnocení volby 'system'. */ effectiveDark: boolean, setThemePreference: (preference: ThemePreference) => void, + styleTheme: StyleTheme, + setStyleTheme: (styleTheme: StyleTheme) => void, }; const settingsContext = React.createContext(null); @@ -22,8 +26,15 @@ function getStoredPreference(): ThemePreference { return stored === 'light' || stored === 'dark' ? stored : 'system'; } +/** Vrátí uložený vizuální styl; neznámou nebo chybějící hodnotu nahradí výchozím. */ +const getStoredStyleTheme = (): StyleTheme => { + const stored = localStorage.getItem(STYLE_THEME_KEY); + return Object.values(StyleTheme).find(value => value === stored) ?? StyleTheme.JEDNODUCHY; +}; + export function ProvideSettings(props: Readonly<{ children: ReactNode }>) { const [themePreference, setStoredPreference] = useState(getStoredPreference); + const [styleTheme, setStyleThemeState] = useState(getStoredStyleTheme); const [systemDark, setSystemDark] = useState(() => globalThis.matchMedia?.('(prefers-color-scheme: dark)').matches ?? false); @@ -43,6 +54,19 @@ export function ProvideSettings(props: Readonly<{ children: ReactNode }>) { document.documentElement.setAttribute('data-bs-theme', effectiveDark ? 'dark' : 'light'); }, [effectiveDark]); + useEffect(() => { + document.documentElement.setAttribute('data-ft-style', styleTheme); + }, [styleTheme]); + + const setStyleTheme = useCallback((value: StyleTheme) => { + setStyleThemeState(value); + if (value === StyleTheme.JEDNODUCHY) { + localStorage.removeItem(STYLE_THEME_KEY); + } else { + localStorage.setItem(STYLE_THEME_KEY, value); + } + }, []); + const setThemePreference = useCallback((preference: ThemePreference) => { setStoredPreference(preference); if (preference === 'system') { @@ -53,8 +77,8 @@ export function ProvideSettings(props: Readonly<{ children: ReactNode }>) { }, []); const value = useMemo( - () => ({ themePreference, effectiveDark, setThemePreference }), - [themePreference, effectiveDark, setThemePreference]); + () => ({ themePreference, effectiveDark, setThemePreference, styleTheme, setStyleTheme }), + [themePreference, effectiveDark, setThemePreference, styleTheme, setStyleTheme]); return {props.children}; } diff --git a/client/src/enums.ts b/client/src/enums.ts index 078e66a..8c885f3 100644 --- a/client/src/enums.ts +++ b/client/src/enums.ts @@ -192,3 +192,17 @@ export enum SourceFilter { VSE = 'VSE', BEZ_ZDROJE = 'BEZ_ZDROJE', } + +/** + * Vizuální styl aplikace, nezávislý na světlém/tmavém režimu. Převzaté z Luncheru, + * hodnota se zapisuje do atributu `data-ft-style` na . + */ +export enum StyleTheme { + JEDNODUCHY = 'JEDNODUCHY', + HELL_GIRL = 'HELL_GIRL', +} + +export const STYLE_THEME_NAMES: Record = { + JEDNODUCHY: 'Jednoduchý', + HELL_GIRL: 'Hell Girl', +}; diff --git a/client/src/index.tsx b/client/src/index.tsx index e0d9622..081c04d 100644 --- a/client/src/index.tsx +++ b/client/src/index.tsx @@ -11,6 +11,7 @@ import 'bootstrap/dist/css/bootstrap.min.css'; import 'react-toastify/dist/ReactToastify.css'; import './index.css'; import './App.scss'; +import './themes/hellgirl.scss'; client.setConfig({ auth: () => getToken(), diff --git a/client/src/themes/hellgirl-flame.gif b/client/src/themes/hellgirl-flame.gif new file mode 100644 index 0000000..eabe6d3 Binary files /dev/null and b/client/src/themes/hellgirl-flame.gif differ diff --git a/client/src/themes/hellgirl-symbol.png b/client/src/themes/hellgirl-symbol.png new file mode 100644 index 0000000..d15083e Binary files /dev/null and b/client/src/themes/hellgirl-symbol.png differ diff --git a/client/src/themes/hellgirl.scss b/client/src/themes/hellgirl.scss new file mode 100644 index 0000000..0821dc5 --- /dev/null +++ b/client/src/themes/hellgirl.scss @@ -0,0 +1,290 @@ +// ============================================ +// STYL "HELL GIRL" (volitelný, výchozí je jednoduchý styl) +// Převzato z Luncheru (plamen při otevření dialogu je v HellGirlFlames). Inspirováno anime Jigoku Shōjo (地獄少女): +// purpurovo-rudé nebe soumraku, papír washi, karmínová rudá nit, květy higanbany +// a ostré tradiční tvary s písmem mincho. Tmavý režim = půlnoc Pekelné korespondence. +// Aktivní, pokud má atribut data-ft-style="HELL_GIRL". +// ============================================ + +$hg: 'html[data-ft-style="HELL_GIRL"]'; + +// Nebe věčného soumraku (fialová → karmínová → oranžová) +$hg-twilight: linear-gradient(90deg, #1e0a1c, #4a0f2a 35%, #8e0f24 70%, #c8452a); +// Rudá nit — tenká linka s jemnou září +$hg-thread: linear-gradient(90deg, transparent, #c8102e 12%, #e8344f 50%, #c8102e 88%, transparent); + +#{$hg} { + --hg-font: 'Shippori Mincho', 'Noto Serif JP', 'Georgia', serif; + --hg-crimson: #a3122a; + --hg-crimson-deep: #7a0c1f; + --hg-crimson-shadow: #4a0612; + --hg-higanbana: #d4203c; + --hg-dusk: #c8452a; + --hg-ink: #1c1012; + --hg-washi: #f4ece0; + --hg-card-border: #dccbbb; + --hg-petal: rgb(163 18 42 / 0.10); + --hg-petal-2: rgb(200 69 42 / 0.08); + + --ft-primary: var(--hg-crimson); + --ft-primary-hover: var(--hg-crimson-deep); + --ft-primary-light: #f7e3e2; + --ft-bg: var(--hg-washi); + --ft-bg-card: #fffbf5; + --ft-bg-hover: #f8ebe6; + --ft-text: var(--hg-ink); + --ft-text-secondary: #4f3a3c; + --ft-text-muted: #8c7672; + --ft-border: var(--hg-card-border); + --ft-border-light: #eadfd2; + --ft-navbar-bg: var(--hg-crimson-deep); + --ft-navbar-text: #fff4ee; + --ft-success: #3d7a4a; + --ft-warning: #c07a1a; + --ft-danger: #c8102e; + // Ostré, tradiční tvary — téměř bez zaoblení + --ft-radius-sm: 2px; + --ft-radius: 3px; + --ft-radius-lg: 4px; + --ft-radius-xl: 6px; + --ft-shadow-sm: 0 1px 4px -2px rgb(74 6 18 / 0.2); + --ft-shadow: 0 3px 12px -4px rgb(74 6 18 / 0.25); + --ft-shadow-lg: 0 10px 26px -8px rgb(74 6 18 / 0.38); + + &[data-bs-theme="dark"] { + --hg-crimson: #e0334f; + --hg-crimson-deep: #b81c38; + --hg-crimson-shadow: #3a0510; + --hg-higanbana: #ff4d63; + --hg-dusk: #ff7a4a; + --hg-card-border: #3d1c22; + --hg-petal: rgb(224 51 79 / 0.22); + --hg-petal-2: rgb(255 122 74 / 0.12); + + --ft-primary: #ec4a63; + --ft-primary-hover: #ff6b80; + --ft-primary-light: #2e1015; + --ft-bg: #0c0608; + --ft-bg-card: #170c0f; + --ft-bg-hover: #221216; + --ft-text: #f3e6e2; + --ft-text-secondary: #c9aeaa; + --ft-text-muted: #8a6c6a; + --ft-border: #3d1c22; + --ft-border-light: #2a1418; + --ft-navbar-bg: #12070a; + --ft-success: #6fbf7e; + --ft-warning: #f0b45a; + --ft-danger: #ff4d63; + --ft-shadow-sm: 0 1px 4px -1px rgb(0 0 0 / 0.6); + --ft-shadow: 0 4px 16px -4px rgb(0 0 0 / 0.7); + --ft-shadow-lg: 0 12px 30px -8px rgb(0 0 0 / 0.8), 0 0 18px -6px rgb(224 51 79 / 0.25); + } + + body { + font-family: var(--hg-font); + background-color: var(--ft-bg); + // Rozfoukané okvětní lístky higanbany nad papírem, nahoře záře soumraku + background-image: + radial-gradient(ellipse 3px 1.5px at 20% 30%, var(--hg-petal) 0 100%, transparent 101%), + radial-gradient(ellipse 2px 4px at 70% 75%, var(--hg-petal-2) 0 100%, transparent 101%), + radial-gradient(ellipse 2.5px 1.2px at 85% 15%, var(--hg-petal) 0 100%, transparent 101%), + linear-gradient(180deg, #f3d9cc 0, var(--ft-bg) 380px); + background-size: 150px 150px, 210px 210px, 260px 260px, 100% 100%; + background-attachment: fixed; + } + + &[data-bs-theme="dark"] body { + // Půlnoční tma s rudou září pekelného ohně odspodu + background-image: + radial-gradient(ellipse 3px 1.5px at 20% 30%, var(--hg-petal) 0 100%, transparent 101%), + radial-gradient(ellipse 2px 4px at 70% 75%, var(--hg-petal-2) 0 100%, transparent 101%), + radial-gradient(ellipse 2.5px 1.2px at 85% 15%, var(--hg-petal) 0 100%, transparent 101%), + radial-gradient(ellipse 120% 45% at 50% 115%, rgb(140 12 36 / 0.35), transparent 70%), + linear-gradient(180deg, #1c0a12 0, var(--ft-bg) 420px); + background-size: 150px 150px, 210px 210px, 260px 260px, 100% 100%, 100% 100%; + } + + .app-container { + background: transparent; + } + + h1, h2, h3, h4, .navbar-brand, .btn, .modal-title { + font-family: var(--hg-font); + } + + // ---------- Navbar: nebe soumraku s rudou nití dole ---------- + .navbar { + background: + #{$hg-thread} bottom / 100% 2px no-repeat, + #{$hg-twilight} !important; + box-shadow: 0 3px 14px -2px rgb(74 6 18 / 0.5); + + .navbar-brand { + font-weight: 800; + letter-spacing: 1.5px; + text-shadow: 0 0 12px rgb(232 52 79 / 0.6); + + // Pekelná korespondence + &::after { + content: '地獄通信'; + display: inline-block; + margin-left: 8px; + font-size: 0.55em; + font-weight: 600; + letter-spacing: 2px; + vertical-align: middle; + color: #ffb3a8; + opacity: 0.85; + animation: hg-flicker 5s ease-in-out infinite; + } + } + + .nav-pill.active { + background: rgb(0 0 0 / 0.35); + box-shadow: inset 0 -2px 0 #e8344f; + } + + .dropdown-menu { + border: 1px solid var(--hg-crimson); + } + } + + &[data-bs-theme="dark"] .navbar { + background: + #{$hg-thread} bottom / 100% 2px no-repeat, + linear-gradient(90deg, #0a0406, #1f0810 55%, #3d0a18) !important; + } + + // ---------- Výběr dne: černá lakovaná deska s rudým lemem a symbolem ohně ---------- + .day-picker { + background: linear-gradient(180deg, #2a1216, #12070a); + border: 1px solid var(--hg-crimson); + outline: 1px solid rgb(163 18 42 / 0.35); + outline-offset: 3px; + + .day-picker-date { + color: #fff4ee; + font-family: var(--hg-font); + font-weight: 800; + letter-spacing: 0.5px; + text-shadow: 0 0 10px rgb(232 52 79 / 0.55); + + &::before { + content: ''; + display: inline-block; + width: 0.9em; + height: 0.9em; + margin-right: 10px; + vertical-align: -0.1em; + background: linear-gradient(180deg, var(--hg-dusk), var(--hg-higanbana) 45%, var(--hg-crimson-deep)); + -webkit-mask: url('./hellgirl-symbol.png') center / contain no-repeat; + mask: url('./hellgirl-symbol.png') center / contain no-repeat; + } + } + + .day-picker-weekday { + color: #c9aeaa; + } + + .btn-outline-secondary, .btn-icon { + color: #fff4ee; + border-color: rgb(255 244 238 / 0.3); + } + } + + // ---------- Panely a dlaždice ---------- + .card-panel, + .summary-tile { + border: 1px solid var(--hg-card-border); + border-top: 3px solid var(--hg-crimson); + box-shadow: var(--ft-shadow); + } + + .day-tab.active { + border-bottom-color: var(--hg-crimson); + } + + .form-select, .form-control { + &:focus { + border-color: var(--hg-crimson); + box-shadow: 0 0 0 3px rgb(200 16 46 / 0.2); + } + } + + // ---------- Tlačítka: ostrá, karmínová s dvojitou linkou jako pás obi ---------- + .btn { + font-weight: 700; + letter-spacing: 0.4px; + } + + .btn-primary { + background: linear-gradient(180deg, #c01733, var(--hg-crimson-deep)); + border: 1px solid var(--hg-crimson-shadow); + color: #fff4ee; + box-shadow: inset 0 0 0 2px rgb(255 255 255 / 0.12); + + &:hover, &:focus, &:active { + background: linear-gradient(180deg, #d41d3b, #8e0f24) !important; + border-color: var(--hg-crimson-shadow) !important; + box-shadow: inset 0 0 0 2px rgb(255 255 255 / 0.18), 0 0 14px rgb(212 32 60 / 0.5); + } + + &:disabled { + background: #b9a3a0; + border-color: #9c8582; + box-shadow: none; + } + } + + .btn-danger { + background: linear-gradient(180deg, #e0243f, #8e0f24); + border: 1px solid var(--hg-crimson-shadow); + } + + // ---------- Modaly ---------- + .modal-content { + border: 1px solid var(--hg-crimson); + overflow: hidden; + } + + .modal-header { + background: + #{$hg-thread} bottom / 100% 2px no-repeat, + #{$hg-twilight}; + border-bottom: none; + + .modal-title { + color: #fff4ee; + font-weight: 800; + letter-spacing: 0.5px; + text-shadow: 0 0 10px rgb(232 52 79 / 0.5); + } + + .btn-close { + filter: invert(1) grayscale(1) brightness(2); + } + } +} + +// Mihotání nápisu jako plamen svíčky +@keyframes hg-flicker { + 0%, 100% { opacity: 0.85; } + 45% { opacity: 0.85; } + 48% { opacity: 0.35; } + 50% { opacity: 0.9; } + 53% { opacity: 0.5; } + 56% { opacity: 0.85; } +} + +@media (prefers-reduced-motion: reduce) { + #{$hg} .navbar .navbar-brand::after { + animation: none; + } +} + +@media (max-width: 575.98px) { + #{$hg} .navbar .navbar-brand::after { + display: none; + } +}