feat: export + detail receptů
feat: losování jídel
This commit is contained in:
1 parent
35cbc5a9ca
commit
caffb6d8b4
23 files changed
+2652
-21
No files matched your search
@@ -82,7 +82,7 @@ cd client && yarn build # tsc --noEmit + vite build → client/dist
|
|||||||
`IntakeStatsPanel`, `ActivityStatsPanel`, `SpendingStatsPanel`)
|
`IntakeStatsPanel`, `ActivityStatsPanel`, `SpendingStatsPanel`)
|
||||||
- **Components:** `components/` (Header, CalorieLookup, MealSuggestions, NutrientsFieldset)
|
- **Components:** `components/` (Header, CalorieLookup, MealSuggestions, NutrientsFieldset)
|
||||||
a `components/modals/` (MealModal, ActivityModal, ApplyWorkoutModal, ApplyMealModal —
|
a `components/modals/` (MealModal, ActivityModal, ApplyWorkoutModal, ApplyMealModal —
|
||||||
výběr z historie a receptů, SettingsModal, ImportModal)
|
výběr z historie a receptů, SettingsModal, ImportModal, SpinWheelModal — kolo štěstí)
|
||||||
- **Klientské enumy** patří do `enums.ts` jako TypeScript `enum` s mapou popisků,
|
- **Klientské enumy** patří do `enums.ts` jako TypeScript `enum` s mapou popisků,
|
||||||
ne jako inline union u komponenty (`DayTab`, `StatsTab`, `StatsRange`)
|
ne jako inline union u komponenty (`DayTab`, `StatsTab`, `StatsRange`)
|
||||||
- **Context:** `context/auth.tsx` (JWT), `context/settings.tsx` (světlý/tmavý motiv)
|
- **Context:** `context/auth.tsx` (JWT), `context/settings.tsx` (světlý/tmavý motiv)
|
||||||
@@ -343,6 +343,29 @@ ji při čtení doplní.
|
|||||||
Recepty se nabízejí v našeptávání i ve výběru z historie (`ApplyMealModal`)
|
Recepty se nabízejí v našeptávání i ve výběru z historie (`ApplyMealModal`)
|
||||||
a jídlo stejného jména z historie se vedle receptu nenabízí podruhé.
|
a jídlo stejného jména z historie se vedle receptu nenabízí podruhé.
|
||||||
|
|
||||||
|
**Postup receptu je Markdown.** V aplikaci ho vykresluje `RecipeNote`
|
||||||
|
(react-markdown, syrové HTML se nevykreslí), do PDF ho převádí `markdownPdf.ts`
|
||||||
|
nad stejným parserem (mdast). Obě cesty používají `markdownBreaks.ts`, který
|
||||||
|
z jednoduchého zalomení řádku dělá tvrdé — starší postupy jsou prostý text po
|
||||||
|
řádcích a jinak by se slily do jednoho odstavce. **Když se změní zobrazení
|
||||||
|
Markdownu v jedné cestě, je potřeba upravit i druhou.**
|
||||||
|
|
||||||
|
**Export do PDF** (`recipePdf.ts`, pdfmake v prohlížeči, načte se líně až při
|
||||||
|
exportu) jde u jednoho receptu (ikona na kartě receptu) i hromadně tlačítkem
|
||||||
|
na stránce Jídla — exportují se recepty, které seznam po filtrech ukazuje, bez
|
||||||
|
filtru všechny. Víc receptů dostane titulní stranu s klikacím obsahem, každý
|
||||||
|
recept začíná na nové stránce. PDF nese suroviny, hodnoty porce, postup a fotky
|
||||||
|
jídla (první přes šířku, ostatní menší); pdfmake umí jen JPEG a PNG, jiné
|
||||||
|
formáty se vynechají. Server se exportu neúčastní.
|
||||||
|
|
||||||
|
**Kolo štěstí** (`SpinWheelModal`, tlačítko v hlavičce) je převzaté z Luncheru.
|
||||||
|
Losuje ze všech jídel (skupin ze seznamu jídel — i objednávaných), nebo jen
|
||||||
|
z receptů, volitelně zúžených chutí a typem. U všech jídel se nejdřív vybere
|
||||||
|
nebo vylosuje zdroj a točí se až z jeho jídel — všech jídel jsou stovky
|
||||||
|
a kolo by s nimi bylo nepoužitelné. Padlý recept jde rovnou otevřít
|
||||||
|
(`WheelRecipe`, jen ke čtení) a stáhnout jako PDF. Nic se neukládá — kolo jen
|
||||||
|
rozhoduje. Konfety (`WheelConfetti`) mají vzhled jednoduchého stylu a Hell Girl.
|
||||||
|
|
||||||
Dřív pod stejným klíčem ležely i šablony hotových jídel (`kind: HOTOVE`, nebo
|
Dřív pod stejným klíčem ležely i šablony hotových jídel (`kind: HOTOVE`, nebo
|
||||||
bez `kind`). Ty zanikly: `loadRecipes` je při čtení vynechá (poznají se podle
|
bez `kind`). Ty zanikly: `loadRecipes` je při čtení vynechá (poznají se podle
|
||||||
toho, že nemají suroviny) a první zápis receptu je z úložiště odklidí. **Jídla
|
toho, že nemají suroviny) a první zápis receptu je z úložiště odklidí. **Jídla
|
||||||
|
|||||||
@@ -10,15 +10,20 @@
|
|||||||
"@fortawesome/free-regular-svg-icons": "^7.1.0",
|
"@fortawesome/free-regular-svg-icons": "^7.1.0",
|
||||||
"@fortawesome/free-solid-svg-icons": "^7.1.0",
|
"@fortawesome/free-solid-svg-icons": "^7.1.0",
|
||||||
"@fortawesome/react-fontawesome": "^3.1.0",
|
"@fortawesome/react-fontawesome": "^3.1.0",
|
||||||
|
"@types/mdast": "4.0.4",
|
||||||
"@types/node": "^24.10.0",
|
"@types/node": "^24.10.0",
|
||||||
|
"@types/pdfmake": "0.3.3",
|
||||||
"@types/react": "^19.2.2",
|
"@types/react": "^19.2.2",
|
||||||
"@types/react-dom": "^19.2.2",
|
"@types/react-dom": "^19.2.2",
|
||||||
"@vitejs/plugin-react": "^5.1.0",
|
"@vitejs/plugin-react": "^5.1.0",
|
||||||
"bootstrap": "^5.3.8",
|
"bootstrap": "^5.3.8",
|
||||||
|
"mdast-util-from-markdown": "2.0.3",
|
||||||
|
"pdfmake": "0.3.11",
|
||||||
"react": "^19.2.0",
|
"react": "^19.2.0",
|
||||||
"react-bootstrap": "^2.10.10",
|
"react-bootstrap": "^2.10.10",
|
||||||
"react-dom": "^19.2.0",
|
"react-dom": "^19.2.0",
|
||||||
"react-jwt": "^1.3.0",
|
"react-jwt": "^1.3.0",
|
||||||
|
"react-markdown": "10.1.0",
|
||||||
"react-router": "^7.9.5",
|
"react-router": "^7.9.5",
|
||||||
"react-router-dom": "^7.9.5",
|
"react-router-dom": "^7.9.5",
|
||||||
"react-toastify": "^11.0.5",
|
"react-toastify": "^11.0.5",
|
||||||
|
|||||||
@@ -268,6 +268,12 @@ body {
|
|||||||
color: var(--ft-text-secondary);
|
color: var(--ft-text-secondary);
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.page-header-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================
|
// ============================================
|
||||||
|
|||||||
@@ -410,3 +410,9 @@ export const parseNutrientInput = (value: string): number | undefined => {
|
|||||||
/** Převede gramy živiny na text pro editační pole. */
|
/** Převede gramy živiny na text pro editační pole. */
|
||||||
export const formatNutrientInput = (grams?: number): string =>
|
export const formatNutrientInput = (grams?: number): string =>
|
||||||
grams == null ? '' : String(grams).replace('.', ',');
|
grams == null ? '' : String(grams).replace('.', ',');
|
||||||
|
|
||||||
|
/** Počet se správným tvarem slova: 1 porce, 2–4 porce, 5 porcí. */
|
||||||
|
export const formatCount = (count: number, one: string, few: string, many: string): string => {
|
||||||
|
if (count === 1) return `${count} ${one}`;
|
||||||
|
return `${count} ${count >= 2 && count <= 4 ? few : many}`;
|
||||||
|
};
|
||||||
@@ -2,11 +2,12 @@ import { useState } from "react";
|
|||||||
import { Navbar, Nav, NavDropdown } from "react-bootstrap";
|
import { Navbar, Nav, NavDropdown } from "react-bootstrap";
|
||||||
import { Link, useLocation } from "react-router";
|
import { Link, useLocation } from "react-router";
|
||||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
import { faSun, faMoon, faGear } from "@fortawesome/free-solid-svg-icons";
|
import { faSun, faMoon, faGear, faDharmachakra } from "@fortawesome/free-solid-svg-icons";
|
||||||
import { useAuth } from "../context/auth";
|
import { useAuth } from "../context/auth";
|
||||||
import { useSettings, ThemePreference } from "../context/settings";
|
import { useSettings, ThemePreference } from "../context/settings";
|
||||||
import { DEN_URL, JIDLA_URL, PROFIL_URL, STATISTIKY_URL, TRENINKY_URL } from "../routes";
|
import { DEN_URL, JIDLA_URL, PROFIL_URL, STATISTIKY_URL, TRENINKY_URL } from "../routes";
|
||||||
import SettingsModal from "./modals/SettingsModal";
|
import SettingsModal from "./modals/SettingsModal";
|
||||||
|
import SpinWheelModal from "./modals/SpinWheelModal";
|
||||||
|
|
||||||
/** Položky hlavní navigace. */
|
/** Položky hlavní navigace. */
|
||||||
const NAV_ITEMS = [
|
const NAV_ITEMS = [
|
||||||
@@ -19,6 +20,7 @@ const NAV_ITEMS = [
|
|||||||
|
|
||||||
export default function Header() {
|
export default function Header() {
|
||||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||||
|
const [wheelOpen, setWheelOpen] = useState(false);
|
||||||
const auth = useAuth();
|
const auth = useAuth();
|
||||||
const settings = useSettings();
|
const settings = useSettings();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
@@ -49,6 +51,14 @@ export default function Header() {
|
|||||||
{item.label}
|
{item.label}
|
||||||
</Link>
|
</Link>
|
||||||
))}
|
))}
|
||||||
|
<button
|
||||||
|
className="theme-toggle"
|
||||||
|
onClick={() => setWheelOpen(true)}
|
||||||
|
title="Zatoč si — kolo štěstí, když nevíte, co jíst"
|
||||||
|
aria-label="Kolo štěstí"
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon icon={faDharmachakra} />
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
className="theme-toggle"
|
className="theme-toggle"
|
||||||
onClick={toggleTheme}
|
onClick={toggleTheme}
|
||||||
@@ -67,6 +77,7 @@ export default function Header() {
|
|||||||
</Nav>
|
</Nav>
|
||||||
</Navbar.Collapse>
|
</Navbar.Collapse>
|
||||||
<SettingsModal isOpen={settingsOpen} onClose={() => setSettingsOpen(false)} />
|
<SettingsModal isOpen={settingsOpen} onClose={() => setSettingsOpen(false)} />
|
||||||
|
{wheelOpen && <SpinWheelModal isOpen onClose={() => setWheelOpen(false)} />}
|
||||||
</Navbar>
|
</Navbar>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -2,6 +2,7 @@ import { useState } from "react";
|
|||||||
import { MealGroup, deleteMealTemplate } from "../../../types";
|
import { MealGroup, deleteMealTemplate } from "../../../types";
|
||||||
import MealTemplateCard from "./MealTemplateCard";
|
import MealTemplateCard from "./MealTemplateCard";
|
||||||
import MealTemplateForm from "./MealTemplateForm";
|
import MealTemplateForm from "./MealTemplateForm";
|
||||||
|
import { useRecipeExport } from "../useRecipeExport";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
group: MealGroup,
|
group: MealGroup,
|
||||||
@@ -17,6 +18,7 @@ type Props = {
|
|||||||
const MealGroupRecipe = ({ group, onChanged, onClose }: Readonly<Props>) => {
|
const MealGroupRecipe = ({ group, onChanged, onClose }: Readonly<Props>) => {
|
||||||
const [editing, setEditing] = useState(!group.recipe);
|
const [editing, setEditing] = useState(!group.recipe);
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
|
const { exporting, exportRecipes } = useRecipeExport();
|
||||||
|
|
||||||
const remove = async (id: string) => {
|
const remove = async (id: string) => {
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
@@ -30,7 +32,8 @@ const MealGroupRecipe = ({ group, onChanged, onClose }: Readonly<Props>) => {
|
|||||||
{group.recipe && !editing && (
|
{group.recipe && !editing && (
|
||||||
<MealTemplateCard
|
<MealTemplateCard
|
||||||
template={group.recipe}
|
template={group.recipe}
|
||||||
busy={busy}
|
busy={busy || exporting}
|
||||||
|
onExport={() => exportRecipes([{ recipe: group.recipe!, photoCount: group.photoCount }])}
|
||||||
onEdit={() => setEditing(true)}
|
onEdit={() => setEditing(true)}
|
||||||
onDelete={() => remove(group.recipe!.id)}
|
onDelete={() => remove(group.recipe!.id)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,21 +1,24 @@
|
|||||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
import { faPenToSquare, faTrash } from "@fortawesome/free-solid-svg-icons";
|
import { faFilePdf, faPenToSquare, faTrash } from "@fortawesome/free-solid-svg-icons";
|
||||||
import { MealTemplate } from "../../../types";
|
import { MealTemplate } from "../../../types";
|
||||||
import { RECIPE_FLAVOR_NAMES } from "../enums";
|
import { RECIPE_FLAVOR_NAMES } from "../enums";
|
||||||
import { deriveRecipe, formatCalories, formatPrice, formatWeight, getIngredientCalories } from "../Utils";
|
import { deriveRecipe, formatCalories, formatCount, formatPrice, formatWeight, getIngredientCalories } from "../Utils";
|
||||||
|
import RecipeNote from "./RecipeNote";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
template: MealTemplate,
|
template: MealTemplate,
|
||||||
busy: boolean,
|
busy?: boolean,
|
||||||
onEdit: () => void,
|
/** Bez úpravy a smazání je karta jen k nahlédnutí (kolo štěstí). */
|
||||||
onDelete: () => void,
|
onEdit?: () => void,
|
||||||
|
onDelete?: () => void,
|
||||||
|
onExport?: () => void,
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Karta receptu uvnitř jídla v seznamu jídel — suroviny, hodnoty celého hrnce
|
* Karta receptu uvnitř jídla v seznamu jídel — suroviny, hodnoty celého hrnce
|
||||||
* i jedné porce a postup. Název a zdroj neopakuje, nese je jídlo.
|
* i jedné porce a postup. Název a zdroj neopakuje, nese je jídlo.
|
||||||
*/
|
*/
|
||||||
const MealTemplateCard = ({ template, busy, onEdit, onDelete }: Readonly<Props>) => {
|
const MealTemplateCard = ({ template, busy, onEdit, onDelete, onExport }: Readonly<Props>) => {
|
||||||
const recipe = deriveRecipe(template.ingredients, template.servings, template.cookedWeight);
|
const recipe = deriveRecipe(template.ingredients, template.servings, template.cookedWeight);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -27,7 +30,7 @@ const MealTemplateCard = ({ template, busy, onEdit, onDelete }: Readonly<Props>)
|
|||||||
<span className="tag">{RECIPE_FLAVOR_NAMES[template.flavor].toLowerCase()}</span>
|
<span className="tag">{RECIPE_FLAVOR_NAMES[template.flavor].toLowerCase()}</span>
|
||||||
{template.weight != null && <span className="tag">porce {formatWeight(template.weight)}</span>}
|
{template.weight != null && <span className="tag">porce {formatWeight(template.weight)}</span>}
|
||||||
{(template.servings ?? 1) > 1 && (
|
{(template.servings ?? 1) > 1 && (
|
||||||
<span className="tag">{template.servings} porcí</span>
|
<span className="tag">{formatCount(template.servings ?? 1, 'porce', 'porce', 'porcí')}</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{template.calories != null && (
|
{template.calories != null && (
|
||||||
@@ -36,9 +39,23 @@ const MealTemplateCard = ({ template, busy, onEdit, onDelete }: Readonly<Props>)
|
|||||||
{template.price != null && (
|
{template.price != null && (
|
||||||
<span className="meal-template-price">{formatPrice(template.price)}</span>
|
<span className="meal-template-price">{formatPrice(template.price)}</span>
|
||||||
)}
|
)}
|
||||||
|
{onExport && (
|
||||||
|
<button
|
||||||
|
className="btn-icon"
|
||||||
|
onClick={() => onExport()}
|
||||||
|
disabled={busy}
|
||||||
|
title="Stáhnout jako PDF"
|
||||||
|
aria-label="Stáhnout recept jako PDF"
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon icon={faFilePdf} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{onEdit && (
|
||||||
<button className="btn-icon" onClick={() => onEdit()} title="Upravit" aria-label="Upravit recept">
|
<button className="btn-icon" onClick={() => onEdit()} title="Upravit" aria-label="Upravit recept">
|
||||||
<FontAwesomeIcon icon={faPenToSquare} />
|
<FontAwesomeIcon icon={faPenToSquare} />
|
||||||
</button>
|
</button>
|
||||||
|
)}
|
||||||
|
{onDelete && (
|
||||||
<button
|
<button
|
||||||
className="btn-icon danger"
|
className="btn-icon danger"
|
||||||
onClick={() => onDelete()}
|
onClick={() => onDelete()}
|
||||||
@@ -48,6 +65,7 @@ const MealTemplateCard = ({ template, busy, onEdit, onDelete }: Readonly<Props>)
|
|||||||
>
|
>
|
||||||
<FontAwesomeIcon icon={faTrash} />
|
<FontAwesomeIcon icon={faTrash} />
|
||||||
</button>
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{!!template.ingredients.length && (
|
{!!template.ingredients.length && (
|
||||||
@@ -74,7 +92,7 @@ const MealTemplateCard = ({ template, busy, onEdit, onDelete }: Readonly<Props>)
|
|||||||
{recipe.totalPrice != null && <> · {formatPrice(recipe.totalPrice)}</>}
|
{recipe.totalPrice != null && <> · {formatPrice(recipe.totalPrice)}</>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{template.note && <p className="meal-template-note">{template.note}</p>}
|
{template.note && <RecipeNote note={template.note} />}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { RECIPE_FLAVOR_NAMES } from "../enums";
|
|||||||
import { deriveRecipe, formatCalories, formatPrice, formatWeight, parseIntInput } from "../Utils";
|
import { deriveRecipe, formatCalories, formatPrice, formatWeight, parseIntInput } from "../Utils";
|
||||||
import SourceInput from "./SourceInput";
|
import SourceInput from "./SourceInput";
|
||||||
import RecipeCategoryInput from "./RecipeCategoryInput";
|
import RecipeCategoryInput from "./RecipeCategoryInput";
|
||||||
|
import RecipeNote from "./RecipeNote";
|
||||||
import RecipeIngredientsEditor, {
|
import RecipeIngredientsEditor, {
|
||||||
DraftIngredient, emptyIngredient, toDraftIngredient, toIngredient,
|
DraftIngredient, emptyIngredient, toDraftIngredient, toIngredient,
|
||||||
} from "./RecipeIngredientsEditor";
|
} from "./RecipeIngredientsEditor";
|
||||||
@@ -21,6 +22,9 @@ type Props = {
|
|||||||
onCancel: () => void,
|
onCancel: () => void,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** Ukázka Markdownu, dokud je postup prázdný. */
|
||||||
|
const NOTE_PLACEHOLDER = 'Jak se jídlo připravuje…\n\n## Příprava\n1. Cibuli nakrájejte nadrobno\n2. **Orestujte** dozlatova';
|
||||||
|
|
||||||
/** Naplní formulář z receptu, nebo ho nechá prázdný pro nový. */
|
/** Naplní formulář z receptu, nebo ho nechá prázdný pro nový. */
|
||||||
const createForm = (template?: MealTemplate, meal?: Props['meal']) => ({
|
const createForm = (template?: MealTemplate, meal?: Props['meal']) => ({
|
||||||
name: meal?.name ?? template?.name ?? '',
|
name: meal?.name ?? template?.name ?? '',
|
||||||
@@ -42,6 +46,7 @@ const MealTemplateForm = ({ template, meal, onSaved, onCancel }: Readonly<Props>
|
|||||||
template?.ingredients?.length ? template.ingredients.map(toDraftIngredient) : [emptyIngredient()]);
|
template?.ingredients?.length ? template.ingredients.map(toDraftIngredient) : [emptyIngredient()]);
|
||||||
const [error, setError] = useState<string | undefined>();
|
const [error, setError] = useState<string | undefined>();
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [notePreview, setNotePreview] = useState(false);
|
||||||
const flavorId = useId();
|
const flavorId = useId();
|
||||||
|
|
||||||
const update = (changes: Partial<ReturnType<typeof createForm>>) =>
|
const update = (changes: Partial<ReturnType<typeof createForm>>) =>
|
||||||
@@ -178,14 +183,43 @@ const MealTemplateForm = ({ template, meal, onSaved, onCancel }: Readonly<Props>
|
|||||||
</fieldset>
|
</fieldset>
|
||||||
|
|
||||||
<Form.Group className="mb-3">
|
<Form.Group className="mb-3">
|
||||||
|
<div className="recipe-note-label">
|
||||||
<Form.Label>Postup</Form.Label>
|
<Form.Label>Postup</Form.Label>
|
||||||
|
<ToggleButtonGroup
|
||||||
|
type="radio"
|
||||||
|
name={`${flavorId}-note`}
|
||||||
|
size="sm"
|
||||||
|
value={notePreview ? 'preview' : 'edit'}
|
||||||
|
onChange={(value: string) => setNotePreview(value === 'preview')}
|
||||||
|
>
|
||||||
|
<ToggleButton id={`${flavorId}-note-edit`} value="edit" variant="outline-secondary">
|
||||||
|
Psát
|
||||||
|
</ToggleButton>
|
||||||
|
<ToggleButton
|
||||||
|
id={`${flavorId}-note-preview`}
|
||||||
|
value="preview"
|
||||||
|
variant="outline-secondary"
|
||||||
|
disabled={!form.note.trim()}
|
||||||
|
>
|
||||||
|
Náhled
|
||||||
|
</ToggleButton>
|
||||||
|
</ToggleButtonGroup>
|
||||||
|
</div>
|
||||||
|
{notePreview && form.note.trim()
|
||||||
|
? <div className="recipe-note-preview"><RecipeNote note={form.note} /></div>
|
||||||
|
: (
|
||||||
<Form.Control
|
<Form.Control
|
||||||
as="textarea"
|
as="textarea"
|
||||||
rows={6}
|
rows={8}
|
||||||
value={form.note}
|
value={form.note}
|
||||||
placeholder="Jak se jídlo připravuje…"
|
placeholder={NOTE_PLACEHOLDER}
|
||||||
onChange={event => update({ note: event.target.value })}
|
onChange={event => update({ note: event.target.value })}
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
|
<Form.Text>
|
||||||
|
Markdown: <code>## nadpis</code>, <code>- odrážka</code>, <code>1. krok</code>,
|
||||||
|
{' '}<code>**tučně**</code>, <code>*kurzíva*</code>
|
||||||
|
</Form.Text>
|
||||||
</Form.Group>
|
</Form.Group>
|
||||||
|
|
||||||
<div className="workout-form-footer">
|
<div className="workout-form-footer">
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import Markdown from "react-markdown";
|
||||||
|
import { remarkLineBreaks } from "../markdownBreaks";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
note: string,
|
||||||
|
};
|
||||||
|
|
||||||
|
const REMARK_PLUGINS = [remarkLineBreaks];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Postup receptu zapsaný v Markdownu. Syrové HTML react-markdown nevykreslí,
|
||||||
|
* takže postup nic nepodstrčí. Jednoduché zalomení řádku zůstává zalomením.
|
||||||
|
*/
|
||||||
|
const RecipeNote = ({ note }: Readonly<Props>) => (
|
||||||
|
<div className="recipe-note">
|
||||||
|
<Markdown remarkPlugins={REMARK_PLUGINS}>{note}</Markdown>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
export default RecipeNote;
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
/** Délka roztočení kola; po ní se ukáže výsledek. */
|
||||||
|
export const SPIN_MS = 4800;
|
||||||
|
/** Počet barev výsečí, které se střídají dokola. */
|
||||||
|
export const SEGMENT_COLORS = 6;
|
||||||
|
|
||||||
|
const WHEEL_SIZE = 360;
|
||||||
|
const RADIUS = WHEEL_SIZE / 2 - 6;
|
||||||
|
const CENTER = WHEEL_SIZE / 2;
|
||||||
|
/** U víc výsečí jsou popisky nečitelné — názvy jsou v seznamu vedle kola. */
|
||||||
|
const MAX_LABELED_SEGMENTS = 40;
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
labels: string[],
|
||||||
|
/** Natočení kola ve stupních; kolo se k němu dotočí animací, když je `spinning`. */
|
||||||
|
rotation: number,
|
||||||
|
spinning: boolean,
|
||||||
|
onSpinEnd: () => void,
|
||||||
|
};
|
||||||
|
|
||||||
|
const truncate = (text: string, max: number): string =>
|
||||||
|
text.length > max ? `${text.slice(0, max - 1)}…` : text;
|
||||||
|
|
||||||
|
/** Bod na kružnici; úhel ve stupních měřený od „12 hodin" po směru hodinových ručiček. */
|
||||||
|
const polar = (angle: number, radius: number) => {
|
||||||
|
const rad = (angle - 90) * Math.PI / 180;
|
||||||
|
return { x: CENTER + radius * Math.cos(rad), y: CENTER + radius * Math.sin(rad) };
|
||||||
|
};
|
||||||
|
|
||||||
|
const createSegmentPath = (start: number, end: number): string => {
|
||||||
|
if (end - start >= 360) {
|
||||||
|
return `M ${CENTER} ${CENTER - RADIUS} A ${RADIUS} ${RADIUS} 0 1 1 ${CENTER - 0.01} ${CENTER - RADIUS} Z`;
|
||||||
|
}
|
||||||
|
const a = polar(start, RADIUS);
|
||||||
|
const b = polar(end, RADIUS);
|
||||||
|
const large = end - start > 180 ? 1 : 0;
|
||||||
|
return `M ${CENTER} ${CENTER} L ${a.x} ${a.y} A ${RADIUS} ${RADIUS} 0 ${large} 1 ${b.x} ${b.y} Z`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getFontSize = (count: number): number => {
|
||||||
|
if (count > 24) return 8.5;
|
||||||
|
if (count > 14) return 10;
|
||||||
|
if (count > 8) return 12;
|
||||||
|
return 14;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Kolo štěstí s výsečí pro každou možnost — převzaté z Luncheru. */
|
||||||
|
const SpinWheel = ({ labels, rotation, spinning, onSpinEnd }: Readonly<Props>) => {
|
||||||
|
const count = labels.length;
|
||||||
|
const angle = 360 / Math.max(count, 1);
|
||||||
|
const fontSize = getFontSize(count);
|
||||||
|
const maxChars = count > 14 ? 24 : 20;
|
||||||
|
const showLabels = count <= MAX_LABELED_SEGMENTS;
|
||||||
|
return (
|
||||||
|
<div className="spin-wheel">
|
||||||
|
<div className="spin-wheel-pointer" aria-hidden />
|
||||||
|
<svg
|
||||||
|
viewBox={`0 0 ${WHEEL_SIZE} ${WHEEL_SIZE}`}
|
||||||
|
className="spin-wheel-svg"
|
||||||
|
style={{
|
||||||
|
transform: `rotate(${rotation}deg)`,
|
||||||
|
transition: spinning ? `transform ${SPIN_MS}ms cubic-bezier(0.12, 0.72, 0.16, 1)` : 'none',
|
||||||
|
}}
|
||||||
|
onTransitionEnd={event => { if (event.propertyName === 'transform') onSpinEnd(); }}
|
||||||
|
role="img"
|
||||||
|
aria-label={`Kolo s ${count} možnostmi`}
|
||||||
|
>
|
||||||
|
<circle cx={CENTER} cy={CENTER} r={RADIUS + 4} className="spin-wheel-rim" />
|
||||||
|
{labels.map((label, index) => {
|
||||||
|
const start = index * angle;
|
||||||
|
const middle = start + angle / 2;
|
||||||
|
return (
|
||||||
|
// Názvy se můžou opakovat (stejné jídlo z jiného zdroje), proto i index
|
||||||
|
<g key={`${index}-${label}`}>
|
||||||
|
<path
|
||||||
|
d={createSegmentPath(start, start + angle)}
|
||||||
|
className={`spin-seg spin-seg-${index % SEGMENT_COLORS}`}
|
||||||
|
/>
|
||||||
|
{showLabels && (
|
||||||
|
<text
|
||||||
|
x={CENTER + 34}
|
||||||
|
y={CENTER}
|
||||||
|
transform={`rotate(${middle - 90} ${CENTER} ${CENTER})`}
|
||||||
|
dominantBaseline="middle"
|
||||||
|
fontSize={fontSize}
|
||||||
|
className="spin-seg-label"
|
||||||
|
>
|
||||||
|
{truncate(label, maxChars)}
|
||||||
|
</text>
|
||||||
|
)}
|
||||||
|
</g>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
<circle cx={CENTER} cy={CENTER} r={26} className="spin-wheel-hub" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default SpinWheel;
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
// Konfety po výsledku kola štěstí, převzaté z Luncheru
|
||||||
|
.wheel-confetti {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 2000; // nad modalem (bootstrap 1055)
|
||||||
|
pointer-events: none;
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
> span {
|
||||||
|
position: absolute;
|
||||||
|
will-change: transform, opacity;
|
||||||
|
animation-fill-mode: both;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Vrh: rychle vzhůru do vrcholu (zpomalení), pak volný pád (zrychlení)
|
||||||
|
@keyframes confetti-throw {
|
||||||
|
0% { transform: translate3d(0, 0, 0) rotate(0deg) scale(0.4); opacity: 0; animation-timing-function: cubic-bezier(0.2, 0.7, 0.4, 1); }
|
||||||
|
6% { opacity: 1; }
|
||||||
|
38% { transform: translate3d(var(--x1), var(--y1), 0) rotate(var(--r1)) scale(1); animation-timing-function: cubic-bezier(0.5, 0, 0.9, 0.6); }
|
||||||
|
88% { opacity: 1; }
|
||||||
|
100% { transform: translate3d(var(--x2), var(--y2), 0) rotate(var(--r2)) scale(1); opacity: 0; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Déšť: plynulý pád shora s mírným houpáním
|
||||||
|
@keyframes confetti-rain {
|
||||||
|
0% { transform: translate3d(0, 0, 0) rotate(0deg); opacity: 0; animation-timing-function: ease-in-out; }
|
||||||
|
8% { opacity: 1; }
|
||||||
|
40% { transform: translate3d(var(--x1), var(--y1), 0) rotate(var(--r1)); animation-timing-function: ease-in; }
|
||||||
|
88% { opacity: 1; }
|
||||||
|
100% { transform: translate3d(var(--x2), var(--y2), 0) rotate(var(--r2)); opacity: 0; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.confetti-emoji {
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Konfety ve stylu Hell Girl
|
||||||
|
// ============================================
|
||||||
|
.confetti-hg {
|
||||||
|
filter: drop-shadow(0 0 6px rgb(200 16 46 / 0.55)) drop-shadow(0 3px 3px rgb(0 0 0 / 0.3));
|
||||||
|
}
|
||||||
|
|
||||||
|
.confetti-hg-badge {
|
||||||
|
padding: 4px 14px;
|
||||||
|
border: 1px solid #c8102e;
|
||||||
|
outline: 1px solid rgb(200 16 46 / 0.4);
|
||||||
|
outline-offset: 2px;
|
||||||
|
color: #fff4ee;
|
||||||
|
font-family: 'Shippori Mincho', 'Noto Serif JP', serif;
|
||||||
|
font-weight: 800;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
white-space: nowrap;
|
||||||
|
text-shadow: 0 0 8px rgb(232 52 79 / 0.7);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nápis „poslání do pekla" — černá deska s rudým lemem, vynoří se z mlhy
|
||||||
|
.confetti-hg-banner {
|
||||||
|
position: absolute;
|
||||||
|
top: 38%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
padding: 14px 48px;
|
||||||
|
font-family: 'Shippori Mincho', 'Noto Serif JP', serif;
|
||||||
|
font-weight: 800;
|
||||||
|
font-size: clamp(2rem, 7vw, 4rem);
|
||||||
|
letter-spacing: 0.3em;
|
||||||
|
white-space: nowrap;
|
||||||
|
color: #fff4ee;
|
||||||
|
background: linear-gradient(90deg, transparent, #12070a 15%, #12070a 85%, transparent);
|
||||||
|
border-top: 2px solid #c8102e;
|
||||||
|
border-bottom: 2px solid #c8102e;
|
||||||
|
text-shadow: 0 0 18px rgb(232 52 79 / 0.9), 0 0 4px #c8102e;
|
||||||
|
animation: hg-banner 2.6s ease both;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes hg-banner {
|
||||||
|
0% { transform: translate(-50%, -50%) scale(1.15); opacity: 0; filter: blur(12px); }
|
||||||
|
25% { transform: translate(-50%, -50%) scale(1); opacity: 1; filter: blur(0); }
|
||||||
|
75% { transform: translate(-50%, -50%) scale(1); opacity: 1; filter: blur(0); }
|
||||||
|
100% { transform: translate(-50%, -50%) scale(0.96); opacity: 0; filter: blur(8px); }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Postava s hláškou — tmavý odznak s rudou září a bublina jako svitek
|
||||||
|
.confetti-voice {
|
||||||
|
position: absolute;
|
||||||
|
left: 16px;
|
||||||
|
bottom: 16px;
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: 8px;
|
||||||
|
max-width: min(460px, calc(100vw - 32px));
|
||||||
|
animation: confetti-voice 4.6s ease both;
|
||||||
|
}
|
||||||
|
|
||||||
|
.confetti-avatar {
|
||||||
|
flex: none;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
width: 84px;
|
||||||
|
height: 84px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
font-size: 46px;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 4px solid #12070a;
|
||||||
|
background: radial-gradient(circle at 50% 60%, #3a0d16, #12070a);
|
||||||
|
box-shadow: 0 0 0 3px var(--speaker, #c8102e), 0 0 18px rgb(200 16 46 / 0.6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.confetti-bubble {
|
||||||
|
position: relative;
|
||||||
|
margin-bottom: 40px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
background: #170c0f;
|
||||||
|
color: #f3e6e2;
|
||||||
|
border: 1px solid var(--speaker, #c8102e);
|
||||||
|
border-radius: 3px;
|
||||||
|
box-shadow: 0 0 16px rgb(0 0 0 / 0.6), 0 0 10px rgb(200 16 46 / 0.3);
|
||||||
|
font-family: 'Shippori Mincho', 'Noto Serif JP', serif;
|
||||||
|
|
||||||
|
p {
|
||||||
|
margin: 0;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 1rem;
|
||||||
|
line-height: 1.35;
|
||||||
|
}
|
||||||
|
|
||||||
|
small {
|
||||||
|
display: block;
|
||||||
|
margin-top: 4px;
|
||||||
|
color: var(--speaker, #c8102e);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ocásek bubliny směrem k postavě
|
||||||
|
&::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
left: -14px;
|
||||||
|
bottom: 14px;
|
||||||
|
border: 8px solid transparent;
|
||||||
|
border-right: 10px solid var(--speaker, #c8102e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes confetti-voice {
|
||||||
|
0% { opacity: 0; transform: translateY(40px) scale(0.8); }
|
||||||
|
10% { opacity: 1; transform: translateY(0) scale(1.03); }
|
||||||
|
15% { transform: translateY(0) scale(1); }
|
||||||
|
85% { opacity: 1; transform: translateY(0); }
|
||||||
|
100% { opacity: 0; transform: translateY(20px); }
|
||||||
|
}
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
import { CSSProperties, ReactNode, useEffect, useMemo, useState } from "react";
|
||||||
|
import { createPortal } from "react-dom";
|
||||||
|
import { StyleTheme } from "../enums";
|
||||||
|
import "./WheelConfetti.scss";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
theme: StyleTheme,
|
||||||
|
/** Mění se s každým výsledkem — nová hodnota spustí novou salvu. */
|
||||||
|
burstKey: number,
|
||||||
|
};
|
||||||
|
|
||||||
|
type Speaker = { name: string, emoji: string, color: string, quote: string };
|
||||||
|
|
||||||
|
const SIMPLE_COLORS = ['#16a34a', '#0ea5e9', '#f59e0b', '#8b5cf6', '#ec4899', '#14b8a6', '#ef4444'];
|
||||||
|
const HG_EMOJI = ['🥀', '🏮', '⛩️', '🔥', '🕯️', '🌑', '🦋', '👁️', '🕸️', '🌸'];
|
||||||
|
// Hlášky z Jigoku Shōjo (Pekelná korespondence, „Ippen, shinde miru?", poslání do pekla...)
|
||||||
|
const HG_BADGES = ['いっぺん、死んでみる?', '地獄通信', '地獄流し', 'Ippen… shinde miru?', 'Pomsta vykonána', '0:00', 'Uvaž to dobře…', 'Smlouva uzavřena'];
|
||||||
|
const HG_BADGE_COLORS = ['#12070a', '#7a0c1f', '#2e0d24'];
|
||||||
|
/** Ai Enma a její služebníci s „hláškou" po výsledku — místo obrázků emoji. */
|
||||||
|
const HG_SPEAKERS: Speaker[] = [
|
||||||
|
{ name: 'Ai Enma', emoji: '🥀', color: '#c8102e', quote: 'Smlouva je uzavřena. Tohle jídlo tě bude provázet až na věčnost.' },
|
||||||
|
{ name: 'Ichimoku Ren', emoji: '👁️', color: '#6b5a8a', quote: 'Prohlédl jsem svým okem všechna jídla. Tohle jediné obstálo.' },
|
||||||
|
{ name: 'Hone Onna', emoji: '💀', color: '#b8862b', quote: 'Dobré až na kost, zlato. Mně z toho stejně nic nezůstane.' },
|
||||||
|
{ name: 'Wanyūdō', emoji: '🔥', color: '#d9582b', quote: 'Já jsem kolo. Kolo rozhodlo. Víc k tomu nemám.' },
|
||||||
|
{ name: 'Kikuri', emoji: '🎀', color: '#d4407a', quote: 'Hihi! Tohle bych do pekla poslala jako první!' },
|
||||||
|
{ name: 'Babička', emoji: '🕸️', color: '#8c7672', quote: 'Ai, jídlo je na stole… jako každý den už čtyři sta let.' },
|
||||||
|
];
|
||||||
|
const DURATION_MS = 6400;
|
||||||
|
|
||||||
|
const rand = (min: number, max: number) => min + Math.random() * (max - min);
|
||||||
|
const pick = <T,>(list: T[]): T => list[Math.floor(Math.random() * list.length)];
|
||||||
|
|
||||||
|
type Piece = { id: number, style: CSSProperties, content: ReactNode, className: string };
|
||||||
|
|
||||||
|
type Launch = 'cannon-left' | 'cannon-right' | 'burst' | 'rain';
|
||||||
|
|
||||||
|
const LAUNCHES: Launch[] = ['cannon-left', 'cannon-left', 'cannon-right', 'cannon-right', 'burst', 'rain'];
|
||||||
|
|
||||||
|
/** Vlastní CSS proměnné dráhy kousku; React je v `style` přijme, typ CSSProperties je nezná. */
|
||||||
|
const withVars = (style: CSSProperties, vars: Record<string, string>): CSSProperties =>
|
||||||
|
({ ...style, ...vars }) as CSSProperties;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Výchozí pozice a dráha jednoho kousku: děla z dolních rohů střílí šikmo vzhůru,
|
||||||
|
* „výbuch" se rozletí ze středu a déšť padá opožděně shora. Kousek nejprve vyletí
|
||||||
|
* do vrcholu (--x1/--y1) a pak dopadne mimo obrazovku (--x2/--y2).
|
||||||
|
*/
|
||||||
|
const createLaunchStyle = (launch: Launch): CSSProperties => {
|
||||||
|
let left: number, top: number, x1: number, y1: number, x2: number, y2: number, delay: number;
|
||||||
|
switch (launch) {
|
||||||
|
case 'cannon-left':
|
||||||
|
case 'cannon-right': {
|
||||||
|
const direction = launch === 'cannon-left' ? 1 : -1;
|
||||||
|
left = launch === 'cannon-left' ? rand(-3, 3) : rand(97, 103);
|
||||||
|
top = rand(88, 100);
|
||||||
|
x1 = direction * rand(12, 62);
|
||||||
|
y1 = -rand(45, 88);
|
||||||
|
x2 = x1 + direction * rand(6, 22);
|
||||||
|
y2 = rand(12, 25);
|
||||||
|
delay = rand(0, 450);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'burst': {
|
||||||
|
const angle = rand(0, Math.PI * 2);
|
||||||
|
const radius = rand(18, 48);
|
||||||
|
left = 50;
|
||||||
|
top = 42;
|
||||||
|
x1 = Math.cos(angle) * radius;
|
||||||
|
y1 = Math.sin(angle) * radius * 0.8 - rand(5, 15);
|
||||||
|
x2 = x1 * 1.35;
|
||||||
|
y2 = rand(70, 85);
|
||||||
|
delay = rand(0, 250);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
default: {
|
||||||
|
left = rand(0, 100);
|
||||||
|
top = -12;
|
||||||
|
x1 = rand(-8, 8);
|
||||||
|
y1 = rand(35, 55);
|
||||||
|
x2 = x1 + rand(-10, 10);
|
||||||
|
y2 = rand(115, 125);
|
||||||
|
delay = rand(400, 1800);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const spin = rand(180, 720) * (Math.random() < 0.5 ? -1 : 1);
|
||||||
|
return withVars({
|
||||||
|
left: `${left}vw`,
|
||||||
|
top: `${top}vh`,
|
||||||
|
animationName: launch === 'rain' ? 'confetti-rain' : 'confetti-throw',
|
||||||
|
animationDelay: `${delay}ms`,
|
||||||
|
animationDuration: `${rand(2600, 3900)}ms`,
|
||||||
|
}, {
|
||||||
|
'--x1': `${x1}vw`,
|
||||||
|
'--y1': `${y1}vh`,
|
||||||
|
'--x2': `${x2}vw`,
|
||||||
|
'--y2': `${y2}vh`,
|
||||||
|
'--r1': `${spin * 0.4}deg`,
|
||||||
|
'--r2': `${spin}deg`,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const createPieces = (theme: StyleTheme): Piece[] => {
|
||||||
|
if (theme !== StyleTheme.HELL_GIRL) {
|
||||||
|
return Array.from({ length: 170 }, (_, id) => ({
|
||||||
|
id,
|
||||||
|
className: 'confetti-paper',
|
||||||
|
content: null,
|
||||||
|
style: {
|
||||||
|
...createLaunchStyle(pick(LAUNCHES)),
|
||||||
|
width: `${rand(6, 11)}px`,
|
||||||
|
height: `${rand(10, 18)}px`,
|
||||||
|
background: pick(SIMPLE_COLORS),
|
||||||
|
borderRadius: Math.random() < 0.3 ? '50%' : '2px',
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
return Array.from({ length: 50 }, (_, id) => {
|
||||||
|
// Samolepky se tolik netočí, ať jsou čitelné
|
||||||
|
const base = withVars(createLaunchStyle(pick(LAUNCHES)), {
|
||||||
|
'--r1': `${rand(-25, 25)}deg`,
|
||||||
|
'--r2': `${rand(-40, 40)}deg`,
|
||||||
|
});
|
||||||
|
if (Math.random() < 0.35) {
|
||||||
|
return { id, className: 'confetti-hg confetti-hg-badge', content: pick(HG_BADGES), style: { ...base, background: pick(HG_BADGE_COLORS) } };
|
||||||
|
}
|
||||||
|
return { id, className: 'confetti-hg confetti-emoji', content: pick(HG_EMOJI), style: { ...base, fontSize: `${rand(28, 52)}px` } };
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Salva konfet po výsledku kola štěstí, ve vzhledu podle zvoleného stylu. Převzato z Luncheru. */
|
||||||
|
const WheelConfetti = ({ theme, burstKey }: Readonly<Props>) => {
|
||||||
|
const [active, setActive] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!burstKey) return;
|
||||||
|
setActive(true);
|
||||||
|
const timeout = setTimeout(() => setActive(false), DURATION_MS + 900);
|
||||||
|
return () => clearTimeout(timeout);
|
||||||
|
}, [burstKey]);
|
||||||
|
|
||||||
|
// Kousky i postava se losují jednou za salvu, ne při každém překreslení
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
const pieces = useMemo(() => createPieces(theme), [burstKey]);
|
||||||
|
const speaker = useMemo(
|
||||||
|
() => theme === StyleTheme.HELL_GIRL ? pick(HG_SPEAKERS) : undefined,
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
[burstKey],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!active || globalThis.matchMedia?.('(prefers-reduced-motion: reduce)').matches) return null;
|
||||||
|
|
||||||
|
return createPortal(
|
||||||
|
<div className="wheel-confetti" key={burstKey} aria-hidden>
|
||||||
|
{theme === StyleTheme.HELL_GIRL && <div className="confetti-hg-banner">🏮 地獄流し 🏮</div>}
|
||||||
|
{pieces.map(piece => <span key={piece.id} className={piece.className} style={piece.style}>{piece.content}</span>)}
|
||||||
|
{speaker && (
|
||||||
|
<div className="confetti-voice" style={withVars({}, { '--speaker': speaker.color })}>
|
||||||
|
<span className="confetti-avatar">{speaker.emoji}</span>
|
||||||
|
<div className="confetti-bubble">
|
||||||
|
<p>„{speaker.quote}"</p>
|
||||||
|
<small>— {speaker.name}</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>,
|
||||||
|
document.body,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default WheelConfetti;
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { Spinner } from "react-bootstrap";
|
||||||
|
import { MealGroup, MealPhoto, MealTemplate, getMealPhotos } from "../../../types";
|
||||||
|
import { useRecipeExport } from "../useRecipeExport";
|
||||||
|
import MealTemplateCard from "./MealTemplateCard";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
group: MealGroup & { recipe: MealTemplate },
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recept jídla, které padlo na kole štěstí — jen k nahlédnutí, ať se podle
|
||||||
|
* něj dá rovnou vařit. Upravuje se v seznamu jídel.
|
||||||
|
*/
|
||||||
|
const WheelRecipe = ({ group }: Readonly<Props>) => {
|
||||||
|
const [photo, setPhoto] = useState<MealPhoto | undefined>();
|
||||||
|
const [loading, setLoading] = useState(group.photoCount > 0);
|
||||||
|
const { exporting, exportRecipes } = useRecipeExport();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!group.photoCount) return;
|
||||||
|
getMealPhotos({ query: { name: group.name, source: group.source } }).then(response => {
|
||||||
|
setPhoto(response.data?.[0]);
|
||||||
|
setLoading(false);
|
||||||
|
});
|
||||||
|
}, [group.name, group.source, group.photoCount]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="wheel-recipe">
|
||||||
|
{loading && <div className="wheel-recipe-photo"><Spinner animation="border" variant="success" size="sm" /></div>}
|
||||||
|
{photo && <img className="wheel-recipe-photo" src={photo.image} alt={group.name} />}
|
||||||
|
<MealTemplateCard
|
||||||
|
template={group.recipe}
|
||||||
|
busy={exporting}
|
||||||
|
onExport={() => exportRecipes([{ recipe: group.recipe, photoCount: group.photoCount }])}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default WheelRecipe;
|
||||||
@@ -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;
|
||||||
@@ -212,3 +212,14 @@ export const STYLE_THEME_NAMES: Record<StyleTheme, string> = {
|
|||||||
JEDNODUCHY: 'Jednoduchý',
|
JEDNODUCHY: 'Jednoduchý',
|
||||||
HELL_GIRL: 'Hell Girl',
|
HELL_GIRL: 'Hell Girl',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** Z čeho kolo štěstí losuje — ze všech jídel (i objednávaných), nebo jen z receptů. */
|
||||||
|
export enum WheelPool {
|
||||||
|
VSE = 'VSE',
|
||||||
|
RECEPTY = 'RECEPTY',
|
||||||
|
}
|
||||||
|
|
||||||
|
export const WHEEL_POOL_NAMES: Record<WheelPool, string> = {
|
||||||
|
VSE: 'Všechna jídla',
|
||||||
|
RECEPTY: 'Recepty',
|
||||||
|
};
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import type { Break, Nodes, Parent, PhrasingContent, Root, Text } from "mdast";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Jednoduché zalomení řádku v Markdownu je jen mezera, nový řádek dělá až prázdný
|
||||||
|
* řádek. Postupy receptů se ale píšou i jako prostý text po řádcích (starší
|
||||||
|
* záznamy vznikly dřív, než postup uměl Markdown), proto se tady každé zalomení
|
||||||
|
* uvnitř odstavce převede na tvrdé — jako v komentářích na GitHubu.
|
||||||
|
*/
|
||||||
|
const splitBreaks = (node: Text): PhrasingContent[] => {
|
||||||
|
const partList = node.value.split('\n');
|
||||||
|
return partList.flatMap((value, index): PhrasingContent[] => {
|
||||||
|
const text: Text = { type: 'text', value };
|
||||||
|
if (index === partList.length - 1) return value ? [text] : [];
|
||||||
|
const lineBreak: Break = { type: 'break' };
|
||||||
|
return value ? [text, lineBreak] : [lineBreak];
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const isParent = (node: Nodes): node is Parent & Nodes => 'children' in node;
|
||||||
|
|
||||||
|
const transform = (node: Nodes) => {
|
||||||
|
if (!isParent(node)) return;
|
||||||
|
node.children = node.children.flatMap(child =>
|
||||||
|
child.type === 'text' && child.value.includes('\n') ? splitBreaks(child) : [child]) as typeof node.children;
|
||||||
|
node.children.forEach(child => transform(child as Nodes));
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Převede zalomení řádků v textu na tvrdá zalomení (`break`). */
|
||||||
|
export const applyLineBreaks = (tree: Root): Root => {
|
||||||
|
transform(tree);
|
||||||
|
return tree;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Plugin pro react-markdown se stejným chováním. */
|
||||||
|
export const remarkLineBreaks = () => (tree: Root) => {
|
||||||
|
applyLineBreaks(tree);
|
||||||
|
};
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
import { fromMarkdown } from "mdast-util-from-markdown";
|
||||||
|
import type { BlockContent, DefinitionContent, ListItem, PhrasingContent, RootContent } from "mdast";
|
||||||
|
import type { Content } from "pdfmake/interfaces";
|
||||||
|
import { applyLineBreaks } from "./markdownBreaks";
|
||||||
|
|
||||||
|
/** Barva odkazů a citací — stejná jako text vedle, jen tlumenější. */
|
||||||
|
const MUTED_COLOR = '#6b7280';
|
||||||
|
const LINK_COLOR = '#15803d';
|
||||||
|
const CODE_BACKGROUND = '#f3f4f6';
|
||||||
|
|
||||||
|
const convertInline = (nodeList: PhrasingContent[]): Content[] => nodeList.map(convertPhrasing);
|
||||||
|
|
||||||
|
const convertPhrasing = (node: PhrasingContent): Content => {
|
||||||
|
switch (node.type) {
|
||||||
|
case 'text':
|
||||||
|
return node.value;
|
||||||
|
case 'strong':
|
||||||
|
return { text: convertInline(node.children), bold: true };
|
||||||
|
case 'emphasis':
|
||||||
|
return { text: convertInline(node.children), italics: true };
|
||||||
|
case 'delete':
|
||||||
|
return { text: convertInline(node.children), decoration: 'lineThrough' };
|
||||||
|
case 'inlineCode':
|
||||||
|
return { text: node.value, background: CODE_BACKGROUND };
|
||||||
|
case 'break':
|
||||||
|
return '\n';
|
||||||
|
case 'link':
|
||||||
|
return { text: convertInline(node.children), link: node.url, color: LINK_COLOR, decoration: 'underline' };
|
||||||
|
// Obrázky z internetu se do PDF nestahují, zůstane popisek
|
||||||
|
case 'image':
|
||||||
|
return node.alt ?? '';
|
||||||
|
case 'html':
|
||||||
|
return node.value;
|
||||||
|
default:
|
||||||
|
return 'children' in node ? convertInline(node.children as PhrasingContent[]) : '';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Položka seznamu s jediným odstavcem je jen text, jinak se bloky skládají pod sebe. */
|
||||||
|
const convertListItem = (item: ListItem): Content => {
|
||||||
|
const [first, ...rest] = item.children;
|
||||||
|
if (first?.type === 'paragraph' && !rest.length) return { text: convertInline(first.children) };
|
||||||
|
return { stack: convertBlocks(item.children) };
|
||||||
|
};
|
||||||
|
|
||||||
|
const convertBlock = (node: RootContent | BlockContent | DefinitionContent): Content | undefined => {
|
||||||
|
switch (node.type) {
|
||||||
|
case 'heading':
|
||||||
|
return { text: convertInline(node.children), style: node.depth <= 2 ? 'noteHeading' : 'noteSubheading' };
|
||||||
|
case 'paragraph':
|
||||||
|
return { text: convertInline(node.children), style: 'noteParagraph' };
|
||||||
|
case 'list':
|
||||||
|
return node.ordered
|
||||||
|
? { ol: node.children.map(convertListItem), start: node.start ?? 1, style: 'noteList' }
|
||||||
|
: { ul: node.children.map(convertListItem), style: 'noteList' };
|
||||||
|
case 'blockquote':
|
||||||
|
return { stack: convertBlocks(node.children), italics: true, color: MUTED_COLOR, margin: [12, 0, 0, 6] };
|
||||||
|
case 'code':
|
||||||
|
return {
|
||||||
|
table: { widths: ['*'], body: [[{ text: node.value, fillColor: CODE_BACKGROUND, fontSize: 9 }]] },
|
||||||
|
layout: 'noBorders',
|
||||||
|
style: 'noteParagraph',
|
||||||
|
};
|
||||||
|
case 'thematicBreak':
|
||||||
|
return {
|
||||||
|
canvas: [{ type: 'line', x1: 0, y1: 0, x2: 515, y2: 0, lineWidth: 0.5, lineColor: '#d1d5db' }],
|
||||||
|
margin: [0, 4, 0, 10],
|
||||||
|
};
|
||||||
|
case 'html':
|
||||||
|
return { text: node.value, style: 'noteParagraph' };
|
||||||
|
default:
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const convertBlocks = (nodeList: (RootContent | BlockContent | DefinitionContent)[]): Content[] =>
|
||||||
|
nodeList.map(convertBlock).filter((item): item is Content => item !== undefined);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Převede postup receptu z Markdownu na obsah pdfmake. Parser je stejný jako
|
||||||
|
* u zobrazení v aplikaci (react-markdown stojí na mdast), včetně zalomení řádků,
|
||||||
|
* takže PDF vypadá jako karta receptu.
|
||||||
|
*/
|
||||||
|
export const convertMarkdownToPdf = (markdown: string): Content[] =>
|
||||||
|
convertBlocks(applyLineBreaks(fromMarkdown(markdown)).children);
|
||||||
@@ -2,12 +2,13 @@ import { useCallback, useEffect, useMemo, useState } from "react";
|
|||||||
import { Button, Form, Spinner } from "react-bootstrap";
|
import { Button, Form, Spinner } from "react-bootstrap";
|
||||||
import { useLocation } from "react-router";
|
import { useLocation } from "react-router";
|
||||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
import { faBookOpen, faCircleCheck } from "@fortawesome/free-solid-svg-icons";
|
import { faBookOpen, faCircleCheck, faFilePdf } from "@fortawesome/free-solid-svg-icons";
|
||||||
import { MealGroup, getMealGroups } from "../../../types";
|
import { MealGroup, MealTemplate, getMealGroups } from "../../../types";
|
||||||
import { MealGroupAction, RECIPE_FILTER_NAMES, RecipeFilter, SourceFilter } from "../enums";
|
import { MealGroupAction, RECIPE_FILTER_NAMES, RecipeFilter, SourceFilter } from "../enums";
|
||||||
import { getMealKey, normalizeSearch } from "../Utils";
|
import { getMealKey, normalizeSearch } from "../Utils";
|
||||||
import MealGroupRow from "../components/MealGroupRow";
|
import MealGroupRow from "../components/MealGroupRow";
|
||||||
import MealTemplateForm from "../components/MealTemplateForm";
|
import MealTemplateForm from "../components/MealTemplateForm";
|
||||||
|
import { useRecipeExport } from "../useRecipeExport";
|
||||||
import "./templates.scss";
|
import "./templates.scss";
|
||||||
import "./MealGroupsPage.scss";
|
import "./MealGroupsPage.scss";
|
||||||
|
|
||||||
@@ -73,6 +74,7 @@ const MealGroupsPage = () => {
|
|||||||
(location.state as LocationState)?.recipeFilter ?? RecipeFilter.VSE);
|
(location.state as LocationState)?.recipeFilter ?? RecipeFilter.VSE);
|
||||||
const [opened, setOpened] = useState<{ key: string, action: MealGroupAction } | undefined>();
|
const [opened, setOpened] = useState<{ key: string, action: MealGroupAction } | undefined>();
|
||||||
const [newRecipeOpen, setNewRecipeOpen] = useState(false);
|
const [newRecipeOpen, setNewRecipeOpen] = useState(false);
|
||||||
|
const { exporting, exportRecipes } = useRecipeExport();
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
const load = useCallback(async () => {
|
||||||
const response = await getMealGroups();
|
const response = await getMealGroups();
|
||||||
@@ -93,6 +95,11 @@ const MealGroupsPage = () => {
|
|||||||
&& (!search.length || matchesSearch(group, search)));
|
&& (!search.length || matchesSearch(group, search)));
|
||||||
}, [groupList, query, sourceFilter, recipeFilter, onlyIncomplete]);
|
}, [groupList, query, sourceFilter, recipeFilter, onlyIncomplete]);
|
||||||
|
|
||||||
|
// Exportují se recepty, které seznam právě ukazuje — bez filtru tedy všechny
|
||||||
|
const visibleRecipeList = useMemo(() => visibleList
|
||||||
|
.filter((group): group is MealGroup & { recipe: MealTemplate } => !!group.recipe)
|
||||||
|
.map(group => ({ recipe: group.recipe, photoCount: group.photoCount })), [visibleList]);
|
||||||
|
|
||||||
const filtered = !!query.trim().length || sourceFilter !== SourceFilter.VSE || recipeFilter !== RecipeFilter.VSE;
|
const filtered = !!query.trim().length || sourceFilter !== SourceFilter.VSE || recipeFilter !== RecipeFilter.VSE;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -104,10 +111,21 @@ const MealGroupsPage = () => {
|
|||||||
Všechna jídla podle názvu a zdroje — údaje doplníte nebo název změníte ve všech dnech najednou
|
Všechna jídla podle názvu a zdroje — údaje doplníte nebo název změníte ve všech dnech najednou
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="page-header-actions">
|
||||||
|
<Button
|
||||||
|
variant="outline-secondary"
|
||||||
|
disabled={exporting || !visibleRecipeList.length}
|
||||||
|
title={filtered ? 'Stáhnout zobrazené recepty jako PDF' : 'Stáhnout všechny recepty jako PDF'}
|
||||||
|
onClick={() => exportRecipes(visibleRecipeList)}
|
||||||
|
>
|
||||||
|
{exporting ? <Spinner animation="border" size="sm" /> : <FontAwesomeIcon icon={faFilePdf} />}
|
||||||
|
{' '}Recepty do PDF{!loading && ` (${visibleRecipeList.length})`}
|
||||||
|
</Button>
|
||||||
<Button variant="outline-secondary" onClick={() => setNewRecipeOpen(open => !open)}>
|
<Button variant="outline-secondary" onClick={() => setNewRecipeOpen(open => !open)}>
|
||||||
<FontAwesomeIcon icon={faBookOpen} /> Nový recept
|
<FontAwesomeIcon icon={faBookOpen} /> Nový recept
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{newRecipeOpen && (
|
{newRecipeOpen && (
|
||||||
<MealTemplateForm
|
<MealTemplateForm
|
||||||
|
|||||||
@@ -92,16 +92,70 @@
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Postup přípravy si drží zalomení řádků tak, jak ho uživatel zapsal
|
// Postup přípravy v Markdownu
|
||||||
.meal-template-note {
|
.recipe-note {
|
||||||
margin: 10px 0 0;
|
margin: 10px 0 0;
|
||||||
padding-top: 10px;
|
padding-top: 10px;
|
||||||
border-top: 1px solid var(--ft-border-light);
|
border-top: 1px solid var(--ft-border-light);
|
||||||
font-size: 0.83rem;
|
font-size: 0.83rem;
|
||||||
color: var(--ft-text-secondary);
|
color: var(--ft-text-secondary);
|
||||||
|
|
||||||
|
p, ul, ol, blockquote, pre {
|
||||||
|
margin: 0 0 0.6em;
|
||||||
|
}
|
||||||
|
|
||||||
|
> :last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1, h2, h3, h4, h5, h6 {
|
||||||
|
margin: 0.8em 0 0.35em;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--ft-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-size: 1.05rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
> :first-child {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
ul, ol {
|
||||||
|
padding-left: 1.4em;
|
||||||
|
}
|
||||||
|
|
||||||
|
blockquote {
|
||||||
|
padding-left: 10px;
|
||||||
|
border-left: 3px solid var(--ft-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
code {
|
||||||
|
padding: 1px 4px;
|
||||||
|
border-radius: var(--ft-radius-sm);
|
||||||
|
background: var(--ft-bg-hover);
|
||||||
|
color: var(--ft-text);
|
||||||
|
font-size: 0.95em;
|
||||||
|
}
|
||||||
|
|
||||||
|
pre code {
|
||||||
|
display: block;
|
||||||
|
padding: 8px 10px;
|
||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
hr {
|
||||||
|
margin: 0.8em 0;
|
||||||
|
border-color: var(--ft-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
img {
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Řádek suroviny receptu — název se roztáhne, číselná pole mají pevnou šířku
|
// Řádek suroviny receptu — název se roztáhne, číselná pole mají pevnou šířku
|
||||||
.recipe-ingredient-row {
|
.recipe-ingredient-row {
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -153,3 +207,30 @@
|
|||||||
color: var(--ft-primary);
|
color: var(--ft-primary);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Popisek postupu s přepínačem psaní a náhledu
|
||||||
|
.recipe-note-label {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
|
||||||
|
.form-label {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.recipe-note-preview {
|
||||||
|
min-height: 120px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border: 1px solid var(--ft-border);
|
||||||
|
border-radius: var(--ft-radius-sm);
|
||||||
|
background: var(--ft-bg-card);
|
||||||
|
|
||||||
|
.recipe-note {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,186 @@
|
|||||||
|
import type { Content, TDocumentDefinitions } from "pdfmake/interfaces";
|
||||||
|
import { MealGroup, MealPhoto, MealTemplate, getMealPhotos } from "../../types";
|
||||||
|
import { RECIPE_FLAVOR_NAMES } from "./enums";
|
||||||
|
import { convertMarkdownToPdf } from "./markdownPdf";
|
||||||
|
import {
|
||||||
|
deriveRecipe, formatCalories, formatCount, formatDate, formatDateString, formatPrice, formatWeight, getIngredientCalories,
|
||||||
|
} from "./Utils";
|
||||||
|
|
||||||
|
/** Recept s počtem fotek jídla — fotky se stahují jen tam, kde nějaké jsou. */
|
||||||
|
type RecipeItem = Pick<MealGroup, 'photoCount'> & { recipe: MealTemplate };
|
||||||
|
|
||||||
|
const PRIMARY_COLOR = '#15803d';
|
||||||
|
const MUTED_COLOR = '#6b7280';
|
||||||
|
const BORDER_COLOR = '#e5e7eb';
|
||||||
|
/** Šířka tisknutelné plochy A4 při okrajích 40 pt. */
|
||||||
|
const CONTENT_WIDTH = 515;
|
||||||
|
const PHOTO_GAP = 8;
|
||||||
|
const SMALL_PHOTOS_PER_ROW = 3;
|
||||||
|
|
||||||
|
/** pdfmake umí jen JPEG a PNG; fotky se sice ukládají jako JPEG, API ale přijímá i WebP. */
|
||||||
|
const isSupportedImage = (photo: MealPhoto): boolean => /^data:image\/(jpeg|png)/.test(photo.image);
|
||||||
|
|
||||||
|
/** Název souboru bez znaků, které systémy v názvu nepovolí. */
|
||||||
|
const toFileName = (name: string): string => `${name.replace(/[\\/:*?"<>|]+/g, '').trim() || 'recept'}.pdf`;
|
||||||
|
|
||||||
|
const getRecipeMeta = (recipe: MealTemplate): string => {
|
||||||
|
return [
|
||||||
|
recipe.category,
|
||||||
|
RECIPE_FLAVOR_NAMES[recipe.flavor].toLowerCase(),
|
||||||
|
formatCount(recipe.servings ?? 1, 'porce', 'porce', 'porcí'),
|
||||||
|
recipe.weight != null ? `porce ${formatWeight(recipe.weight)}` : undefined,
|
||||||
|
recipe.calories != null ? `${formatCalories(recipe.calories)} na porci` : undefined,
|
||||||
|
recipe.price != null ? `${formatPrice(recipe.price)} za porci` : undefined,
|
||||||
|
].filter(item => !!item).join(' · ');
|
||||||
|
};
|
||||||
|
|
||||||
|
/** První fotka přes celou šířku, ostatní menší v řadách po třech. */
|
||||||
|
const createPhotos = (photoList: MealPhoto[]): Content[] => {
|
||||||
|
const [first, ...rest] = photoList;
|
||||||
|
if (!first) return [];
|
||||||
|
const result: Content[] = [{ image: first.image, fit: [CONTENT_WIDTH, 240], alignment: 'center', margin: [0, 0, 0, PHOTO_GAP] }];
|
||||||
|
const smallWidth = (CONTENT_WIDTH - PHOTO_GAP * (SMALL_PHOTOS_PER_ROW - 1)) / SMALL_PHOTOS_PER_ROW;
|
||||||
|
for (let start = 0; start < rest.length; start += SMALL_PHOTOS_PER_ROW) {
|
||||||
|
const rowList = rest.slice(start, start + SMALL_PHOTOS_PER_ROW);
|
||||||
|
result.push({
|
||||||
|
columns: [
|
||||||
|
...rowList.map(photo => ({ image: photo.image, fit: [smallWidth, 130] as [number, number], width: smallWidth })),
|
||||||
|
// Nedoplněná řada si drží šířku sloupců
|
||||||
|
...Array.from({ length: SMALL_PHOTOS_PER_ROW - rowList.length }, () => ({ text: '', width: smallWidth })),
|
||||||
|
],
|
||||||
|
columnGap: PHOTO_GAP,
|
||||||
|
margin: [0, 0, 0, PHOTO_GAP],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
|
||||||
|
const createIngredients = (recipe: MealTemplate): Content[] => {
|
||||||
|
if (!recipe.ingredients.length) return [];
|
||||||
|
const summary = deriveRecipe(recipe.ingredients, recipe.servings, recipe.cookedWeight);
|
||||||
|
const hasPrice = recipe.ingredients.some(item => item.price != null);
|
||||||
|
const header = ['Surovina', 'Množství', 'Energie', ...(hasPrice ? ['Cena'] : [])]
|
||||||
|
.map((text, index) => ({ text, style: 'tableHeader', alignment: index ? 'right' as const : 'left' as const }));
|
||||||
|
const rowList = recipe.ingredients.map(item => [
|
||||||
|
item.name,
|
||||||
|
{ text: item.weight != null ? formatWeight(item.weight) : '', alignment: 'right' as const },
|
||||||
|
{ text: formatCalories(getIngredientCalories(item)), alignment: 'right' as const },
|
||||||
|
...(hasPrice ? [{ text: item.price != null ? formatPrice(item.price) : '', alignment: 'right' as const }] : []),
|
||||||
|
]);
|
||||||
|
const totalList = [
|
||||||
|
`Celkem ${formatCalories(summary.totalCalories)}`,
|
||||||
|
summary.totalWeight != null ? formatWeight(summary.totalWeight) : undefined,
|
||||||
|
summary.caloriesPer100g != null ? `${summary.caloriesPer100g} kcal/100 g` : undefined,
|
||||||
|
summary.totalPrice != null ? formatPrice(summary.totalPrice) : undefined,
|
||||||
|
].filter(item => !!item);
|
||||||
|
return [
|
||||||
|
{ text: 'Suroviny', style: 'sectionHeading' },
|
||||||
|
{
|
||||||
|
table: {
|
||||||
|
headerRows: 1,
|
||||||
|
widths: ['*', 70, 70, ...(hasPrice ? [70] : [])],
|
||||||
|
body: [header, ...rowList],
|
||||||
|
},
|
||||||
|
layout: {
|
||||||
|
hLineWidth: index => (index === 1 ? 0.8 : 0.4),
|
||||||
|
vLineWidth: () => 0,
|
||||||
|
hLineColor: () => BORDER_COLOR,
|
||||||
|
paddingTop: () => 3,
|
||||||
|
paddingBottom: () => 3,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ text: totalList.join(' · '), style: 'summary' },
|
||||||
|
];
|
||||||
|
};
|
||||||
|
|
||||||
|
const createRecipe = (recipe: MealTemplate, photoList: MealPhoto[], index: number, withToc: boolean): Content => ({
|
||||||
|
stack: [
|
||||||
|
{
|
||||||
|
text: recipe.name,
|
||||||
|
style: 'recipeTitle',
|
||||||
|
id: `recept-${index}`,
|
||||||
|
tocItem: withToc,
|
||||||
|
},
|
||||||
|
...(recipe.source ? [{ text: recipe.source, style: 'recipeSource' }] : []),
|
||||||
|
{ text: getRecipeMeta(recipe), style: 'recipeMeta' },
|
||||||
|
...createPhotos(photoList),
|
||||||
|
...createIngredients(recipe),
|
||||||
|
...(recipe.note?.trim()
|
||||||
|
? [{ text: 'Postup', style: 'sectionHeading' }, ...convertMarkdownToPdf(recipe.note)]
|
||||||
|
: []),
|
||||||
|
],
|
||||||
|
// Každý recept začíná na nové stránce, první hned za obsahem
|
||||||
|
...(index > 0 || withToc ? { pageBreak: 'before' as const } : {}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const loadPhotos = async (item: RecipeItem): Promise<MealPhoto[]> => {
|
||||||
|
if (!item.photoCount) return [];
|
||||||
|
const response = await getMealPhotos({ query: { name: item.recipe.name, source: item.recipe.source } });
|
||||||
|
return (response.data ?? []).filter(isSupportedImage);
|
||||||
|
};
|
||||||
|
|
||||||
|
/** pdfmake je velký (písma), načte se až při prvním exportu. */
|
||||||
|
const loadPdfMake = async () => {
|
||||||
|
const [pdfMakeModule, vfsModule] = await Promise.all([
|
||||||
|
import("pdfmake/build/pdfmake"),
|
||||||
|
import("pdfmake/build/vfs_fonts"),
|
||||||
|
]);
|
||||||
|
// Oba soubory jsou CommonJS/UMD, bundler je vystaví jako default
|
||||||
|
const pdfMake = pdfMakeModule.default;
|
||||||
|
const vfs = vfsModule.default;
|
||||||
|
pdfMake.addVirtualFileSystem(vfs);
|
||||||
|
return pdfMake;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stáhne recepty jako PDF — suroviny, hodnoty porce, postup a fotky jídla.
|
||||||
|
* Víc receptů dostane titulní stránku s klikacím obsahem a každý recept
|
||||||
|
* začíná na nové stránce.
|
||||||
|
*/
|
||||||
|
export const exportRecipesPdf = async (itemList: RecipeItem[]): Promise<void> => {
|
||||||
|
const sortedList = [...itemList].sort((a, b) => a.recipe.name.localeCompare(b.recipe.name, 'cs'));
|
||||||
|
const [pdfMake, photoLists] = await Promise.all([loadPdfMake(), Promise.all(sortedList.map(loadPhotos))]);
|
||||||
|
const withToc = sortedList.length > 1;
|
||||||
|
const title = withToc ? 'Recepty' : sortedList[0]?.recipe.name ?? 'Recept';
|
||||||
|
|
||||||
|
const cover: Content[] = withToc ? [
|
||||||
|
{ text: 'Recepty', style: 'coverTitle' },
|
||||||
|
{ text: `${formatCount(sortedList.length, 'recept', 'recepty', 'receptů')} · ${formatDateString(formatDate(new Date()))}`, style: 'recipeMeta' },
|
||||||
|
{
|
||||||
|
toc: {
|
||||||
|
title: { text: 'Obsah', style: 'sectionHeading' },
|
||||||
|
textMargin: [0, 2, 0, 2],
|
||||||
|
numberStyle: { color: MUTED_COLOR },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
] : [];
|
||||||
|
|
||||||
|
const definition: TDocumentDefinitions = {
|
||||||
|
info: { title },
|
||||||
|
pageSize: 'A4',
|
||||||
|
pageMargins: [40, 40, 40, 50],
|
||||||
|
content: [...cover, ...sortedList.map((item, index) => createRecipe(item.recipe, photoLists[index], index, withToc))],
|
||||||
|
footer: (currentPage, pageCount) => ({
|
||||||
|
text: `${currentPage} / ${pageCount}`,
|
||||||
|
alignment: 'center',
|
||||||
|
fontSize: 8,
|
||||||
|
color: MUTED_COLOR,
|
||||||
|
}),
|
||||||
|
defaultStyle: { font: 'Roboto', fontSize: 10, lineHeight: 1.25 },
|
||||||
|
styles: {
|
||||||
|
coverTitle: { fontSize: 28, bold: true, color: PRIMARY_COLOR, margin: [0, 0, 0, 4] },
|
||||||
|
recipeTitle: { fontSize: 20, bold: true, color: PRIMARY_COLOR },
|
||||||
|
recipeSource: { fontSize: 11, color: MUTED_COLOR },
|
||||||
|
recipeMeta: { fontSize: 9, color: MUTED_COLOR, margin: [0, 4, 0, 12] },
|
||||||
|
sectionHeading: { fontSize: 13, bold: true, margin: [0, 10, 0, 6] },
|
||||||
|
tableHeader: { bold: true, fontSize: 9, color: MUTED_COLOR },
|
||||||
|
summary: { bold: true, fontSize: 9, color: MUTED_COLOR, margin: [0, 6, 0, 0] },
|
||||||
|
noteHeading: { fontSize: 12, bold: true, margin: [0, 8, 0, 4] },
|
||||||
|
noteSubheading: { fontSize: 10.5, bold: true, margin: [0, 6, 0, 3] },
|
||||||
|
noteParagraph: { margin: [0, 0, 0, 6] },
|
||||||
|
noteList: { margin: [0, 0, 0, 6] },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
await pdfMake.createPdf(definition).download(withToc ? 'recepty.pdf' : toFileName(title));
|
||||||
|
};
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { toast } from "react-toastify";
|
||||||
|
import { exportRecipesPdf } from "./recipePdf";
|
||||||
|
|
||||||
|
/** Export receptů do PDF s příznakem probíhajícího exportu a hláškou při chybě. */
|
||||||
|
export const useRecipeExport = () => {
|
||||||
|
const [exporting, setExporting] = useState(false);
|
||||||
|
|
||||||
|
const exportRecipes = async (itemList: Parameters<typeof exportRecipesPdf>[0]) => {
|
||||||
|
if (exporting || !itemList.length) return;
|
||||||
|
setExporting(true);
|
||||||
|
try {
|
||||||
|
await exportRecipesPdf(itemList);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Export receptů do PDF selhal', error);
|
||||||
|
toast.error('PDF se nepodařilo vytvořit.');
|
||||||
|
} finally {
|
||||||
|
setExporting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return { exporting, exportRecipes };
|
||||||
|
};
|
||||||
+855
-4
File diff suppressed because it is too large.
Load diff
Reference in new issue
Block a user