feat: export + detail receptů

feat: losování jídel
This commit is contained in:
Ondřej Anděl committed 2026-10-16 12:55:32 +02:00
1 parent 35cbc5a9ca
commit caffb6d8b4
23 files changed
+2674 -43

No files matched your search

@@ -0,0 +1,361 @@
// Kolo štěstí převzaté z Luncheru. Barvy výsečí jednoduchý styl odvozuje od
// primární barvy, styl Hell Girl je přebíjí vlastní paletou (viz níže)
:root {
--wheel-c0: var(--ft-primary);
--wheel-c1: #0ea5e9;
--wheel-c2: #f59e0b;
--wheel-c3: #8b5cf6;
--wheel-c4: #ec4899;
--wheel-c5: #14b8a6;
--wheel-label: #ffffff;
--wheel-rim: var(--ft-text);
--wheel-hub: var(--ft-bg-card);
}
.spin-wheel-modal {
.spin-filters {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px 12px;
margin-bottom: 16px;
}
.spin-category {
width: auto;
min-width: 180px;
}
// Přepínače jako segmentovaná „pilulka"
.spin-source {
display: inline-flex;
gap: 2px;
padding: 3px;
border-radius: 999px;
background: var(--ft-bg-hover);
> .btn {
padding: 6px 16px;
font-size: 0.9rem;
border: none !important;
border-radius: 999px !important;
box-shadow: none !important;
margin: 0 !important;
}
> .btn-outline-primary {
background: transparent;
color: var(--ft-text-secondary);
&:hover {
background: var(--ft-bg-card);
color: var(--ft-primary);
}
}
}
.spin-layout {
display: grid;
grid-template-columns: minmax(0, 1fr) 240px;
gap: 20px;
align-items: start;
@media (max-width: 768px) {
grid-template-columns: minmax(0, 1fr);
}
}
.spin-wheel-column {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
min-width: 0;
}
.spin-empty {
width: 100%;
max-width: 380px;
aspect-ratio: 1;
display: flex;
align-items: center;
justify-content: center;
text-align: center;
padding: 24px;
border: 2px dashed var(--ft-border);
border-radius: 50%;
color: var(--ft-text-muted);
}
.spin-result {
min-height: 1.6em;
font-size: 1.15rem;
text-align: center;
opacity: 0;
transform: translateY(6px);
transition: opacity 0.3s ease, transform 0.3s ease;
&.visible {
opacity: 1;
transform: none;
}
strong {
color: var(--ft-primary);
}
small {
color: var(--ft-text-muted);
}
}
.spin-actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
justify-content: center;
}
.spin-button {
min-width: 160px;
}
.spin-options {
border: 1px solid var(--ft-border);
border-radius: var(--ft-radius);
background: var(--ft-bg);
max-height: 440px;
display: flex;
flex-direction: column;
min-width: 0;
ul {
list-style: none;
margin: 0;
padding: 4px;
overflow-y: auto;
}
li {
display: flex;
align-items: center;
gap: 8px;
padding: 4px 6px;
border-radius: var(--ft-radius-sm);
font-size: 0.85rem;
&:hover {
background: var(--ft-bg-hover);
}
}
}
.spin-options-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 10px;
border-bottom: 1px solid var(--ft-border);
font-weight: 700;
font-size: 0.85rem;
}
.spin-restore {
background: none;
border: none;
color: var(--ft-primary);
font-size: 0.8rem;
font-weight: 600;
padding: 0;
}
.spin-dot {
flex: none;
width: 10px;
height: 10px;
border-radius: 50%;
}
.spin-option-label {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.spin-option-kind {
flex: none;
max-width: 80px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--ft-text-muted);
font-size: 0.7rem;
}
.spin-remove {
flex: none;
background: none;
border: none;
color: var(--ft-text-muted);
padding: 2px 6px;
border-radius: 4px;
&:hover:not(:disabled) {
color: var(--ft-danger);
background: var(--ft-bg-hover);
}
}
// Recept, který padl — fotka nad kartou receptu
.wheel-recipe {
.template-card {
margin-bottom: 0;
}
}
.wheel-recipe-photo {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
max-height: 280px;
min-height: 60px;
margin-bottom: 12px;
object-fit: cover;
border-radius: var(--ft-radius);
}
}
.spin-wheel {
position: relative;
width: 100%;
max-width: 380px;
aspect-ratio: 1;
}
.spin-wheel-svg {
width: 100%;
height: 100%;
display: block;
filter: drop-shadow(0 6px 12px rgb(0 0 0 / 0.18));
}
.spin-wheel-pointer {
position: absolute;
top: -6px;
left: 50%;
transform: translateX(-50%);
z-index: 2;
width: 0;
height: 0;
border-left: 14px solid transparent;
border-right: 14px solid transparent;
border-top: 26px solid var(--ft-danger);
filter: drop-shadow(0 2px 2px rgb(0 0 0 / 0.3));
}
.spin-wheel-rim {
fill: var(--wheel-rim);
}
.spin-wheel-hub {
fill: var(--wheel-hub);
stroke: var(--wheel-rim);
stroke-width: 4;
}
.spin-seg {
stroke: var(--wheel-hub);
stroke-width: 1.5;
}
.spin-seg-label {
fill: var(--wheel-label);
font-weight: 700;
pointer-events: none;
paint-order: stroke;
stroke: rgb(0 0 0 / 0.25);
stroke-width: 2px;
}
@for $i from 0 through 5 {
.spin-seg-#{$i} {
fill: var(--wheel-c#{$i});
background: var(--wheel-c#{$i});
}
}
@media (prefers-reduced-motion: reduce) {
.spin-wheel-svg {
transition-duration: 0.6s !important;
}
}
// ============================================
// STYL HELL GIRL — výseče v barvách soumraku a pekla, rudá nit jako lem.
// Dialog, tlačítka a písmo už řeší themes/hellgirl.scss, tady je jen kolo.
// ============================================
html[data-ft-style="HELL_GIRL"] {
--wheel-c0: #a3122a;
--wheel-c1: #1f1416;
--wheel-c2: #c8452a;
--wheel-c3: #4b1a4f;
--wheel-c4: #6e0b1a;
--wheel-c5: #9a6b1f;
--wheel-rim: #c8102e;
--wheel-hub: #12070a;
&[data-bs-theme="dark"] {
--wheel-c1: #2e1a1e;
}
.spin-wheel-modal {
.spin-source {
border-radius: 2px;
}
.spin-source > .btn {
border-radius: 2px !important;
}
// Tlačítko roztočení — černá deska s rudou září
.spin-button {
background: linear-gradient(180deg, #2a1216, #12070a);
border: 1px solid #c8102e;
color: #fff4ee;
box-shadow: 0 0 0 3px rgb(200 16 46 / 0.25), 0 0 18px rgb(200 16 46 / 0.45);
letter-spacing: 1px;
font-size: 1.15rem;
padding: 12px 36px;
&:hover:not(:disabled) {
background: linear-gradient(180deg, #3a1219, #1c0a10);
box-shadow: 0 0 0 3px rgb(200 16 46 / 0.4), 0 0 24px rgb(200 16 46 / 0.65);
}
}
.spin-options {
border-top: 3px solid var(--ft-primary);
}
.spin-wheel-pointer {
border-top-color: #c8102e;
filter: drop-shadow(0 0 4px rgb(200 16 46 / 0.7));
}
.spin-wheel-rim {
filter: drop-shadow(0 0 6px rgb(200 16 46 / 0.6));
}
.spin-seg {
stroke: #12070a;
stroke-width: 2;
}
.spin-seg-label {
font-family: var(--hg-font);
}
}
}
@@ -0,0 +1,388 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { Button, ButtonGroup, Form, Modal, Spinner } from "react-bootstrap";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faBookOpen, faRotateLeft, faXmark } from "@fortawesome/free-solid-svg-icons";
import { MealGroup, MealTemplate, RecipeFlavor, getMealGroups } from "../../../../types";
import { RECIPE_FLAVOR_NAMES, StyleTheme, WHEEL_POOL_NAMES, WheelPool } from "../../enums";
import { useSettings } from "../../context/settings";
import { getMealKey, normalizeSearch } from "../../Utils";
import SpinWheel, { SEGMENT_COLORS } from "../SpinWheel";
import WheelConfetti from "../WheelConfetti";
import WheelRecipe from "../WheelRecipe";
import "./SpinWheelModal.scss";
type Props = {
isOpen: boolean,
onClose: () => void,
};
/** Točit má smysl až od dvou možností — jediná zbylá se vybere rovnou. */
const MIN_SPIN_ITEMS = 2;
type RecipeGroup = MealGroup & { recipe: MealTemplate };
/** Výseč kola — jídlo, nebo u všech jídel nejdřív zdroj, ze kterého se pak losuje. */
type WheelItem = {
key: string,
label: string,
note?: string,
group?: MealGroup,
};
/** Klíče zdrojů se nesmí potkat s klíči jídel — vyřazené možnosti obou kol leží v jedné množině. */
const SOURCE_KEY_PREFIX = 'zdroj:';
const getSourceKey = (group: MealGroup): string => `${SOURCE_KEY_PREFIX}${normalizeSearch(group.source ?? '')}`;
/** Zdroje jídel pro první kolo, každý jednou, seřazené podle abecedy; jídla bez zdroje jako „Bez zdroje". */
const listSources = (groupList: MealGroup[]): WheelItem[] => {
const sourceMap = new Map<string, { label: string, count: number }>();
for (const group of groupList) {
const key = getSourceKey(group);
const current = sourceMap.get(key);
if (current) current.count++;
else sourceMap.set(key, { label: group.source?.trim() || 'Bez zdroje', count: 1 });
}
return [...sourceMap.entries()]
.map(([key, { label, count }]) => ({ key, label, note: `${count}×` }))
.sort((a, b) => a.label.localeCompare(b.label, 'cs'));
};
const createMealItem = (group: MealGroup, withSource: boolean): WheelItem =>
({ key: getMealKey(group), label: group.name, note: withSource ? group.source : undefined, group });
const getResultCaption = (sourceStage: boolean, onlyOne: boolean): string => {
if (sourceStage) return onlyOne ? 'Zbývá jediný zdroj' : 'Padl zdroj';
return onlyOne ? 'Zbývá jediná možnost' : 'Padlo';
};
const hasRecipe = (group: MealGroup): group is RecipeGroup => !!group.recipe;
/** Kryptograficky náhodný index v rozsahu 0..n-1 (modulo zkreslení u malých n nevadí). */
const getRandomIndex = (count: number): number => {
const buffer = new Uint32Array(1);
crypto.getRandomValues(buffer);
return buffer[0] % count;
};
/** Typy receptů pro výběr, každý jednou (porovnává se bez diakritiky), seřazené podle abecedy. */
const listCategories = (groupList: RecipeGroup[]): { value: string, label: string }[] => {
const categoryMap = new Map<string, string>();
for (const group of groupList) {
const category = group.recipe.category?.trim();
if (category && !categoryMap.has(normalizeSearch(category))) categoryMap.set(normalizeSearch(category), category);
}
return [...categoryMap.entries()]
.map(([value, label]) => ({ value, label }))
.sort((a, b) => a.label.localeCompare(b.label, 'cs'));
};
/**
* Kolo štěstí pro nerozhodnuté, převzaté z Luncheru. Losuje buď ze všech jídel
* (i objednávaných — pak stačí, že kolo rozhodne), nebo jen z receptů, volitelně
* zúžených chutí a typem; padlý recept jde rovnou otevřít a vařit podle něj.
* Všech jídel jsou stovky, takže se u nich nejdřív vybere (nebo vylosuje) zdroj
* a teprve z jeho jídel se točí. Možnosti lze z kola průběžně vyřazovat.
*/
const SpinWheelModal = ({ isOpen, onClose }: Readonly<Props>) => {
const [groupList, setGroupList] = useState<MealGroup[]>();
const [pool, setPool] = useState(WheelPool.VSE);
const [flavor, setFlavor] = useState<RecipeFlavor | undefined>();
const [category, setCategory] = useState('');
const [sourceKey, setSourceKey] = useState<string | undefined>();
const [excludedKeys, setExcludedKeys] = useState<Set<string>>(new Set());
const [picked, setPicked] = useState<WheelItem | undefined>();
const [recipeShown, setRecipeShown] = useState(false);
const [rotation, setRotation] = useState(0);
const [spinning, setSpinning] = useState(false);
const [respinRequested, setRespinRequested] = useState(false);
const [confettiKey, setConfettiKey] = useState(0);
const pendingIndex = useRef<number | undefined>(undefined);
const theme = useSettings()?.styleTheme ?? StyleTheme.JEDNODUCHY;
useEffect(() => {
if (isOpen && !groupList) {
getMealGroups().then(response => setGroupList(response.data ?? []));
}
}, [isOpen, groupList]);
const flavorRecipeList = useMemo(
() => (groupList ?? []).filter(hasRecipe).filter(group => !flavor || group.recipe.flavor === flavor),
[groupList, flavor],
);
const categoryList = useMemo(() => listCategories(flavorRecipeList), [flavorRecipeList]);
const sourceList = useMemo(() => listSources(groupList ?? []), [groupList]);
const sourceStage = pool === WheelPool.VSE && !sourceKey;
const candidateList = useMemo<WheelItem[]>(() => {
if (pool === WheelPool.VSE) {
if (!sourceKey) return sourceList;
return (groupList ?? [])
.filter(group => getSourceKey(group) === sourceKey)
.map(group => createMealItem(group, false));
}
return flavorRecipeList
.filter(group => !category || normalizeSearch(group.recipe.category ?? '') === category)
.map(group => createMealItem(group, true));
}, [pool, sourceKey, sourceList, groupList, flavorRecipeList, category]);
const wheelList = useMemo(
() => candidateList.filter(item => !excludedKeys.has(item.key)),
[candidateList, excludedKeys],
);
const excludedCount = candidateList.length - wheelList.length;
const onlyOne = wheelList.length === 1;
const clearResult = () => {
setPicked(undefined);
setRecipeShown(false);
};
const changePool = (value: WheelPool) => {
setPool(value);
clearResult();
};
const changeFlavor = (value?: RecipeFlavor) => {
setFlavor(value);
// Typ, který s novou chutí žádný recept nemá, by kolo vyprázdnil
const stillExists = !category || (groupList ?? []).filter(hasRecipe).some(group =>
(!value || group.recipe.flavor === value) && normalizeSearch(group.recipe.category ?? '') === category);
if (!stillExists) setCategory('');
clearResult();
};
const changeSource = (value?: string) => {
setSourceKey(value);
clearResult();
};
// Zbyla jediná možnost — není co losovat, vybere se rovnou
useEffect(() => {
if (spinning || !onlyOne) return;
if (picked?.key !== wheelList[0].key) setPicked(wheelList[0]);
}, [onlyOne, spinning, wheelList, picked]);
const spin = () => {
const count = wheelList.length;
if (count < MIN_SPIN_ITEMS || spinning) return;
const index = getRandomIndex(count);
const angle = 360 / count;
const jitter = (Math.random() - 0.5) * angle * 0.6;
const target = 360 - (index * angle + angle / 2) + jitter;
const turns = 5 + getRandomIndex(3);
pendingIndex.current = index;
clearResult();
setSpinning(true);
setRotation(current => current - (current % 360) + turns * 360 + ((target % 360) + 360) % 360);
};
const handleSpinEnd = () => {
const index = pendingIndex.current;
if (index == null) return;
pendingIndex.current = undefined;
setSpinning(false);
setPicked(wheelList[index]);
// Padlý zdroj je jen mezikrok, oslavuje se až jídlo
if (!sourceStage) setConfettiKey(key => key + 1);
};
const removeOption = (item: WheelItem) => {
if (spinning) return;
setExcludedKeys(current => new Set(current).add(item.key));
if (picked?.key === item.key) clearResult();
};
const removeResultAndRespin = () => {
if (!picked) return;
removeOption(picked);
setRespinRequested(true);
};
// Po vyřazení se kolo překreslí s menším počtem výsečí — roztočí se až nad novými daty
useEffect(() => {
if (respinRequested) {
setRespinRequested(false);
spin();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [respinRequested]);
const restoreAll = () => {
const candidateKeys = new Set(candidateList.map(item => item.key));
setExcludedKeys(current => new Set([...current].filter(key => !candidateKeys.has(key))));
};
const pickedRecipe = picked?.group && hasRecipe(picked.group) ? picked.group : undefined;
const pickedSource = sourceStage ? picked : undefined;
const renderWheel = () => {
if (!groupList) return <div className="spin-empty"><Spinner animation="border" variant="success" /></div>;
if (!wheelList.length) {
return (
<div className="spin-empty">
{candidateList.length
? 'Na kole nezbyla žádná možnost.'
: pool === WheelPool.RECEPTY ? 'Takový recept zatím nemáte.' : 'Zatím nemáte zapsané žádné jídlo.'}
</div>
);
}
return <SpinWheel labels={wheelList.map(item => item.label)} rotation={rotation} spinning={spinning} onSpinEnd={() => handleSpinEnd()} />;
};
return (
<Modal show={isOpen} onHide={onClose} size="lg" className="spin-wheel-modal">
<Modal.Header closeButton>
<Modal.Title>
{recipeShown && pickedRecipe ? pickedRecipe.name : 'Kolo štěstí: co dnes?'}
</Modal.Title>
</Modal.Header>
<WheelConfetti theme={theme} burstKey={confettiKey} />
<Modal.Body>
{recipeShown && pickedRecipe ? <WheelRecipe group={pickedRecipe} /> : (
<>
<div className="spin-filters">
<ButtonGroup className="spin-source" aria-label="Z čeho losovat">
{Object.values(WheelPool).map(value => (
<Button
key={value}
variant={pool === value ? 'primary' : 'outline-primary'}
disabled={spinning}
onClick={() => changePool(value)}
>
{WHEEL_POOL_NAMES[value]}
</Button>
))}
</ButtonGroup>
{pool === WheelPool.VSE && (
<Form.Select
className="spin-category"
value={sourceKey ?? ''}
disabled={spinning || !sourceList.length}
aria-label="Zdroj"
onChange={event => changeSource(event.target.value || undefined)}
>
<option value="">Vylosovat zdroj</option>
{sourceList.map(item => <option key={item.key} value={item.key}>{item.label}</option>)}
</Form.Select>
)}
{pool === WheelPool.RECEPTY && (
<>
<ButtonGroup className="spin-source" aria-label="Chuť">
{[undefined, ...Object.values(RecipeFlavor)].map(value => (
<Button
key={value ?? 'all'}
variant={flavor === value ? 'primary' : 'outline-primary'}
disabled={spinning}
onClick={() => changeFlavor(value)}
>
{value ? RECIPE_FLAVOR_NAMES[value] : 'Obojí'}
</Button>
))}
</ButtonGroup>
<Form.Select
className="spin-category"
value={category}
disabled={spinning || !categoryList.length}
aria-label="Typ receptu"
onChange={event => { setCategory(event.target.value); clearResult(); }}
>
<option value="">Jakýkoli typ</option>
{categoryList.map(item => <option key={item.value} value={item.value}>{item.label}</option>)}
</Form.Select>
</>
)}
</div>
<div className="spin-layout">
<div className="spin-wheel-column">
{renderWheel()}
<div className={`spin-result ${picked ? 'visible' : ''}`} aria-live="polite">
{picked && (
<>
{getResultCaption(sourceStage, onlyOne)}: <strong>{picked.label}</strong>
{picked.group?.source && <small> ({picked.group.source})</small>}
</>
)}
</div>
<div className="spin-actions">
{pickedSource && !spinning && (
<Button onClick={() => changeSource(pickedSource.key)}>
Losovat jídla: {pickedSource.label}
</Button>
)}
<Button
className="spin-button"
variant={pickedSource ? 'outline-primary' : 'primary'}
disabled={spinning || wheelList.length < MIN_SPIN_ITEMS}
title={wheelList.length < MIN_SPIN_ITEMS ? 'Na kole musí být aspoň 2 možnosti' : undefined}
onClick={() => spin()}
>
{spinning ? 'Točí se…' : picked && !onlyOne ? 'Točit znovu' : 'Roztočit!'}
</Button>
{picked && !spinning && wheelList.length > 1 && (
<Button variant="outline-secondary" onClick={() => removeResultAndRespin()}>
Vyřadit a točit znovu
</Button>
)}
</div>
{onlyOne && (
<small className="text-muted">
Na kole musí být aspoň 2 možnosti — vraťte nějaké vyřazené, nebo berte tuto.
</small>
)}
</div>
<div className="spin-options">
<div className="spin-options-header">
<span>Na kole ({wheelList.length})</span>
{excludedCount > 0 && (
<button
className="spin-restore"
disabled={spinning}
title="Vrátit vyřazené možnosti"
onClick={() => restoreAll()}
>
<FontAwesomeIcon icon={faRotateLeft} /> Vrátit {excludedCount}
</button>
)}
</div>
<ul>
{wheelList.map((item, index) => (
<li key={item.key}>
<span className={`spin-dot spin-seg-${index % SEGMENT_COLORS}`} />
<span className="spin-option-label" title={item.label}>{item.label}</span>
{item.group?.recipe && <FontAwesomeIcon className="spin-option-kind" icon={faBookOpen} title="Recept" />}
{item.note && <small className="spin-option-kind">{item.note}</small>}
<button
className="spin-remove"
disabled={spinning}
title={`Vyřadit z kola: ${item.label}`}
aria-label={`Vyřadit ${item.label}`}
onClick={() => removeOption(item)}
>
<FontAwesomeIcon icon={faXmark} />
</button>
</li>
))}
</ul>
</div>
</div>
</>
)}
</Modal.Body>
<Modal.Footer>
{recipeShown && (
<Button variant="outline-secondary" onClick={() => setRecipeShown(false)}>Zpět na kolo</Button>
)}
{!recipeShown && pickedRecipe && !spinning && (
<Button onClick={() => setRecipeShown(true)}>
<FontAwesomeIcon icon={faBookOpen} /> Zobrazit recept
</Button>
)}
<Button variant="outline-secondary" onClick={() => onClose()}>Zavřít</Button>
</Modal.Footer>
</Modal>
);
};
export default SpinWheelModal;