fix: split umožní hledat i v prvním sloupci, kopírují se i podrobné hodnoty
fix: tlačítka z headeru jdou i přes kolečko feat: nový mode zobrazení
This commit is contained in:
1 parent
e13f7ce8e5
commit
b637e243d5
14 files changed
+553
-67
No files matched your search
@@ -6,6 +6,9 @@
|
|||||||
<meta name="theme-color" content="#111827" />
|
<meta name="theme-color" content="#111827" />
|
||||||
<meta name="description" content="Food Tracer — evidence snědených jídel a útraty za jídlo" />
|
<meta name="description" content="Food Tracer — evidence snědených jídel a útraty za jídlo" />
|
||||||
<title>Food Tracer</title>
|
<title>Food Tracer</title>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Shippori+Mincho:wght@600;700;800&display=swap" rel="stylesheet" />
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<noscript>Pro běh aplikace je potřeba zapnutý JavaScript.</noscript>
|
<noscript>Pro běh aplikace je potřeba zapnutý JavaScript.</noscript>
|
||||||
|
|||||||
@@ -182,9 +182,11 @@ body {
|
|||||||
padding: 6px 14px;
|
padding: 6px 14px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
|
text-decoration: none;
|
||||||
transition: var(--ft-transition);
|
transition: var(--ft-transition);
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
|
color: var(--ft-navbar-text);
|
||||||
background: rgb(255 255 255 / 0.1);
|
background: rgb(255 255 255 / 0.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,16 +2,20 @@ import { Routes, Route, Navigate } from "react-router-dom";
|
|||||||
import { ToastContainer } from "react-toastify";
|
import { ToastContainer } from "react-toastify";
|
||||||
import { useAuth } from "./context/auth";
|
import { useAuth } from "./context/auth";
|
||||||
import Header from "./components/Header";
|
import Header from "./components/Header";
|
||||||
|
import HellGirlFlames from "./components/HellGirlFlames";
|
||||||
import Login from "./Login";
|
import Login from "./Login";
|
||||||
import DayPage from "./pages/DayPage";
|
import DayPage from "./pages/DayPage";
|
||||||
import StatsPage from "./pages/StatsPage";
|
import StatsPage from "./pages/StatsPage";
|
||||||
import WorkoutsPage from "./pages/WorkoutsPage";
|
import WorkoutsPage from "./pages/WorkoutsPage";
|
||||||
import ProfilePage from "./pages/ProfilePage";
|
import ProfilePage from "./pages/ProfilePage";
|
||||||
import MealGroupsPage from "./pages/MealGroupsPage";
|
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";
|
import { DEN_URL, JIDLA_URL, PROFIL_URL, STATISTIKY_URL, TRENINKY_URL } from "./routes";
|
||||||
|
|
||||||
export default function AppRoutes() {
|
export default function AppRoutes() {
|
||||||
const auth = useAuth();
|
const auth = useAuth();
|
||||||
|
const settings = useSettings();
|
||||||
|
|
||||||
if (!auth?.login) {
|
if (!auth?.login) {
|
||||||
return <Login />;
|
return <Login />;
|
||||||
@@ -20,6 +24,7 @@ export default function AppRoutes() {
|
|||||||
return (
|
return (
|
||||||
<div className="app-container">
|
<div className="app-container">
|
||||||
<Header />
|
<Header />
|
||||||
|
{settings?.styleTheme === StyleTheme.HELL_GIRL && <HellGirlFlames />}
|
||||||
<div className="wrapper">
|
<div className="wrapper">
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path={DEN_URL} element={<DayPage />} />
|
<Route path={DEN_URL} element={<DayPage />} />
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { Navbar, Nav, NavDropdown } from "react-bootstrap";
|
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 { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
import { faSun, faMoon, faGear } from "@fortawesome/free-solid-svg-icons";
|
import { faSun, faMoon, faGear } from "@fortawesome/free-solid-svg-icons";
|
||||||
import { useAuth } from "../context/auth";
|
import { useAuth } from "../context/auth";
|
||||||
@@ -21,7 +21,6 @@ export default function Header() {
|
|||||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||||
const auth = useAuth();
|
const auth = useAuth();
|
||||||
const settings = useSettings();
|
const settings = useSettings();
|
||||||
const navigate = useNavigate();
|
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
|
|
||||||
const effectiveDark = settings?.effectiveDark ?? false;
|
const effectiveDark = settings?.effectiveDark ?? false;
|
||||||
@@ -37,18 +36,18 @@ export default function Header() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Navbar variant="dark" expand="lg">
|
<Navbar variant="dark" expand="lg">
|
||||||
<Navbar.Brand href={DEN_URL}>Food Tracer</Navbar.Brand>
|
<Navbar.Brand as={Link} to={DEN_URL}>Food Tracer</Navbar.Brand>
|
||||||
<Navbar.Toggle aria-controls="main-navbar-nav" />
|
<Navbar.Toggle aria-controls="main-navbar-nav" />
|
||||||
<Navbar.Collapse id="main-navbar-nav">
|
<Navbar.Collapse id="main-navbar-nav">
|
||||||
<Nav className="nav">
|
<Nav className="nav">
|
||||||
{NAV_ITEMS.map(item => (
|
{NAV_ITEMS.map(item => (
|
||||||
<button
|
<Link
|
||||||
key={item.url}
|
key={item.url}
|
||||||
|
to={item.url}
|
||||||
className={`nav-pill ${isActive(item.url) ? 'active' : ''}`}
|
className={`nav-pill ${isActive(item.url) ? 'active' : ''}`}
|
||||||
onClick={() => navigate(item.url)}
|
|
||||||
>
|
>
|
||||||
{item.label}
|
{item.label}
|
||||||
</button>
|
</Link>
|
||||||
))}
|
))}
|
||||||
<button
|
<button
|
||||||
className="theme-toggle"
|
className="theme-toggle"
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
// Plamen stylu Hell Girl — hoří na černé přes obsah dialogu (.modal-content je relative a ve stylu
|
||||||
|
// Hell Girl ořezává přetečení), pak černá odezní a odhalí obsah.
|
||||||
|
.hg-burn {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 10;
|
||||||
|
background: #020204; // barva pozadí GIFu, aby kolem plamene nebyl vidět obdélník
|
||||||
|
pointer-events: none;
|
||||||
|
animation: hg-burn-reveal 1.5s ease-in forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hg-burn-flame {
|
||||||
|
position: absolute;
|
||||||
|
left: 50%;
|
||||||
|
// Dlouhý dialog může přesahovat obrazovku — plamen držet ve viditelné části
|
||||||
|
top: min(50%, 45vh);
|
||||||
|
height: min(85%, 60vh);
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes hg-burn-reveal {
|
||||||
|
0%, 50% { opacity: 1; }
|
||||||
|
100% { opacity: 0; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { createPortal } from "react-dom";
|
||||||
|
import flameUrl from "../themes/hellgirl-flame.gif";
|
||||||
|
import "./HellGirlFlames.scss";
|
||||||
|
|
||||||
|
/** Plamen vzplane a rozpadne se v jiskry za ~1,2 s, pak GIF drží černý snímek — mezitím se obsah vynoří z černé. */
|
||||||
|
const BURN_MS = 1500;
|
||||||
|
|
||||||
|
/** `target` = obsah dialogu, ve kterém se hoří. */
|
||||||
|
type Burn = { src: string, target: HTMLElement };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Doplněk stylu Hell Girl převzatý z Luncheru: při otevření dialogu jeho obsah zčerná,
|
||||||
|
* uprostřed jednou vzplane plamen („Ippen, shinde miru?") a obsah se pak z černé vynoří.
|
||||||
|
*/
|
||||||
|
const HellGirlFlames = () => {
|
||||||
|
const [burning, setBurning] = useState<Burn>();
|
||||||
|
const flameBlobRef = useRef<Blob | undefined>(undefined);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetch(flameUrl)
|
||||||
|
.then(response => response.ok ? response.blob() : undefined)
|
||||||
|
.then(blob => { flameBlobRef.current = blob; })
|
||||||
|
.catch(() => { /* bez plamenů */ });
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!burning) return;
|
||||||
|
const timeout = setTimeout(() => setBurning(undefined), BURN_MS);
|
||||||
|
return () => {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
URL.revokeObjectURL(burning.src);
|
||||||
|
};
|
||||||
|
}, [burning]);
|
||||||
|
|
||||||
|
// Dialogy (react-bootstrap) se vkládají přes portál přímo do <body>
|
||||||
|
useEffect(() => {
|
||||||
|
const burn = (target: HTMLElement) => {
|
||||||
|
const blob = flameBlobRef.current;
|
||||||
|
if (!blob || globalThis.matchMedia?.('(prefers-reduced-motion: reduce)').matches) return;
|
||||||
|
// Pokaždé nová URL — jinak by prohlížeč GIF (bez opakování) znovu nepřehrál
|
||||||
|
setBurning({ src: URL.createObjectURL(blob), target });
|
||||||
|
};
|
||||||
|
const observer = new MutationObserver(mutations => {
|
||||||
|
const modalContent = mutations
|
||||||
|
.flatMap(mutation => Array.from(mutation.addedNodes))
|
||||||
|
.map(node => node instanceof HTMLElement ? node.querySelector<HTMLElement>('.modal-content') : null)
|
||||||
|
.find(element => !!element);
|
||||||
|
if (modalContent) burn(modalContent);
|
||||||
|
});
|
||||||
|
observer.observe(document.body, { childList: true });
|
||||||
|
return () => observer.disconnect();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (!burning) return null;
|
||||||
|
return createPortal(
|
||||||
|
<div className="hg-burn" aria-hidden="true">
|
||||||
|
<img className="hg-burn-flame" src={burning.src} alt="" />
|
||||||
|
</div>,
|
||||||
|
burning.target,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default HellGirlFlames;
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { Alert, Button, Form, Modal } from "react-bootstrap";
|
import { Alert, Button, Form, Modal } from "react-bootstrap";
|
||||||
import {
|
import {
|
||||||
DayRecord, MealEntry, MealInput, MealSuggestion, MealTemplate, MealType, getMealHistory, getMealTemplates, splitMeal,
|
DayRecord, MealEntry, MealInput, MealSuggestion, MealTemplate, MealType, Nutrients, getMealHistory, getMealTemplates,
|
||||||
|
splitMeal,
|
||||||
} from "../../../../types";
|
} from "../../../../types";
|
||||||
import { MEAL_TYPES_IN_ORDER, MEAL_TYPE_ICONS, MEAL_TYPE_NAMES } from "../../enums";
|
import { MEAL_TYPES_IN_ORDER, MEAL_TYPE_ICONS, MEAL_TYPE_NAMES } from "../../enums";
|
||||||
import {
|
import {
|
||||||
@@ -31,8 +32,16 @@ type SplitForm = {
|
|||||||
caloriesPer100g: string,
|
caloriesPer100g: string,
|
||||||
calories: string,
|
calories: string,
|
||||||
note: string,
|
note: string,
|
||||||
|
/** Živiny se v dialogu neukazují, jen se přenesou z původního jídla nebo z vybraného návrhu. */
|
||||||
|
nutrientsPer100g?: Nutrients,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** Pole části, která se v dialogu píšou jako text. */
|
||||||
|
type SplitTextKey = Exclude<keyof SplitForm, 'nutrientsPer100g'>;
|
||||||
|
|
||||||
|
/** Návrh, ze kterého se část vyplní — hodnoty už převedené do podoby formuláře. */
|
||||||
|
type SplitSuggestion = Partial<SplitForm> & { name: string };
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Rozdělí název podle prvního "+" — Luncher tak píše jídlo s přílohou
|
* Rozdělí název podle prvního "+" — Luncher tak píše jídlo s přílohou
|
||||||
* ("Chicken tikka massala+garlic nun"). Bez "+" zůstane celý název vlevo.
|
* ("Chicken tikka massala+garlic nun"). Bez "+" zůstane celý název vlevo.
|
||||||
@@ -53,6 +62,7 @@ const createOriginalForm = (source: MealEntry, name: string): SplitForm => ({
|
|||||||
caloriesPer100g: source.caloriesPer100g != null ? String(source.caloriesPer100g) : '',
|
caloriesPer100g: source.caloriesPer100g != null ? String(source.caloriesPer100g) : '',
|
||||||
calories: source.calories != null ? String(source.calories) : '',
|
calories: source.calories != null ? String(source.calories) : '',
|
||||||
note: source.note ?? '',
|
note: source.note ?? '',
|
||||||
|
nutrientsPer100g: source.nutrientsPer100g,
|
||||||
});
|
});
|
||||||
|
|
||||||
/** Nová část — ze stejného chodu a zdroje, ostatní je na uživateli. */
|
/** Nová část — ze stejného chodu a zdroje, ostatní je na uživateli. */
|
||||||
@@ -97,23 +107,48 @@ const createMealInput = (form: SplitForm, date: string): MealInput => {
|
|||||||
// Server si kalorie dopočte sám, ručně poslané použije jen jako zálohu
|
// Server si kalorie dopočte sám, ručně poslané použije jen jako zálohu
|
||||||
calories: values.derived.calories != null ? undefined : values.manualCalories,
|
calories: values.derived.calories != null ? undefined : values.manualCalories,
|
||||||
note: form.note.trim() || undefined,
|
note: form.note.trim() || undefined,
|
||||||
|
nutrientsPer100g: form.nutrientsPer100g,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Doplní do nové části hodnoty z návrhu, ale jen do prázdných polí. Cena se
|
* Vyplní část hodnotami z návrhu. Nová část je prázdná, takže ji návrh přepíše
|
||||||
* nepřebírá — za jídlo se platilo jednou a cena z jiného dne by ji zdvojila.
|
* celou a uživatel může mezi návrhy přeskakovat. U původního jídla jen doplní
|
||||||
|
* prázdná pole — co přišlo z importu, je skutečné. Cena se z návrhu nepřebírá
|
||||||
|
* nikdy, za jídlo se platilo jednou a cena z jiného dne by ji zdvojila.
|
||||||
*/
|
*/
|
||||||
const fillEmpty = (current: SplitForm, values: Partial<SplitForm>): SplitForm => {
|
const mergeSuggestion = (current: SplitForm, values: SplitSuggestion, onlyEmpty: boolean): SplitForm => {
|
||||||
const result = { ...current };
|
if (!onlyEmpty) return { ...current, ...values };
|
||||||
for (const [key, value] of Object.entries(values) as [keyof SplitForm, string][]) {
|
const result = { ...current, name: values.name };
|
||||||
if (key === 'name' || !String(current[key]).trim().length) {
|
for (const [key, value] of Object.entries(values) as [keyof SplitForm, SplitForm[keyof SplitForm]][]) {
|
||||||
(result as Record<string, string>)[key] = value;
|
if (key === 'nutrientsPer100g') {
|
||||||
|
result.nutrientsPer100g = current.nutrientsPer100g ?? (value as Nutrients | undefined);
|
||||||
|
} else if (!String(current[key] ?? '').trim().length) {
|
||||||
|
(result as Record<string, unknown>)[key] = value;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return result;
|
return result;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const fromHistory = (suggestion: MealSuggestion): SplitSuggestion => ({
|
||||||
|
name: suggestion.name,
|
||||||
|
source: suggestion.source ?? '',
|
||||||
|
pricePer100g: formatPriceInput(suggestion.pricePer100g),
|
||||||
|
weight: suggestion.weight != null ? String(suggestion.weight) : '',
|
||||||
|
caloriesPer100g: suggestion.caloriesPer100g != null ? String(suggestion.caloriesPer100g) : '',
|
||||||
|
calories: suggestion.calories != null ? String(suggestion.calories) : '',
|
||||||
|
nutrientsPer100g: suggestion.nutrientsPer100g,
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Recept nemá živiny na 100 g ani zdroj, převezme se jen jedna porce. */
|
||||||
|
const fromRecipe = (recipe: MealTemplate): SplitSuggestion => ({
|
||||||
|
name: recipe.name,
|
||||||
|
weight: recipe.weight != null ? String(recipe.weight) : '',
|
||||||
|
caloriesPer100g: recipe.caloriesPer100g != null ? String(recipe.caloriesPer100g) : '',
|
||||||
|
calories: recipe.calories != null ? String(recipe.calories) : '',
|
||||||
|
nutrientsPer100g: undefined,
|
||||||
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Rozdělení jídla, které je zapsané jako jedno, na dvě — typicky import
|
* Rozdělení jídla, které je zapsané jako jedno, na dvě — typicky import
|
||||||
* z Luncheru, kde je jídlo s přílohou v jednom řádku. Vlevo jsou hodnoty
|
* z Luncheru, kde je jídlo s přílohou v jednom řádku. Vlevo jsou hodnoty
|
||||||
@@ -127,7 +162,9 @@ const MealSplitModal = ({ isOpen, entry, onClose, onSaved }: Readonly<Props>) =>
|
|||||||
const [split, setSplit] = useState<SplitForm | undefined>();
|
const [split, setSplit] = useState<SplitForm | undefined>();
|
||||||
const [recipes, setRecipes] = useState<MealTemplate[]>([]);
|
const [recipes, setRecipes] = useState<MealTemplate[]>([]);
|
||||||
const [history, setHistory] = useState<MealSuggestion[]>([]);
|
const [history, setHistory] = useState<MealSuggestion[]>([]);
|
||||||
const [pickedSuggestion, setPickedSuggestion] = useState<string | undefined>();
|
/** Názvy návrhů, ze kterých se sloupce naposledy vyplnily — pak už se návrhy nenabízejí. */
|
||||||
|
const [pickedOriginal, setPickedOriginal] = useState<string | undefined>();
|
||||||
|
const [pickedSplit, setPickedSplit] = useState<string | undefined>();
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -135,7 +172,8 @@ const MealSplitModal = ({ isOpen, entry, onClose, onSaved }: Readonly<Props>) =>
|
|||||||
const [originalName, splitPartName] = splitName(entry.name);
|
const [originalName, splitPartName] = splitName(entry.name);
|
||||||
setOriginal(createOriginalForm(entry, originalName));
|
setOriginal(createOriginalForm(entry, originalName));
|
||||||
setSplit(createSplitForm(entry, splitPartName));
|
setSplit(createSplitForm(entry, splitPartName));
|
||||||
setPickedSuggestion(undefined);
|
setPickedOriginal(undefined);
|
||||||
|
setPickedSplit(undefined);
|
||||||
getMealTemplates().then(response => setRecipes(response.data ?? []));
|
getMealTemplates().then(response => setRecipes(response.data ?? []));
|
||||||
getMealHistory().then(response => setHistory(response.data ?? []));
|
getMealHistory().then(response => setHistory(response.data ?? []));
|
||||||
}, [isOpen, entry]);
|
}, [isOpen, entry]);
|
||||||
@@ -144,7 +182,7 @@ const MealSplitModal = ({ isOpen, entry, onClose, onSaved }: Readonly<Props>) =>
|
|||||||
|
|
||||||
const updateOriginal = (changes: Partial<SplitForm>) => setOriginal(current => current && { ...current, ...changes });
|
const updateOriginal = (changes: Partial<SplitForm>) => setOriginal(current => current && { ...current, ...changes });
|
||||||
const updateSplit = (changes: Partial<SplitForm>) => setSplit(current => current && { ...current, ...changes });
|
const updateSplit = (changes: Partial<SplitForm>) => setSplit(current => current && { ...current, ...changes });
|
||||||
const copy = (key: keyof SplitForm) => updateSplit({ [key]: original[key] });
|
const copy = (key: SplitTextKey) => updateSplit({ [key]: original[key] });
|
||||||
|
|
||||||
/** Rozdělí současný součet ceny obou částí napůl; lichý haléř zůstane u původního jídla. */
|
/** Rozdělí současný součet ceny obou částí napůl; lichý haléř zůstane u původního jídla. */
|
||||||
const halvePrice = () => {
|
const halvePrice = () => {
|
||||||
@@ -154,31 +192,24 @@ const MealSplitModal = ({ isOpen, entry, onClose, onSaved }: Readonly<Props>) =>
|
|||||||
updateSplit({ price: formatPriceInput(half) });
|
updateSplit({ price: formatPriceInput(half) });
|
||||||
};
|
};
|
||||||
|
|
||||||
const applyHistory = (suggestion: MealSuggestion) => {
|
const applyToOriginal = (values: SplitSuggestion) => {
|
||||||
setPickedSuggestion(suggestion.name);
|
setPickedOriginal(values.name);
|
||||||
setSplit(current => current && fillEmpty(current, {
|
setOriginal(current => current && mergeSuggestion(current, values, true));
|
||||||
name: suggestion.name,
|
|
||||||
source: suggestion.source ?? '',
|
|
||||||
weight: suggestion.weight != null ? String(suggestion.weight) : '',
|
|
||||||
caloriesPer100g: suggestion.caloriesPer100g != null ? String(suggestion.caloriesPer100g) : '',
|
|
||||||
calories: suggestion.calories != null ? String(suggestion.calories) : '',
|
|
||||||
}));
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const applyRecipe = (recipe: MealTemplate) => {
|
const applyToSplit = (values: SplitSuggestion) => {
|
||||||
setPickedSuggestion(recipe.name);
|
setPickedSplit(values.name);
|
||||||
setSplit(current => current && fillEmpty(current, {
|
setSplit(current => current && mergeSuggestion(current, values, false));
|
||||||
name: recipe.name,
|
|
||||||
weight: recipe.weight != null ? String(recipe.weight) : '',
|
|
||||||
caloriesPer100g: recipe.caloriesPer100g != null ? String(recipe.caloriesPer100g) : '',
|
|
||||||
calories: recipe.calories != null ? String(recipe.calories) : '',
|
|
||||||
}));
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Rozdělované jídlo samo sebe nenabízí
|
||||||
|
const otherHistory = history.filter(item => getMealKey(item) !== getMealKey(entry));
|
||||||
|
|
||||||
const originalValues = deriveForm(original);
|
const originalValues = deriveForm(original);
|
||||||
const splitValues = deriveForm(split);
|
const splitValues = deriveForm(split);
|
||||||
const totalPrice = (originalValues.price ?? 0) + (splitValues.price ?? 0);
|
const totalPrice = (originalValues.price ?? 0) + (splitValues.price ?? 0);
|
||||||
const priceChanged = totalPrice !== (entry.price ?? 0);
|
const priceChanged = totalPrice !== (entry.price ?? 0);
|
||||||
|
const nutrientsStay = !!original.nutrientsPer100g && !split.nutrientsPer100g;
|
||||||
const canSave = !!original.name.trim().length && !!split.name.trim().length && !saving;
|
const canSave = !!original.name.trim().length && !!split.name.trim().length && !saving;
|
||||||
|
|
||||||
const save = async () => {
|
const save = async () => {
|
||||||
@@ -190,8 +221,7 @@ const MealSplitModal = ({ isOpen, entry, onClose, onSaved }: Readonly<Props>) =>
|
|||||||
date: entry.date,
|
date: entry.date,
|
||||||
original: {
|
original: {
|
||||||
...createMealInput(original, entry.date),
|
...createMealInput(original, entry.date),
|
||||||
// Živiny a násobek se v dialogu nemění a zůstávají původnímu jídlu
|
// Násobek se v dialogu nemění a zůstává původnímu jídlu
|
||||||
nutrientsPer100g: entry.nutrientsPer100g,
|
|
||||||
multiplier: entry.multiplier,
|
multiplier: entry.multiplier,
|
||||||
},
|
},
|
||||||
split: createMealInput(split, entry.date),
|
split: createMealInput(split, entry.date),
|
||||||
@@ -203,7 +233,7 @@ const MealSplitModal = ({ isOpen, entry, onClose, onSaved }: Readonly<Props>) =>
|
|||||||
|
|
||||||
/** Textové pole části; u čísel bez desetinné čárky propustí jen číslice. */
|
/** Textové pole části; u čísel bez desetinné čárky propustí jen číslice. */
|
||||||
const textInput = (
|
const textInput = (
|
||||||
form: SplitForm, update: (changes: Partial<SplitForm>) => void, key: keyof SplitForm,
|
form: SplitForm, update: (changes: Partial<SplitForm>) => void, key: SplitTextKey,
|
||||||
options: { placeholder?: string, unit?: string, numeric?: boolean } = {},
|
options: { placeholder?: string, unit?: string, numeric?: boolean } = {},
|
||||||
) => {
|
) => {
|
||||||
const control = (
|
const control = (
|
||||||
@@ -235,6 +265,36 @@ const MealSplitModal = ({ isOpen, entry, onClose, onSaved }: Readonly<Props>) =>
|
|||||||
</Form.Select>
|
</Form.Select>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/** Název části s návrhy z receptů a historie; vybraný návrh vyplní i energii a živiny. */
|
||||||
|
const nameInput = (
|
||||||
|
form: SplitForm, picked: string | undefined, apply: (values: SplitSuggestion) => void,
|
||||||
|
onChange: (name: string) => void, options: { autoFocus?: boolean, placeholder?: string } = {},
|
||||||
|
) => (
|
||||||
|
<>
|
||||||
|
<Form.Control
|
||||||
|
autoFocus={options.autoFocus}
|
||||||
|
value={form.name}
|
||||||
|
placeholder={options.placeholder}
|
||||||
|
onChange={event => onChange(event.target.value)}
|
||||||
|
/>
|
||||||
|
{picked !== form.name && (
|
||||||
|
<MealSuggestions
|
||||||
|
recipes={recipes}
|
||||||
|
history={otherHistory}
|
||||||
|
query={form.name}
|
||||||
|
onPickRecipe={recipe => apply(fromRecipe(recipe))}
|
||||||
|
onPickHistory={suggestion => apply(fromHistory(suggestion))}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{picked === form.name && (
|
||||||
|
<Form.Text>
|
||||||
|
{form === original ? 'doplněna prázdná pole z dřívějška, cena ne' : 'vyplněno z dřívějška, cena ne'}
|
||||||
|
{form.nutrientsPer100g && ', živiny převzaty'}
|
||||||
|
</Form.Text>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
/** Kalorie porce — dopočtené z gramáže se jen ukážou, jinak jdou zadat ručně. */
|
/** Kalorie porce — dopočtené z gramáže se jen ukážou, jinak jdou zadat ručně. */
|
||||||
const caloriesCell = (
|
const caloriesCell = (
|
||||||
form: SplitForm, update: (changes: Partial<SplitForm>) => void, values: ReturnType<typeof deriveForm>,
|
form: SplitForm, update: (changes: Partial<SplitForm>) => void, values: ReturnType<typeof deriveForm>,
|
||||||
@@ -260,30 +320,14 @@ const MealSplitModal = ({ isOpen, entry, onClose, onSaved }: Readonly<Props>) =>
|
|||||||
|
|
||||||
<MealSplitRow
|
<MealSplitRow
|
||||||
label="Jídlo"
|
label="Jídlo"
|
||||||
original={textInput(original, updateOriginal, 'name')}
|
original={nameInput(original, pickedOriginal, applyToOriginal, name => {
|
||||||
split={
|
updateOriginal({ name });
|
||||||
<>
|
setPickedOriginal(undefined);
|
||||||
<Form.Control
|
})}
|
||||||
autoFocus
|
split={nameInput(split, pickedSplit, applyToSplit, name => {
|
||||||
value={split.name}
|
updateSplit({ name });
|
||||||
placeholder="např. Garlic naan"
|
setPickedSplit(undefined);
|
||||||
onChange={event => { updateSplit({ name: event.target.value }); setPickedSuggestion(undefined); }}
|
}, { autoFocus: true, placeholder: 'např. Garlic naan' })}
|
||||||
/>
|
|
||||||
{pickedSuggestion !== split.name && (
|
|
||||||
<MealSuggestions
|
|
||||||
recipes={recipes}
|
|
||||||
// Rozdělované jídlo samo sebe nenabízí
|
|
||||||
history={history.filter(item => getMealKey(item) !== getMealKey(entry))}
|
|
||||||
query={split.name}
|
|
||||||
onPickRecipe={recipe => applyRecipe(recipe)}
|
|
||||||
onPickHistory={suggestion => applyHistory(suggestion)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{pickedSuggestion === split.name && (
|
|
||||||
<Form.Text>doplněna prázdná pole z dřívějška, cena ne</Form.Text>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
onCopy={() => copy('name')}
|
onCopy={() => copy('name')}
|
||||||
/>
|
/>
|
||||||
<MealSplitRow
|
<MealSplitRow
|
||||||
@@ -351,11 +395,11 @@ const MealSplitModal = ({ isOpen, entry, onClose, onSaved }: Readonly<Props>) =>
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</Form>
|
</Form>
|
||||||
{(entry.nutrientsPer100g || entry.multiplier != null) && (
|
{(nutrientsStay || entry.multiplier != null) && (
|
||||||
<p className="usage-note">
|
<p className="usage-note">
|
||||||
{entry.multiplier != null && `Násobek ${formatMultiplier(entry.multiplier)}`}
|
{entry.multiplier != null && `Násobek ${formatMultiplier(entry.multiplier)}`}
|
||||||
{entry.multiplier != null && entry.nutrientsPer100g && ' a '}
|
{entry.multiplier != null && nutrientsStay && ' a '}
|
||||||
{entry.nutrientsPer100g && (entry.multiplier != null ? 'živiny' : 'Živiny')}
|
{nutrientsStay && (entry.multiplier != null ? 'živiny' : 'Živiny')}
|
||||||
{' '}zůstávají u původního jídla, novému je doplníte v úpravě.
|
{' '}zůstávají u původního jídla, novému je doplníte v úpravě.
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
|||||||
import { faTrash, faPlus } from "@fortawesome/free-solid-svg-icons";
|
import { faTrash, faPlus } from "@fortawesome/free-solid-svg-icons";
|
||||||
import { UserSettings, getSettings, saveSourceRate } from "../../../../types";
|
import { UserSettings, getSettings, saveSourceRate } from "../../../../types";
|
||||||
import { formatPriceInput, parsePriceInput } from "../../Utils";
|
import { formatPriceInput, parsePriceInput } from "../../Utils";
|
||||||
|
import { STYLE_THEME_NAMES, StyleTheme } from "../../enums";
|
||||||
|
import { useSettings } from "../../context/settings";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
isOpen: boolean,
|
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
|
* 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.
|
* 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<Props>) {
|
|||||||
const [newSource, setNewSource] = useState('');
|
const [newSource, setNewSource] = useState('');
|
||||||
const [newRate, setNewRate] = useState('');
|
const [newRate, setNewRate] = useState('');
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
|
const appSettings = useSettings();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isOpen) return;
|
if (!isOpen) return;
|
||||||
@@ -52,6 +55,19 @@ export default function SettingsModal({ isOpen, onClose }: Readonly<Props>) {
|
|||||||
<Modal.Title>Nastavení</Modal.Title>
|
<Modal.Title>Nastavení</Modal.Title>
|
||||||
</Modal.Header>
|
</Modal.Header>
|
||||||
<Modal.Body>
|
<Modal.Body>
|
||||||
|
<h3 className="settings-section-title">Vzhled</h3>
|
||||||
|
<Form.Group className="mb-4" controlId="settings-style-theme">
|
||||||
|
<Form.Label>Styl</Form.Label>
|
||||||
|
<Form.Select
|
||||||
|
value={appSettings?.styleTheme}
|
||||||
|
onChange={event => appSettings?.setStyleTheme(event.target.value as StyleTheme)}
|
||||||
|
>
|
||||||
|
{Object.values(StyleTheme).map(value => (
|
||||||
|
<option key={value} value={value}>{STYLE_THEME_NAMES[value]}</option>
|
||||||
|
))}
|
||||||
|
</Form.Select>
|
||||||
|
</Form.Group>
|
||||||
|
|
||||||
<h3 className="settings-section-title">Cena za 100 g</h3>
|
<h3 className="settings-section-title">Cena za 100 g</h3>
|
||||||
<p className="text-body-secondary small">
|
<p className="text-body-secondary small">
|
||||||
U podniků, které účtují podle váhy, se z ceny jídla a této sazby dopočítá
|
U podniků, které účtují podle váhy, se z ceny jídla a této sazby dopočítá
|
||||||
|
|||||||
@@ -1,15 +1,19 @@
|
|||||||
import React, { ReactNode, useCallback, useContext, useEffect, useMemo, useState } from "react";
|
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. */
|
/** Volba motivu — buď pevná, nebo řízená nastavením operačního systému. */
|
||||||
export type ThemePreference = 'light' | 'dark' | 'system';
|
export type ThemePreference = 'light' | 'dark' | 'system';
|
||||||
|
|
||||||
const THEME_KEY = 'themePreference';
|
const THEME_KEY = 'themePreference';
|
||||||
|
const STYLE_THEME_KEY = 'styleTheme';
|
||||||
|
|
||||||
export type SettingsContextProps = {
|
export type SettingsContextProps = {
|
||||||
themePreference: ThemePreference,
|
themePreference: ThemePreference,
|
||||||
/** Skutečně použitý motiv po vyhodnocení volby 'system'. */
|
/** Skutečně použitý motiv po vyhodnocení volby 'system'. */
|
||||||
effectiveDark: boolean,
|
effectiveDark: boolean,
|
||||||
setThemePreference: (preference: ThemePreference) => void,
|
setThemePreference: (preference: ThemePreference) => void,
|
||||||
|
styleTheme: StyleTheme,
|
||||||
|
setStyleTheme: (styleTheme: StyleTheme) => void,
|
||||||
};
|
};
|
||||||
|
|
||||||
const settingsContext = React.createContext<SettingsContextProps | null>(null);
|
const settingsContext = React.createContext<SettingsContextProps | null>(null);
|
||||||
@@ -22,8 +26,15 @@ function getStoredPreference(): ThemePreference {
|
|||||||
return stored === 'light' || stored === 'dark' ? stored : 'system';
|
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 }>) {
|
export function ProvideSettings(props: Readonly<{ children: ReactNode }>) {
|
||||||
const [themePreference, setStoredPreference] = useState<ThemePreference>(getStoredPreference);
|
const [themePreference, setStoredPreference] = useState<ThemePreference>(getStoredPreference);
|
||||||
|
const [styleTheme, setStyleThemeState] = useState<StyleTheme>(getStoredStyleTheme);
|
||||||
const [systemDark, setSystemDark] = useState(() =>
|
const [systemDark, setSystemDark] = useState(() =>
|
||||||
globalThis.matchMedia?.('(prefers-color-scheme: dark)').matches ?? false);
|
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');
|
document.documentElement.setAttribute('data-bs-theme', effectiveDark ? 'dark' : 'light');
|
||||||
}, [effectiveDark]);
|
}, [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) => {
|
const setThemePreference = useCallback((preference: ThemePreference) => {
|
||||||
setStoredPreference(preference);
|
setStoredPreference(preference);
|
||||||
if (preference === 'system') {
|
if (preference === 'system') {
|
||||||
@@ -53,8 +77,8 @@ export function ProvideSettings(props: Readonly<{ children: ReactNode }>) {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const value = useMemo(
|
const value = useMemo(
|
||||||
() => ({ themePreference, effectiveDark, setThemePreference }),
|
() => ({ themePreference, effectiveDark, setThemePreference, styleTheme, setStyleTheme }),
|
||||||
[themePreference, effectiveDark, setThemePreference]);
|
[themePreference, effectiveDark, setThemePreference, styleTheme, setStyleTheme]);
|
||||||
|
|
||||||
return <settingsContext.Provider value={value}>{props.children}</settingsContext.Provider>;
|
return <settingsContext.Provider value={value}>{props.children}</settingsContext.Provider>;
|
||||||
}
|
}
|
||||||
@@ -192,3 +192,17 @@ export enum SourceFilter {
|
|||||||
VSE = 'VSE',
|
VSE = 'VSE',
|
||||||
BEZ_ZDROJE = 'BEZ_ZDROJE',
|
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 <html>.
|
||||||
|
*/
|
||||||
|
export enum StyleTheme {
|
||||||
|
JEDNODUCHY = 'JEDNODUCHY',
|
||||||
|
HELL_GIRL = 'HELL_GIRL',
|
||||||
|
}
|
||||||
|
|
||||||
|
export const STYLE_THEME_NAMES: Record<StyleTheme, string> = {
|
||||||
|
JEDNODUCHY: 'Jednoduchý',
|
||||||
|
HELL_GIRL: 'Hell Girl',
|
||||||
|
};
|
||||||
@@ -11,6 +11,7 @@ import 'bootstrap/dist/css/bootstrap.min.css';
|
|||||||
import 'react-toastify/dist/ReactToastify.css';
|
import 'react-toastify/dist/ReactToastify.css';
|
||||||
import './index.css';
|
import './index.css';
|
||||||
import './App.scss';
|
import './App.scss';
|
||||||
|
import './themes/hellgirl.scss';
|
||||||
|
|
||||||
client.setConfig({
|
client.setConfig({
|
||||||
auth: () => getToken(),
|
auth: () => getToken(),
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 619 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 3.3 KiB |
@@ -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á <html> 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user