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,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;
|
||||
Reference in new issue
Block a user