feat: export + detail receptů

feat: losování jídel
This commit is contained in:
Ondřej Anděl committed 2026-10-16 12:55:32 +02:00
1 parent 35cbc5a9ca
commit caffb6d8b4
23 files changed
+2652 -21

No files matched your search

+24 -1
View File
@@ -82,7 +82,7 @@ cd client && yarn build # tsc --noEmit + vite build → client/dist
`IntakeStatsPanel`, `ActivityStatsPanel`, `SpendingStatsPanel`)
- **Components:** `components/` (Header, CalorieLookup, MealSuggestions, NutrientsFieldset)
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ů,
ne jako inline union u komponenty (`DayTab`, `StatsTab`, `StatsRange`)
- **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`)
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
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
+5
View File
@@ -10,15 +10,20 @@
"@fortawesome/free-regular-svg-icons": "^7.1.0",
"@fortawesome/free-solid-svg-icons": "^7.1.0",
"@fortawesome/react-fontawesome": "^3.1.0",
"@types/mdast": "4.0.4",
"@types/node": "^24.10.0",
"@types/pdfmake": "0.3.3",
"@types/react": "^19.2.2",
"@types/react-dom": "^19.2.2",
"@vitejs/plugin-react": "^5.1.0",
"bootstrap": "^5.3.8",
"mdast-util-from-markdown": "2.0.3",
"pdfmake": "0.3.11",
"react": "^19.2.0",
"react-bootstrap": "^2.10.10",
"react-dom": "^19.2.0",
"react-jwt": "^1.3.0",
"react-markdown": "10.1.0",
"react-router": "^7.9.5",
"react-router-dom": "^7.9.5",
"react-toastify": "^11.0.5",
+6
View File
@@ -268,6 +268,12 @@ body {
color: var(--ft-text-secondary);
font-size: 0.9rem;
}
.page-header-actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
}
// ============================================
+6
View File
@@ -410,3 +410,9 @@ export const parseNutrientInput = (value: string): number | undefined => {
/** Převede gramy živiny na text pro editační pole. */
export const formatNutrientInput = (grams?: number): string =>
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}`;
};
+12 -1
View File
@@ -2,11 +2,12 @@ import { useState } from "react";
import { Navbar, Nav, NavDropdown } from "react-bootstrap";
import { Link, useLocation } from "react-router";
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 { useSettings, ThemePreference } from "../context/settings";
import { DEN_URL, JIDLA_URL, PROFIL_URL, STATISTIKY_URL, TRENINKY_URL } from "../routes";
import SettingsModal from "./modals/SettingsModal";
import SpinWheelModal from "./modals/SpinWheelModal";
/** Položky hlavní navigace. */
const NAV_ITEMS = [
@@ -19,6 +20,7 @@ const NAV_ITEMS = [
export default function Header() {
const [settingsOpen, setSettingsOpen] = useState(false);
const [wheelOpen, setWheelOpen] = useState(false);
const auth = useAuth();
const settings = useSettings();
const location = useLocation();
@@ -49,6 +51,14 @@ export default function Header() {
{item.label}
</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
className="theme-toggle"
onClick={toggleTheme}
@@ -67,6 +77,7 @@ export default function Header() {
</Nav>
</Navbar.Collapse>
<SettingsModal isOpen={settingsOpen} onClose={() => setSettingsOpen(false)} />
{wheelOpen && <SpinWheelModal isOpen onClose={() => setWheelOpen(false)} />}
</Navbar>
);
}
+4 -1
View File
@@ -2,6 +2,7 @@ import { useState } from "react";
import { MealGroup, deleteMealTemplate } from "../../../types";
import MealTemplateCard from "./MealTemplateCard";
import MealTemplateForm from "./MealTemplateForm";
import { useRecipeExport } from "../useRecipeExport";
type Props = {
group: MealGroup,
@@ -17,6 +18,7 @@ type Props = {
const MealGroupRecipe = ({ group, onChanged, onClose }: Readonly<Props>) => {
const [editing, setEditing] = useState(!group.recipe);
const [busy, setBusy] = useState(false);
const { exporting, exportRecipes } = useRecipeExport();
const remove = async (id: string) => {
setBusy(true);
@@ -30,7 +32,8 @@ const MealGroupRecipe = ({ group, onChanged, onClose }: Readonly<Props>) => {
{group.recipe && !editing && (
<MealTemplateCard
template={group.recipe}
busy={busy}
busy={busy || exporting}
onExport={() => exportRecipes([{ recipe: group.recipe!, photoCount: group.photoCount }])}
onEdit={() => setEditing(true)}
onDelete={() => remove(group.recipe!.id)}
/>
+26 -8
View File
@@ -1,21 +1,24 @@
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 { 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 = {
template: MealTemplate,
busy: boolean,
onEdit: () => void,
onDelete: () => void,
busy?: boolean,
/** Bez úpravy a smazání je karta jen k nahlédnutí (kolo štěstí). */
onEdit?: () => void,
onDelete?: () => void,
onExport?: () => void,
};
/**
* 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.
*/
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);
return (
@@ -27,7 +30,7 @@ const MealTemplateCard = ({ template, busy, onEdit, onDelete }: Readonly<Props>)
<span className="tag">{RECIPE_FLAVOR_NAMES[template.flavor].toLowerCase()}</span>
{template.weight != null && <span className="tag">porce {formatWeight(template.weight)}</span>}
{(template.servings ?? 1) > 1 && (
<span className="tag">{template.servings} porcí</span>
<span className="tag">{formatCount(template.servings ?? 1, 'porce', 'porce', 'porcí')}</span>
)}
</div>
{template.calories != null && (
@@ -36,9 +39,23 @@ const MealTemplateCard = ({ template, busy, onEdit, onDelete }: Readonly<Props>)
{template.price != null && (
<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">
<FontAwesomeIcon icon={faPenToSquare} />
</button>
)}
{onDelete && (
<button
className="btn-icon danger"
onClick={() => onDelete()}
@@ -48,6 +65,7 @@ const MealTemplateCard = ({ template, busy, onEdit, onDelete }: Readonly<Props>)
>
<FontAwesomeIcon icon={faTrash} />
</button>
)}
</div>
{!!template.ingredients.length && (
@@ -74,7 +92,7 @@ const MealTemplateCard = ({ template, busy, onEdit, onDelete }: Readonly<Props>)
{recipe.totalPrice != null && <> · {formatPrice(recipe.totalPrice)}</>}
</div>
{template.note && <p className="meal-template-note">{template.note}</p>}
{template.note && <RecipeNote note={template.note} />}
</div>
);
};
+36 -2
View File
@@ -5,6 +5,7 @@ import { RECIPE_FLAVOR_NAMES } from "../enums";
import { deriveRecipe, formatCalories, formatPrice, formatWeight, parseIntInput } from "../Utils";
import SourceInput from "./SourceInput";
import RecipeCategoryInput from "./RecipeCategoryInput";
import RecipeNote from "./RecipeNote";
import RecipeIngredientsEditor, {
DraftIngredient, emptyIngredient, toDraftIngredient, toIngredient,
} from "./RecipeIngredientsEditor";
@@ -21,6 +22,9 @@ type Props = {
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ý. */
const createForm = (template?: MealTemplate, meal?: Props['meal']) => ({
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()]);
const [error, setError] = useState<string | undefined>();
const [busy, setBusy] = useState(false);
const [notePreview, setNotePreview] = useState(false);
const flavorId = useId();
const update = (changes: Partial<ReturnType<typeof createForm>>) =>
@@ -178,14 +183,43 @@ const MealTemplateForm = ({ template, meal, onSaved, onCancel }: Readonly<Props>
</fieldset>
<Form.Group className="mb-3">
<div className="recipe-note-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
as="textarea"
rows={6}
rows={8}
value={form.note}
placeholder="Jak se jídlo připravuje…"
placeholder={NOTE_PLACEHOLDER}
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>
<div className="workout-form-footer">
+20
View File
@@ -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;
+99
View File
@@ -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;
+154
View File
@@ -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); }
}
+170
View File
@@ -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;
+41
View File
@@ -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;
+11
View File
@@ -212,3 +212,14 @@ export const STYLE_THEME_NAMES: Record<StyleTheme, string> = {
JEDNODUCHY: 'Jednoduchý',
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',
};
+37
View File
@@ -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);
};
+85
View File
@@ -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);
+20 -2
View File
@@ -2,12 +2,13 @@ import { useCallback, useEffect, useMemo, useState } from "react";
import { Button, Form, Spinner } from "react-bootstrap";
import { useLocation } from "react-router";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faBookOpen, faCircleCheck } from "@fortawesome/free-solid-svg-icons";
import { MealGroup, getMealGroups } from "../../../types";
import { faBookOpen, faCircleCheck, faFilePdf } from "@fortawesome/free-solid-svg-icons";
import { MealGroup, MealTemplate, getMealGroups } from "../../../types";
import { MealGroupAction, RECIPE_FILTER_NAMES, RecipeFilter, SourceFilter } from "../enums";
import { getMealKey, normalizeSearch } from "../Utils";
import MealGroupRow from "../components/MealGroupRow";
import MealTemplateForm from "../components/MealTemplateForm";
import { useRecipeExport } from "../useRecipeExport";
import "./templates.scss";
import "./MealGroupsPage.scss";
@@ -73,6 +74,7 @@ const MealGroupsPage = () => {
(location.state as LocationState)?.recipeFilter ?? RecipeFilter.VSE);
const [opened, setOpened] = useState<{ key: string, action: MealGroupAction } | undefined>();
const [newRecipeOpen, setNewRecipeOpen] = useState(false);
const { exporting, exportRecipes } = useRecipeExport();
const load = useCallback(async () => {
const response = await getMealGroups();
@@ -93,6 +95,11 @@ const MealGroupsPage = () => {
&& (!search.length || matchesSearch(group, search)));
}, [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;
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
</p>
</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)}>
<FontAwesomeIcon icon={faBookOpen} /> Nový recept
</Button>
</div>
</div>
{newRecipeOpen && (
<MealTemplateForm
+83 -2
View File
@@ -92,14 +92,68 @@
white-space: nowrap;
}
// Postup přípravy si drží zalomení řádků tak, jak ho uživatel zapsal
.meal-template-note {
// Postup přípravy v Markdownu
.recipe-note {
margin: 10px 0 0;
padding-top: 10px;
border-top: 1px solid var(--ft-border-light);
font-size: 0.83rem;
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;
}
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
@@ -153,3 +207,30 @@
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;
}
}
+186
View File
@@ -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));
};
+23
View File
@@ -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
View File
File diff suppressed because it is too large. Load diff