Files
food-tracer/client/src/components/modals/SpinWheelModal.tsx
T
Ondřej Anděl 865c2c3b22 fix: rozdílné jednotky u receptů
feat: vazby a trvání receptů
2026-10-19 18:40:13 +02:00

406 lines
20 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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);
// Recept otevřený z vazby padlého receptu (příloha, podobný)
const [linkedKey, setLinkedKey] = useState<string | undefined>();
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 showRecipe = (shown: boolean) => {
setRecipeShown(shown);
setLinkedKey(undefined);
};
const clearResult = () => {
setPicked(undefined);
showRecipe(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 recipeGroupList = (groupList ?? []).filter(hasRecipe);
const shownRecipe = recipeShown
? recipeGroupList.find(group => getMealKey(group) === linkedKey) ?? pickedRecipe
: 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>
{shownRecipe ? shownRecipe.name : 'Kolo štěstí: co dnes?'}
</Modal.Title>
</Modal.Header>
<WheelConfetti theme={theme} burstKey={confettiKey} />
<Modal.Body>
{shownRecipe ? (
<WheelRecipe
group={shownRecipe}
recipeList={recipeGroupList.map(group => group.recipe)}
onOpenRecipe={recipe => setLinkedKey(getMealKey(recipe))}
/>
) : (
<>
<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={() => showRecipe(false)}>Zpět na kolo</Button>
)}
{!recipeShown && pickedRecipe && !spinning && (
<Button onClick={() => showRecipe(true)}>
<FontAwesomeIcon icon={faBookOpen} /> Zobrazit recept
</Button>
)}
<Button variant="outline-secondary" onClick={() => onClose()}>Zavřít</Button>
</Modal.Footer>
</Modal>
);
};
export default SpinWheelModal;