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,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;