feat: export + detail receptů
feat: losování jídel
This commit is contained in:
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;
|
||||
Reference in new issue
Block a user