fix: recepty přesnuty k jídlům
feat: přidána podpora fotek
This commit is contained in:
1 parent
efed5e6e86
commit
e13f7ce8e5
39 files changed
+1266
-511
No files matched your search
@@ -5,10 +5,10 @@ import Header from "./components/Header";
|
||||
import Login from "./Login";
|
||||
import DayPage from "./pages/DayPage";
|
||||
import StatsPage from "./pages/StatsPage";
|
||||
import TemplatesPage from "./pages/TemplatesPage";
|
||||
import WorkoutsPage from "./pages/WorkoutsPage";
|
||||
import ProfilePage from "./pages/ProfilePage";
|
||||
import MealGroupsPage from "./pages/MealGroupsPage";
|
||||
import { DEN_URL, JIDLA_URL, PROFIL_URL, SABLONY_URL, STATISTIKY_URL } from "./routes";
|
||||
import { DEN_URL, JIDLA_URL, PROFIL_URL, STATISTIKY_URL, TRENINKY_URL } from "./routes";
|
||||
|
||||
export default function AppRoutes() {
|
||||
const auth = useAuth();
|
||||
@@ -25,7 +25,7 @@ export default function AppRoutes() {
|
||||
<Route path={DEN_URL} element={<DayPage />} />
|
||||
<Route path={JIDLA_URL} element={<MealGroupsPage />} />
|
||||
<Route path={STATISTIKY_URL} element={<StatsPage />} />
|
||||
<Route path={SABLONY_URL} element={<TemplatesPage />} />
|
||||
<Route path={TRENINKY_URL} element={<WorkoutsPage />} />
|
||||
<Route path={PROFIL_URL} element={<ProfilePage />} />
|
||||
<Route path="*" element={<Navigate to={DEN_URL} replace />} />
|
||||
</Routes>
|
||||
|
||||
@@ -5,14 +5,14 @@ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { faSun, faMoon, faGear } from "@fortawesome/free-solid-svg-icons";
|
||||
import { useAuth } from "../context/auth";
|
||||
import { useSettings, ThemePreference } from "../context/settings";
|
||||
import { DEN_URL, JIDLA_URL, PROFIL_URL, SABLONY_URL, STATISTIKY_URL } from "../routes";
|
||||
import { DEN_URL, JIDLA_URL, PROFIL_URL, STATISTIKY_URL, TRENINKY_URL } from "../routes";
|
||||
import SettingsModal from "./modals/SettingsModal";
|
||||
|
||||
/** Položky hlavní navigace. */
|
||||
const NAV_ITEMS = [
|
||||
{ url: DEN_URL, label: 'Můj den' },
|
||||
{ url: JIDLA_URL, label: 'Jídla' },
|
||||
{ url: SABLONY_URL, label: 'Šablony' },
|
||||
{ url: TRENINKY_URL, label: 'Tréninky' },
|
||||
{ url: STATISTIKY_URL, label: 'Statistiky' },
|
||||
{ url: PROFIL_URL, label: 'Profil' },
|
||||
];
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Button, Modal, Spinner } from "react-bootstrap";
|
||||
import { toast } from "react-toastify";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { faCamera, faTrash } from "@fortawesome/free-solid-svg-icons";
|
||||
import { MealGroup, MealPhoto, addMealPhoto, deleteMealPhoto, getMealPhotos } from "../../../types";
|
||||
import { prepareMealPhoto } from "../imageResize";
|
||||
|
||||
type Props = {
|
||||
group: MealGroup,
|
||||
/** Zavolá se po přidání nebo smazání fotky, aby se v seznamu obnovil náhled a počet. */
|
||||
onChanged: () => void,
|
||||
};
|
||||
|
||||
/**
|
||||
* Fotky jídla — patří jídlu jako celku (názvu a zdroji), ne dni, kdy se jedlo.
|
||||
* Fotky se před nahráním zmenší v prohlížeči.
|
||||
*/
|
||||
const MealGroupPhotos = ({ group, onChanged }: Readonly<Props>) => {
|
||||
const [photoList, setPhotoList] = useState<MealPhoto[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [shown, setShown] = useState<MealPhoto | undefined>();
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
getMealPhotos({ query: { name: group.name, source: group.source } }).then(response => {
|
||||
setPhotoList(response.data ?? []);
|
||||
setLoading(false);
|
||||
});
|
||||
}, [group.name, group.source]);
|
||||
|
||||
const upload = async (fileList: FileList | null) => {
|
||||
if (!fileList?.length) return;
|
||||
setBusy(true);
|
||||
const addedList: MealPhoto[] = [];
|
||||
for (const file of Array.from(fileList)) {
|
||||
try {
|
||||
const prepared = await prepareMealPhoto(file);
|
||||
const response = await addMealPhoto({ body: { name: group.name, source: group.source, ...prepared } });
|
||||
if (response.data) addedList.push(response.data);
|
||||
} catch {
|
||||
toast.error(`Soubor ${file.name} se nepodařilo načíst jako obrázek.`);
|
||||
}
|
||||
}
|
||||
setBusy(false);
|
||||
if (inputRef.current) inputRef.current.value = '';
|
||||
if (addedList.length) {
|
||||
setPhotoList(current => [...current, ...addedList]);
|
||||
onChanged();
|
||||
}
|
||||
};
|
||||
|
||||
const remove = async (photo: MealPhoto) => {
|
||||
setBusy(true);
|
||||
const response = await deleteMealPhoto({ body: { id: photo.id } });
|
||||
setBusy(false);
|
||||
if (!response.error) {
|
||||
setPhotoList(current => current.filter(item => item.id !== photo.id));
|
||||
setShown(undefined);
|
||||
onChanged();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="meal-group-form">
|
||||
{loading && <Spinner animation="border" variant="success" size="sm" />}
|
||||
{!loading && (
|
||||
<div className="meal-photos">
|
||||
{photoList.map(photo => (
|
||||
<button
|
||||
type="button"
|
||||
key={photo.id}
|
||||
className="meal-photo"
|
||||
onClick={() => setShown(photo)}
|
||||
title="Zobrazit"
|
||||
>
|
||||
<img src={photo.thumbnail} alt={group.name} />
|
||||
</button>
|
||||
))}
|
||||
<label className={`meal-photo meal-photo-add ${busy ? 'disabled' : ''}`} title="Přidat fotky">
|
||||
{busy ? <Spinner animation="border" size="sm" /> : <FontAwesomeIcon icon={faCamera} />}
|
||||
<span>Přidat</span>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
multiple
|
||||
hidden
|
||||
disabled={busy}
|
||||
onChange={event => upload(event.target.files)}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Modal show={!!shown} onHide={() => setShown(undefined)} centered size="lg">
|
||||
<Modal.Header closeButton>
|
||||
<Modal.Title>{group.name}</Modal.Title>
|
||||
</Modal.Header>
|
||||
<Modal.Body className="text-center">
|
||||
{shown && <img src={shown.image} alt={group.name} className="meal-photo-full" />}
|
||||
</Modal.Body>
|
||||
<Modal.Footer>
|
||||
<Button variant="outline-danger" disabled={busy} onClick={() => shown && remove(shown)}>
|
||||
<FontAwesomeIcon icon={faTrash} /> Smazat fotku
|
||||
</Button>
|
||||
<Button variant="outline-secondary" onClick={() => setShown(undefined)}>Zavřít</Button>
|
||||
</Modal.Footer>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default MealGroupPhotos;
|
||||
@@ -0,0 +1,50 @@
|
||||
import { useState } from "react";
|
||||
import { MealGroup, deleteMealTemplate } from "../../../types";
|
||||
import MealTemplateCard from "./MealTemplateCard";
|
||||
import MealTemplateForm from "./MealTemplateForm";
|
||||
|
||||
type Props = {
|
||||
group: MealGroup,
|
||||
/** Zavolá se po uložení nebo smazání receptu, aby se seznam načetl znovu. */
|
||||
onChanged: () => void,
|
||||
onClose: () => void,
|
||||
};
|
||||
|
||||
/**
|
||||
* Recept jídla v seznamu jídel. Recept rozšiřuje jídlo (název a zdroj) o suroviny
|
||||
* a postup; jídlo bez receptu nabídne rovnou formulář na jeho založení.
|
||||
*/
|
||||
const MealGroupRecipe = ({ group, onChanged, onClose }: Readonly<Props>) => {
|
||||
const [editing, setEditing] = useState(!group.recipe);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const remove = async (id: string) => {
|
||||
setBusy(true);
|
||||
const response = await deleteMealTemplate({ body: { id } });
|
||||
setBusy(false);
|
||||
if (response.data) onChanged();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="meal-group-form meal-group-recipe">
|
||||
{group.recipe && !editing && (
|
||||
<MealTemplateCard
|
||||
template={group.recipe}
|
||||
busy={busy}
|
||||
onEdit={() => setEditing(true)}
|
||||
onDelete={() => remove(group.recipe!.id)}
|
||||
/>
|
||||
)}
|
||||
{editing && (
|
||||
<MealTemplateForm
|
||||
template={group.recipe}
|
||||
meal={group}
|
||||
onSaved={() => onChanged()}
|
||||
onCancel={() => group.recipe ? setEditing(false) : onClose()}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default MealGroupRecipe;
|
||||
@@ -1,13 +1,15 @@
|
||||
import { useState } from "react";
|
||||
import { Button } from "react-bootstrap";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { faCalendarDays, faPen, faPenToSquare } from "@fortawesome/free-solid-svg-icons";
|
||||
import { faBookOpen, faCalendarDays, faCamera, faPen, faPenToSquare } from "@fortawesome/free-solid-svg-icons";
|
||||
import { MealGroup } from "../../../types";
|
||||
import { MealGroupAction } from "../enums";
|
||||
import { formatDateString } from "../Utils";
|
||||
import MealGroupForm from "./MealGroupForm";
|
||||
import MealGroupRenameForm from "./MealGroupRenameForm";
|
||||
import MealGroupDays from "./MealGroupDays";
|
||||
import MealGroupRecipe from "./MealGroupRecipe";
|
||||
import MealGroupPhotos from "./MealGroupPhotos";
|
||||
|
||||
type Props = {
|
||||
group: MealGroup,
|
||||
@@ -16,8 +18,10 @@ type Props = {
|
||||
/** Rozbalená akce; nejvýš jeden řádek seznamu má nějakou otevřenou. */
|
||||
action?: MealGroupAction,
|
||||
onAction: (action?: MealGroupAction) => void,
|
||||
/** Zavolá se po doplnění nebo přejmenování, aby se seznam načetl znovu. */
|
||||
/** Zavolá se po doplnění, přejmenování nebo změně receptu — seznam se načte znovu a akce zavře. */
|
||||
onChanged: () => void,
|
||||
/** Obnoví seznam a otevřenou akci nechá — po změně fotek, aby šlo přidávat dál. */
|
||||
onRefresh: () => void,
|
||||
};
|
||||
|
||||
/** Kolik jídel skupiny daný údaj nemá — "u všech", nebo "3 z 5". */
|
||||
@@ -26,20 +30,35 @@ const describeMissing = (missing: number, count: number): string =>
|
||||
|
||||
/**
|
||||
* Řádek skupiny jídel (stejný název a zdroj) s přehledem chybějících údajů.
|
||||
* Nabízí doplnění údajů a změnu názvu a zdroje všem jídlům skupiny a dny,
|
||||
* kdy se jídlo jedlo, jako odkazy na přehled dne.
|
||||
* Nabízí recept a fotky jídla, doplnění údajů a změnu názvu a zdroje všem
|
||||
* jídlům skupiny a dny, kdy se jídlo jedlo, jako odkazy na přehled dne.
|
||||
*/
|
||||
const MealGroupRow = ({ group, groupList, action, onAction, onChanged }: Readonly<Props>) => {
|
||||
const MealGroupRow = ({ group, groupList, action, onAction, onChanged, onRefresh }: Readonly<Props>) => {
|
||||
const [daysOpen, setDaysOpen] = useState(false);
|
||||
const toggle = (value: MealGroupAction) => onAction(action === value ? undefined : value);
|
||||
return (
|
||||
<div className="card-panel meal-group">
|
||||
<div className="meal-group-head">
|
||||
{group.thumbnail && (
|
||||
<button
|
||||
type="button"
|
||||
className="meal-group-thumb"
|
||||
title="Fotky jídla"
|
||||
onClick={() => toggle(MealGroupAction.FOTKY)}
|
||||
>
|
||||
<img src={group.thumbnail} alt={group.name} />
|
||||
</button>
|
||||
)}
|
||||
<div className="meal-group-info">
|
||||
<div className="meal-group-name">{group.name}</div>
|
||||
<div className="meal-meta">
|
||||
{group.source && <span className="tag">{group.source}</span>}
|
||||
<span className="tag">{group.count}× · naposledy {formatDateString(group.lastDate)}</span>
|
||||
{group.lastDate
|
||||
? <span className="tag">{group.count}× · naposledy {formatDateString(group.lastDate)}</span>
|
||||
: <span className="tag">zatím nejedeno</span>}
|
||||
{group.recipe && (
|
||||
<span className="tag tag-recipe"><FontAwesomeIcon icon={faBookOpen} /> recept</span>
|
||||
)}
|
||||
{group.missingCaloriesCount > 0 && (
|
||||
<span className="tag tag-missing">
|
||||
bez kalorií {describeMissing(group.missingCaloriesCount, group.count)}
|
||||
@@ -58,7 +77,7 @@ const MealGroupRow = ({ group, groupList, action, onAction, onChanged }: Readonl
|
||||
</div>
|
||||
</div>
|
||||
<div className="meal-group-actions">
|
||||
{!action && (
|
||||
{!action && group.count > 0 && (
|
||||
<Button
|
||||
variant="outline-secondary"
|
||||
size="sm"
|
||||
@@ -68,6 +87,24 @@ const MealGroupRow = ({ group, groupList, action, onAction, onChanged }: Readonl
|
||||
<FontAwesomeIcon icon={faCalendarDays} /> Dny
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="outline-secondary"
|
||||
size="sm"
|
||||
active={action === MealGroupAction.RECEPT}
|
||||
title={group.recipe ? 'Zobrazit recept' : 'Zapsat k jídlu recept'}
|
||||
onClick={() => toggle(MealGroupAction.RECEPT)}
|
||||
>
|
||||
<FontAwesomeIcon icon={faBookOpen} /> Recept
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline-secondary"
|
||||
size="sm"
|
||||
active={action === MealGroupAction.FOTKY}
|
||||
title="Fotky jídla"
|
||||
onClick={() => toggle(MealGroupAction.FOTKY)}
|
||||
>
|
||||
<FontAwesomeIcon icon={faCamera} /> {group.photoCount || ''}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline-secondary"
|
||||
size="sm"
|
||||
@@ -77,19 +114,26 @@ const MealGroupRow = ({ group, groupList, action, onAction, onChanged }: Readonl
|
||||
>
|
||||
<FontAwesomeIcon icon={faPen} /> Přejmenovat
|
||||
</Button>
|
||||
<Button
|
||||
variant={action === MealGroupAction.DOPLNIT ? 'outline-secondary' : 'primary'}
|
||||
size="sm"
|
||||
onClick={() => toggle(MealGroupAction.DOPLNIT)}
|
||||
>
|
||||
<FontAwesomeIcon icon={faPenToSquare} /> {action === MealGroupAction.DOPLNIT ? 'Zavřít' : 'Doplnit'}
|
||||
</Button>
|
||||
{group.count > 0 && (
|
||||
<Button
|
||||
variant={action === MealGroupAction.DOPLNIT ? 'outline-secondary' : 'primary'}
|
||||
size="sm"
|
||||
onClick={() => toggle(MealGroupAction.DOPLNIT)}
|
||||
>
|
||||
<FontAwesomeIcon icon={faPenToSquare} />
|
||||
{' '}{action === MealGroupAction.DOPLNIT ? 'Zavřít' : 'Doplnit'}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{(action === MealGroupAction.DOPLNIT || (!action && daysOpen)) && <MealGroupDays days={group.days} />}
|
||||
{action === MealGroupAction.DOPLNIT && (
|
||||
<MealGroupForm group={group} onFilled={onChanged} onCancel={() => onAction(undefined)} />
|
||||
)}
|
||||
{action === MealGroupAction.RECEPT && (
|
||||
<MealGroupRecipe group={group} onChanged={onChanged} onClose={() => onAction(undefined)} />
|
||||
)}
|
||||
{action === MealGroupAction.FOTKY && <MealGroupPhotos group={group} onChanged={onRefresh} />}
|
||||
{action === MealGroupAction.PREJMENOVAT && (
|
||||
<MealGroupRenameForm
|
||||
group={group}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { faPenToSquare, faTrash } from "@fortawesome/free-solid-svg-icons";
|
||||
import { MealTemplate } from "../../../types";
|
||||
import { MEAL_TYPE_ICONS, MEAL_TYPE_NAMES } from "../enums";
|
||||
import { deriveRecipe, formatCalories, formatPrice, formatWeight, getIngredientCalories } from "../Utils";
|
||||
|
||||
type Props = {
|
||||
@@ -12,8 +11,8 @@ type Props = {
|
||||
};
|
||||
|
||||
/**
|
||||
* Karta receptu na stránce Šablony — suroviny, hodnoty celého hrnce i jedné
|
||||
* porce a postup.
|
||||
* 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 recipe = deriveRecipe(template.ingredients, template.servings, template.cookedWeight);
|
||||
@@ -22,13 +21,7 @@ const MealTemplateCard = ({ template, busy, onEdit, onDelete }: Readonly<Props>)
|
||||
<div className="card-panel template-card">
|
||||
<div className="template-head">
|
||||
<div className="template-title">
|
||||
{template.name}
|
||||
{template.mealType && (
|
||||
<span className="tag">
|
||||
{MEAL_TYPE_ICONS[template.mealType]} {MEAL_TYPE_NAMES[template.mealType]}
|
||||
</span>
|
||||
)}
|
||||
{template.source && <span className="tag">{template.source}</span>}
|
||||
Recept
|
||||
{template.weight != null && <span className="tag">porce {formatWeight(template.weight)}</span>}
|
||||
{(template.servings ?? 1) > 1 && (
|
||||
<span className="tag">{template.servings} porcí</span>
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { useState } from "react";
|
||||
import { Alert, Button, Form } from "react-bootstrap";
|
||||
import { MealTemplate, MealType, saveMealTemplate } from "../../../types";
|
||||
import { MEAL_TYPES_IN_ORDER, MEAL_TYPE_ICONS, MEAL_TYPE_NAMES } from "../enums";
|
||||
import { MealTemplate, saveMealTemplate } from "../../../types";
|
||||
import { deriveRecipe, formatCalories, formatPrice, formatWeight, parseIntInput } from "../Utils";
|
||||
import SourceInput from "./SourceInput";
|
||||
import RecipeIngredientsEditor, {
|
||||
@@ -11,15 +10,19 @@ import RecipeIngredientsEditor, {
|
||||
type Props = {
|
||||
/** Upravovaný recept. Pokud chybí, zakládá se nový. */
|
||||
template?: MealTemplate,
|
||||
/**
|
||||
* Jídlo ze seznamu jídel, ke kterému recept patří. Název a zdroj pak nejdou
|
||||
* měnit — mění se přejmenováním jídla, aby recept zůstal u svých záznamů.
|
||||
*/
|
||||
meal?: { name: string, source?: string },
|
||||
onSaved: (templates: MealTemplate[]) => void,
|
||||
onCancel: () => void,
|
||||
};
|
||||
|
||||
/** Naplní formulář z receptu, nebo ho nechá prázdný pro nový. */
|
||||
const createForm = (template?: MealTemplate) => ({
|
||||
name: template?.name ?? '',
|
||||
source: template?.source ?? '',
|
||||
mealType: (template?.mealType ?? '') as MealType | '',
|
||||
const createForm = (template?: MealTemplate, meal?: Props['meal']) => ({
|
||||
name: meal?.name ?? template?.name ?? '',
|
||||
source: meal?.source ?? template?.source ?? '',
|
||||
note: template?.note ?? '',
|
||||
servings: template?.servings != null ? String(template.servings) : '',
|
||||
cookedWeight: template?.cookedWeight != null ? String(template.cookedWeight) : '',
|
||||
@@ -29,8 +32,8 @@ const createForm = (template?: MealTemplate) => ({
|
||||
* Formulář receptu — vařeného jídla. Zapisují se suroviny a postup a hodnoty
|
||||
* porce se z nich dopočtou — stejně jako na serveru, tady jen jako živý náhled.
|
||||
*/
|
||||
const MealTemplateForm = ({ template, onSaved, onCancel }: Readonly<Props>) => {
|
||||
const [form, setForm] = useState(() => createForm(template));
|
||||
const MealTemplateForm = ({ template, meal, onSaved, onCancel }: Readonly<Props>) => {
|
||||
const [form, setForm] = useState(() => createForm(template, meal));
|
||||
const [ingredients, setIngredients] = useState<DraftIngredient[]>(() =>
|
||||
template?.ingredients?.length ? template.ingredients.map(toDraftIngredient) : [emptyIngredient()]);
|
||||
const [error, setError] = useState<string | undefined>();
|
||||
@@ -54,7 +57,6 @@ const MealTemplateForm = ({ template, onSaved, onCancel }: Readonly<Props>) => {
|
||||
...(template ? { id: template.id } : {}),
|
||||
name: form.name.trim(),
|
||||
...(form.source.trim() ? { source: form.source.trim() } : {}),
|
||||
...(form.mealType ? { mealType: form.mealType } : {}),
|
||||
...(form.note.trim() ? { note: form.note.trim() } : {}),
|
||||
ingredients: filledIngredients,
|
||||
servings: parseIntInput(form.servings),
|
||||
@@ -75,38 +77,26 @@ const MealTemplateForm = ({ template, onSaved, onCancel }: Readonly<Props>) => {
|
||||
|
||||
{error && <Alert variant="danger" className="py-2 small">{error}</Alert>}
|
||||
|
||||
<div className="row g-3 mb-3">
|
||||
<div className="col-sm-5">
|
||||
<Form.Label>Název</Form.Label>
|
||||
<Form.Control
|
||||
value={form.name}
|
||||
placeholder="Např. Kuřecí rizoto"
|
||||
onChange={event => update({ name: event.target.value })}
|
||||
/>
|
||||
{!meal && (
|
||||
<div className="row g-3 mb-3">
|
||||
<div className="col-sm-7">
|
||||
<Form.Label>Název</Form.Label>
|
||||
<Form.Control
|
||||
value={form.name}
|
||||
placeholder="Např. Kuřecí rizoto"
|
||||
onChange={event => update({ name: event.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="col-sm-5">
|
||||
<Form.Label>Zdroj</Form.Label>
|
||||
<SourceInput
|
||||
value={form.source}
|
||||
placeholder="doma"
|
||||
onChange={source => update({ source })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-sm-4">
|
||||
<Form.Label>Zdroj</Form.Label>
|
||||
<SourceInput
|
||||
value={form.source}
|
||||
placeholder="doma"
|
||||
onChange={source => update({ source })}
|
||||
/>
|
||||
</div>
|
||||
<div className="col-sm-3">
|
||||
<Form.Label>Chod</Form.Label>
|
||||
<Form.Select
|
||||
value={form.mealType}
|
||||
onChange={event => update({ mealType: event.target.value as MealType | '' })}
|
||||
>
|
||||
<option value="">— vybrat při použití —</option>
|
||||
{MEAL_TYPES_IN_ORDER.map(type => (
|
||||
<option key={type} value={type}>
|
||||
{MEAL_TYPE_ICONS[type]} {MEAL_TYPE_NAMES[type]}
|
||||
</option>
|
||||
))}
|
||||
</Form.Select>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<fieldset className="meal-fieldset mb-3">
|
||||
<legend>Suroviny</legend>
|
||||
@@ -168,9 +158,7 @@ const MealTemplateForm = ({ template, onSaved, onCancel }: Readonly<Props>) => {
|
||||
{recipe.calories != null && (
|
||||
<span className="workout-draft-calories">porce {formatCalories(recipe.calories)}</span>
|
||||
)}
|
||||
{template && (
|
||||
<Button variant="outline-secondary" size="sm" onClick={() => onCancel()}>Zrušit úpravu</Button>
|
||||
)}
|
||||
<Button variant="outline-secondary" size="sm" onClick={() => onCancel()}>Zrušit</Button>
|
||||
<Button size="sm" onClick={() => save()} disabled={!canSave}>
|
||||
{template ? 'Uložit změny' : 'Vytvořit recept'}
|
||||
</Button>
|
||||
|
||||
@@ -123,7 +123,7 @@ export default function ActivityModal({ isOpen, date, entry, onClose, onSaved }:
|
||||
? response.data.activities.entries.find(item => item.id === entry.id)
|
||||
: [...response.data.activities.entries].sort((a, b) => a.createdAt.localeCompare(b.createdAt)).pop();
|
||||
if (saved) {
|
||||
// Šablona z jednoho cviku; víc jich jde spojit na stránce Šablony
|
||||
// Šablona z jednoho cviku; víc jich jde spojit na stránce Tréninky
|
||||
await createWorkoutTemplateFromActivities({
|
||||
body: { date: saved.date, name: saved.name, ids: [saved.id] },
|
||||
});
|
||||
|
||||
@@ -6,9 +6,9 @@ import { faPlay, faClockRotateLeft } from "@fortawesome/free-solid-svg-icons";
|
||||
import {
|
||||
MealSuggestion, MealTemplate, MealType, addMeal, applyMealTemplate, getMealHistory, getMealTemplates,
|
||||
} from "../../../../types";
|
||||
import { MEAL_TYPES_IN_ORDER, MEAL_TYPE_ICONS, MEAL_TYPE_NAMES, TemplateTab } from "../../enums";
|
||||
import { MEAL_TYPES_IN_ORDER, MEAL_TYPE_ICONS, MEAL_TYPE_NAMES, RecipeFilter } from "../../enums";
|
||||
import { describeMealSuggestion, describeMealTemplate, getMealKey, normalizeSearch, parseMultiplierInput } from "../../Utils";
|
||||
import { SABLONY_URL } from "../../routes";
|
||||
import { JIDLA_URL } from "../../routes";
|
||||
|
||||
type Props = {
|
||||
isOpen: boolean,
|
||||
@@ -114,7 +114,7 @@ const ApplyMealModal = ({ isOpen, date, onClose, onApplied }: Readonly<Props>) =
|
||||
<div className="empty-title">Zatím nemáte co vybírat</div>
|
||||
<p className="empty-description">
|
||||
Každé zapsané jídlo se tu příště nabídne. Vařená jídla si můžete zapsat jako
|
||||
recept na stránce Šablony.
|
||||
recept u jídla na stránce Jídla.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
@@ -135,7 +135,7 @@ const ApplyMealModal = ({ isOpen, date, onClose, onApplied }: Readonly<Props>) =
|
||||
<Form.Label>Založit jako</Form.Label>
|
||||
<Form.Select
|
||||
value={mealType}
|
||||
title="Bez volby se jídlo z historie založí jako oběd a recept pod svým chodem"
|
||||
title="Bez volby se jídlo založí jako oběd"
|
||||
onChange={event => setMealType(event.target.value as MealType | '')}
|
||||
>
|
||||
<option value="">— výchozí —</option>
|
||||
@@ -218,7 +218,7 @@ const ApplyMealModal = ({ isOpen, date, onClose, onApplied }: Readonly<Props>) =
|
||||
<Modal.Footer>
|
||||
<Button
|
||||
variant="outline-secondary"
|
||||
onClick={() => { onClose(); navigate(SABLONY_URL, { state: { tab: TemplateTab.RECEPTY } }); }}
|
||||
onClick={() => { onClose(); navigate(JIDLA_URL, { state: { recipeFilter: RecipeFilter.S_RECEPTEM } }); }}
|
||||
>
|
||||
Spravovat recepty
|
||||
</Button>
|
||||
|
||||
@@ -6,7 +6,7 @@ import { faPlay, faLayerGroup, faDumbbell } from "@fortawesome/free-solid-svg-ic
|
||||
import { DayOverview, WorkoutItemKind, WorkoutTemplate, applyWorkoutTemplate, getWorkoutTemplates } from "../../../../types";
|
||||
import { ACTIVITY_UNIT_SHORT } from "../../enums";
|
||||
import { formatCalories, formatQuantityWithSets } from "../../Utils";
|
||||
import { SABLONY_URL } from "../../routes";
|
||||
import { TRENINKY_URL } from "../../routes";
|
||||
|
||||
type Props = {
|
||||
isOpen: boolean,
|
||||
@@ -70,7 +70,7 @@ const ApplyWorkoutModal = ({ isOpen, date, onClose, onApplied }: Readonly<Props>
|
||||
<div className="empty-state">
|
||||
<div className="empty-icon"><FontAwesomeIcon icon={faDumbbell} /></div>
|
||||
<div className="empty-title">Zatím nemáte žádnou šablonu</div>
|
||||
<p className="empty-description">Vytvořit si ji můžete na stránce Šablony.</p>
|
||||
<p className="empty-description">Vytvořit si ji můžete na stránce Tréninky.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -95,7 +95,7 @@ const ApplyWorkoutModal = ({ isOpen, date, onClose, onApplied }: Readonly<Props>
|
||||
<Modal.Footer>
|
||||
<Button
|
||||
variant="outline-secondary"
|
||||
onClick={() => { onClose(); navigate(SABLONY_URL); }}
|
||||
onClick={() => { onClose(); navigate(TRENINKY_URL); }}
|
||||
>
|
||||
Spravovat šablony
|
||||
</Button>
|
||||
|
||||
@@ -139,7 +139,6 @@ export default function MealModal({ isOpen, date, entry, onClose, onSaved }: Rea
|
||||
setPickedSuggestion(recipe.name);
|
||||
setForm(current => mergeSuggestion(current, {
|
||||
name: recipe.name,
|
||||
...(recipe.mealType ? { mealType: recipe.mealType } : {}),
|
||||
source: recipe.source ?? '',
|
||||
price: formatPriceInput(recipe.price),
|
||||
pricePer100g: '',
|
||||
|
||||
+16
-12
@@ -134,17 +134,6 @@ export const BMI_KATEGORIE_NAMES: Record<BmiKategorie, string> = {
|
||||
/** Nabízená týdenní tempa změny hmotnosti v gramech; záporné je hubnutí. */
|
||||
export const WEEKLY_CHANGE_OPTIONS = [-1000, -750, -500, -250, 0, 250, 500];
|
||||
|
||||
/** Záložky stránky se šablonami. */
|
||||
export enum TemplateTab {
|
||||
TRENINKY = 'TRENINKY',
|
||||
RECEPTY = 'RECEPTY',
|
||||
}
|
||||
|
||||
export const TEMPLATE_TAB_NAMES: Record<TemplateTab, string> = {
|
||||
TRENINKY: 'Tréninky',
|
||||
RECEPTY: 'Recepty',
|
||||
};
|
||||
|
||||
/** Živiny v pořadí, v jakém je uvádí etiketa potravin. */
|
||||
export const NUTRIENTS_IN_ORDER: (keyof Nutrients)[] = [
|
||||
'protein', 'carbohydrates', 'sugars', 'fat', 'saturatedFat', 'fiber', 'salt',
|
||||
@@ -177,12 +166,27 @@ export const IMPORT_MODE_NAMES: Record<ImportMode, string> = {
|
||||
/** Předvolby násobku porce nabízené v přehledu dne. */
|
||||
export const MULTIPLIER_PRESETS = [0.5, 1, 1.5, 2, 3];
|
||||
|
||||
/** Akce nad skupinou jídel v seznamu jídel — doplnění jen připisuje, přejmenování přepisuje. */
|
||||
/** Rozbalitelné části jídla v seznamu jídel. Doplnění jen připisuje, přejmenování přepisuje. */
|
||||
export enum MealGroupAction {
|
||||
RECEPT = 'RECEPT',
|
||||
FOTKY = 'FOTKY',
|
||||
DOPLNIT = 'DOPLNIT',
|
||||
PREJMENOVAT = 'PREJMENOVAT',
|
||||
}
|
||||
|
||||
/** Filtr seznamu jídel podle receptu. */
|
||||
export enum RecipeFilter {
|
||||
VSE = 'VSE',
|
||||
S_RECEPTEM = 'S_RECEPTEM',
|
||||
BEZ_RECEPTU = 'BEZ_RECEPTU',
|
||||
}
|
||||
|
||||
export const RECIPE_FILTER_NAMES: Record<RecipeFilter, string> = {
|
||||
VSE: 'S receptem i bez',
|
||||
S_RECEPTEM: 'S receptem',
|
||||
BEZ_RECEPTU: 'Bez receptu',
|
||||
};
|
||||
|
||||
/** Zvláštní volby filtru zdroje v seznamu jídel; jinak se filtruje konkrétním zdrojem. */
|
||||
export enum SourceFilter {
|
||||
VSE = 'VSE',
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
/** Delší strana fotky jídla — na telefonu i monitoru stačí, a velikost zůstane ve stovkách kB. */
|
||||
const PHOTO_MAX_SIZE = 1600;
|
||||
/** Delší strana náhledu v seznamu jídel. */
|
||||
const THUMBNAIL_MAX_SIZE = 240;
|
||||
|
||||
/** Překreslí obrázek do JPEGu s delší stranou nejvýš `maxSize`; menší obrázek nezvětšuje. */
|
||||
const toJpegDataUrl = (image: ImageBitmap, maxSize: number, quality: number): string => {
|
||||
const scale = Math.min(1, maxSize / Math.max(image.width, image.height));
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = Math.round(image.width * scale);
|
||||
canvas.height = Math.round(image.height * scale);
|
||||
canvas.getContext('2d')?.drawImage(image, 0, 0, canvas.width, canvas.height);
|
||||
return canvas.toDataURL('image/jpeg', quality);
|
||||
};
|
||||
|
||||
/**
|
||||
* Připraví fotku z fotoaparátu k uložení — zmenšenou fotku a malý náhled, obojí
|
||||
* jako data URL. Originál z telefonu má několik MB, ukládat ho celý nemá smysl.
|
||||
* `createImageBitmap` zároveň srovná otočení fotky podle EXIF.
|
||||
*/
|
||||
export const prepareMealPhoto = async (file: File): Promise<{ image: string, thumbnail: string }> => {
|
||||
const image = await createImageBitmap(file, { imageOrientation: 'from-image' });
|
||||
try {
|
||||
return {
|
||||
image: toJpegDataUrl(image, PHOTO_MAX_SIZE, 0.85),
|
||||
thumbnail: toJpegDataUrl(image, THUMBNAIL_MAX_SIZE, 0.7),
|
||||
};
|
||||
} finally {
|
||||
image.close();
|
||||
}
|
||||
};
|
||||
@@ -10,8 +10,9 @@
|
||||
flex: 1 1 240px;
|
||||
}
|
||||
|
||||
.meal-groups-source {
|
||||
flex: 0 1 220px;
|
||||
.meal-groups-source,
|
||||
.meal-groups-recipe {
|
||||
flex: 0 1 200px;
|
||||
}
|
||||
|
||||
.form-check {
|
||||
@@ -93,3 +94,93 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.meal-group {
|
||||
.meal-group-thumb {
|
||||
flex: 0 0 auto;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: none;
|
||||
|
||||
img {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
object-fit: cover;
|
||||
border-radius: var(--ft-radius-sm);
|
||||
}
|
||||
}
|
||||
|
||||
.meal-group-actions {
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.tag-recipe {
|
||||
background: var(--ft-primary-light);
|
||||
color: var(--ft-primary);
|
||||
}
|
||||
|
||||
// Recept je uvnitř karty jídla, druhý rámeček kolem by jen zabíral místo
|
||||
.meal-group-recipe .card-panel {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
box-shadow: none;
|
||||
background: none;
|
||||
}
|
||||
}
|
||||
|
||||
.meal-photos {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.meal-photo {
|
||||
width: 104px;
|
||||
height: 104px;
|
||||
padding: 0;
|
||||
border: 1px solid var(--ft-border);
|
||||
border-radius: var(--ft-radius-sm);
|
||||
background: var(--ft-bg-card);
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
}
|
||||
|
||||
.meal-photo-add {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
margin: 0;
|
||||
border-style: dashed;
|
||||
color: var(--ft-text-muted);
|
||||
font-size: 0.8rem;
|
||||
|
||||
svg {
|
||||
font-size: 1.3rem;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
color: var(--ft-primary);
|
||||
border-color: var(--ft-primary);
|
||||
}
|
||||
|
||||
&.disabled {
|
||||
pointer-events: none;
|
||||
opacity: 0.6;
|
||||
}
|
||||
}
|
||||
|
||||
.meal-photo-full {
|
||||
max-width: 100%;
|
||||
max-height: 75vh;
|
||||
border-radius: var(--ft-radius-sm);
|
||||
}
|
||||
@@ -1,12 +1,14 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { Form, Spinner } from "react-bootstrap";
|
||||
import { Button, Form, Spinner } from "react-bootstrap";
|
||||
import { useLocation } from "react-router";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { faCircleCheck } from "@fortawesome/free-solid-svg-icons";
|
||||
import { faBookOpen, faCircleCheck } from "@fortawesome/free-solid-svg-icons";
|
||||
import { MealGroup, getMealGroups } from "../../../types";
|
||||
import { MealGroupAction, SourceFilter } from "../enums";
|
||||
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 "./templates.scss";
|
||||
import "./MealGroupsPage.scss";
|
||||
|
||||
const isIncomplete = (group: MealGroup): boolean =>
|
||||
@@ -32,6 +34,15 @@ const listSources = (groupList: MealGroup[]): { value: string, label: string }[]
|
||||
.map(([value, item]) => ({ value, label: item.label }));
|
||||
};
|
||||
|
||||
const matchesRecipe = (group: MealGroup, recipeFilter: RecipeFilter): boolean => {
|
||||
if (recipeFilter === RecipeFilter.S_RECEPTEM) return !!group.recipe;
|
||||
if (recipeFilter === RecipeFilter.BEZ_RECEPTU) return !group.recipe;
|
||||
return true;
|
||||
};
|
||||
|
||||
/** Stav, se kterým stránku může otevřít odkaz odjinud (statistiky, výběr z historie). */
|
||||
type LocationState = { onlyIncomplete?: boolean, recipeFilter?: RecipeFilter } | null;
|
||||
|
||||
const matchesSource = (group: MealGroup, sourceFilter: string): boolean => {
|
||||
if (sourceFilter === SourceFilter.VSE) return true;
|
||||
if (sourceFilter === SourceFilter.BEZ_ZDROJE) return !group.source;
|
||||
@@ -43,6 +54,8 @@ const matchesSource = (group: MealGroup, sourceFilter: string): boolean => {
|
||||
*
|
||||
* Skupině jde hromadně doplnit chybějící kalorie, gramáž nebo živiny a změnit
|
||||
* název a zdroj, a to ve všech dnech naráz, bez procházení jednotlivých dnů.
|
||||
* Jídlo jde rozšířit o recept a fotky; recept jídla, které se ještě nejedlo,
|
||||
* se zakládá tlačítkem v hlavičce a v seznamu je pak samo.
|
||||
*/
|
||||
const MealGroupsPage = () => {
|
||||
const location = useLocation();
|
||||
@@ -50,9 +63,11 @@ const MealGroupsPage = () => {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [query, setQuery] = useState('');
|
||||
const [sourceFilter, setSourceFilter] = useState<string>(SourceFilter.VSE);
|
||||
const [onlyIncomplete, setOnlyIncomplete] = useState(() =>
|
||||
!!(location.state as { onlyIncomplete?: boolean } | null)?.onlyIncomplete);
|
||||
const [onlyIncomplete, setOnlyIncomplete] = useState(() => !!(location.state as LocationState)?.onlyIncomplete);
|
||||
const [recipeFilter, setRecipeFilter] = useState<RecipeFilter>(() =>
|
||||
(location.state as LocationState)?.recipeFilter ?? RecipeFilter.VSE);
|
||||
const [opened, setOpened] = useState<{ key: string, action: MealGroupAction } | undefined>();
|
||||
const [newRecipeOpen, setNewRecipeOpen] = useState(false);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
const response = await getMealGroups();
|
||||
@@ -69,11 +84,12 @@ const MealGroupsPage = () => {
|
||||
return groupList.filter(group =>
|
||||
(!onlyIncomplete || isIncomplete(group))
|
||||
&& matchesSource(group, sourceFilter)
|
||||
&& matchesRecipe(group, recipeFilter)
|
||||
&& (!search.length || normalizeSearch(group.name).includes(search)));
|
||||
}, [groupList, query, sourceFilter, onlyIncomplete]);
|
||||
}, [groupList, query, sourceFilter, recipeFilter, onlyIncomplete]);
|
||||
|
||||
const incompleteCount = groupList.filter(group => isIncomplete(group)).length;
|
||||
const filtered = !!query.trim().length || sourceFilter !== SourceFilter.VSE;
|
||||
const filtered = !!query.trim().length || sourceFilter !== SourceFilter.VSE || recipeFilter !== RecipeFilter.VSE;
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -84,8 +100,18 @@ 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>
|
||||
<Button variant="outline-secondary" onClick={() => setNewRecipeOpen(open => !open)}>
|
||||
<FontAwesomeIcon icon={faBookOpen} /> Nový recept
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{newRecipeOpen && (
|
||||
<MealTemplateForm
|
||||
onSaved={() => { setNewRecipeOpen(false); load(); }}
|
||||
onCancel={() => setNewRecipeOpen(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="card-panel meal-groups-filter">
|
||||
<Form.Control
|
||||
value={query}
|
||||
@@ -101,6 +127,15 @@ const MealGroupsPage = () => {
|
||||
<option value={SourceFilter.BEZ_ZDROJE}>Bez zdroje</option>
|
||||
{sourceList.map(source => <option key={source.value} value={source.value}>{source.label}</option>)}
|
||||
</Form.Select>
|
||||
<Form.Select
|
||||
className="meal-groups-recipe"
|
||||
value={recipeFilter}
|
||||
onChange={event => setRecipeFilter(event.target.value as RecipeFilter)}
|
||||
>
|
||||
{Object.values(RecipeFilter).map(value => (
|
||||
<option key={value} value={value}>{RECIPE_FILTER_NAMES[value]}</option>
|
||||
))}
|
||||
</Form.Select>
|
||||
<Form.Check
|
||||
type="switch"
|
||||
id="meal-groups-incomplete"
|
||||
@@ -135,6 +170,7 @@ const MealGroupsPage = () => {
|
||||
action={opened?.key === key ? opened.action : undefined}
|
||||
onAction={action => setOpened(action ? { key, action } : undefined)}
|
||||
onChanged={() => { setOpened(undefined); load(); }}
|
||||
onRefresh={() => load()}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -1,88 +0,0 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { Spinner } from "react-bootstrap";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { faBookOpen } from "@fortawesome/free-solid-svg-icons";
|
||||
import { MealTemplate, deleteMealTemplate, getMealTemplates } from "../../../types";
|
||||
import MealTemplateCard from "../components/MealTemplateCard";
|
||||
import MealTemplateForm from "../components/MealTemplateForm";
|
||||
|
||||
/**
|
||||
* Panel se správou receptů — vařených jídel se surovinami a postupem, ze kterých
|
||||
* se hodnoty porce dopočtou. Objednaná a koupená jídla šablony nemají, při
|
||||
* zakládání jídla se nabízejí z historie.
|
||||
*/
|
||||
const MealTemplatesPanel = () => {
|
||||
const [templates, setTemplates] = useState<MealTemplate[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [edited, setEdited] = useState<MealTemplate | undefined>();
|
||||
const [busy, setBusy] = useState(false);
|
||||
/** Po uložení se zvýší, aby se formulář vyprázdnil i u nové šablony. */
|
||||
const [formVersion, setFormVersion] = useState(0);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
const response = await getMealTemplates();
|
||||
setTemplates(response.data ?? []);
|
||||
setLoading(false);
|
||||
}, []);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
const remove = async (id: string) => {
|
||||
setBusy(true);
|
||||
const response = await deleteMealTemplate({ body: { id } });
|
||||
setBusy(false);
|
||||
if (response.data) {
|
||||
setTemplates(response.data);
|
||||
if (edited?.id === id) setEdited(undefined);
|
||||
}
|
||||
};
|
||||
|
||||
const edit = (template: MealTemplate) => {
|
||||
setEdited(template);
|
||||
globalThis.scrollTo({ top: document.body.scrollHeight, behavior: 'smooth' });
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<p className="text-body-secondary small">
|
||||
Recept je vařené jídlo — zapíšete suroviny a postup a kalorie porce se spočítají samy.
|
||||
V přehledu dne se porce založí jedním kliknutím, nebo se recept nabídne, když začnete psát
|
||||
název jídla. Objednaná a koupená jídla si zapisovat nemusíte, nabízejí se z historie.
|
||||
</p>
|
||||
|
||||
{loading && (
|
||||
<div className="empty-state">
|
||||
<Spinner animation="border" variant="success" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && !templates.length && (
|
||||
<div className="card-panel empty-state">
|
||||
<div className="empty-icon"><FontAwesomeIcon icon={faBookOpen} /></div>
|
||||
<div className="empty-title">Zatím nemáte žádný recept</div>
|
||||
<p className="empty-description">Zapište si ho ve formuláři níže.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && templates.map(template => (
|
||||
<MealTemplateCard
|
||||
key={template.id}
|
||||
template={template}
|
||||
busy={busy}
|
||||
onEdit={() => edit(template)}
|
||||
onDelete={() => remove(template.id)}
|
||||
/>
|
||||
))}
|
||||
|
||||
<MealTemplateForm
|
||||
key={`${edited?.id ?? 'new'}-${formVersion}`}
|
||||
template={edited}
|
||||
onSaved={saved => { setTemplates(saved); setEdited(undefined); setFormVersion(current => current + 1); }}
|
||||
onCancel={() => setEdited(undefined)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default MealTemplatesPanel;
|
||||
@@ -1,50 +0,0 @@
|
||||
import { useState } from "react";
|
||||
import { useLocation } from "react-router";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { faDumbbell, faBookOpen } from "@fortawesome/free-solid-svg-icons";
|
||||
import { TemplateTab, TEMPLATE_TAB_NAMES } from "../enums";
|
||||
import WorkoutTemplatesPanel from "./WorkoutTemplatesPanel";
|
||||
import MealTemplatesPanel from "./MealTemplatesPanel";
|
||||
import "./TemplatesPage.scss";
|
||||
|
||||
/**
|
||||
* Šablony — tréninky a recepty vedle sebe.
|
||||
*
|
||||
* Obojí jsou předpřipravené položky zakládané do dne jedním kliknutím, ale mají
|
||||
* jinou strukturu (tréninky se skládají z jiných šablon, recepty ze surovin),
|
||||
* proto mají vlastní panely a společné jen záložky. Hotová jídla šablony
|
||||
* nemají, nabízejí se z historie.
|
||||
*/
|
||||
const TemplatesPage = () => {
|
||||
// Odkaz může rovnou otevřít konkrétní záložku, třeba recepty z výběru jídla
|
||||
const location = useLocation();
|
||||
const [tab, setTab] = useState<TemplateTab>(() =>
|
||||
(location.state as { tab?: TemplateTab } | null)?.tab ?? TemplateTab.TRENINKY);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="page-header">
|
||||
<h1>Šablony</h1>
|
||||
</div>
|
||||
|
||||
<div className="day-tabs">
|
||||
<button
|
||||
className={`day-tab ${tab === TemplateTab.TRENINKY ? 'active' : ''}`}
|
||||
onClick={() => setTab(TemplateTab.TRENINKY)}
|
||||
>
|
||||
<FontAwesomeIcon icon={faDumbbell} /> {TEMPLATE_TAB_NAMES[TemplateTab.TRENINKY]}
|
||||
</button>
|
||||
<button
|
||||
className={`day-tab ${tab === TemplateTab.RECEPTY ? 'active' : ''}`}
|
||||
onClick={() => setTab(TemplateTab.RECEPTY)}
|
||||
>
|
||||
<FontAwesomeIcon icon={faBookOpen} /> {TEMPLATE_TAB_NAMES[TemplateTab.RECEPTY]}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{tab === TemplateTab.TRENINKY ? <WorkoutTemplatesPanel /> : <MealTemplatesPanel />}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default TemplatesPage;
|
||||
@@ -0,0 +1,17 @@
|
||||
import WorkoutTemplatesPanel from "./WorkoutTemplatesPanel";
|
||||
import "./templates.scss";
|
||||
|
||||
/**
|
||||
* Tréninky — šablony tréninků zakládané do dne jedním kliknutím. Recepty sem
|
||||
* dřív patřily také, teď jsou rozšířením jídla v seznamu jídel.
|
||||
*/
|
||||
const WorkoutsPage = () => (
|
||||
<>
|
||||
<div className="page-header">
|
||||
<h1>Tréninky</h1>
|
||||
</div>
|
||||
<WorkoutTemplatesPanel />
|
||||
</>
|
||||
);
|
||||
|
||||
export default WorkoutsPage;
|
||||
@@ -66,6 +66,15 @@
|
||||
}
|
||||
|
||||
.template-form {
|
||||
.chart-title {
|
||||
font-size: 0.85rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--ft-text-secondary);
|
||||
margin: 0 0 14px;
|
||||
}
|
||||
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
*/
|
||||
export const DEN_URL = '/';
|
||||
export const STATISTIKY_URL = '/statistiky';
|
||||
export const SABLONY_URL = '/sablony';
|
||||
export const TRENINKY_URL = '/treninky';
|
||||
export const PROFIL_URL = '/profil';
|
||||
export const JIDLA_URL = '/jidla';
|
||||
|
||||
|
||||
Reference in new issue
Block a user