Initial commit

This commit is contained in:
2026-06-04 06:24:56 +02:00
commit 282f4864c4
111 changed files with 8083 additions and 0 deletions

View File

@@ -0,0 +1,121 @@
import { useMemo, useState } from "react";
import { CheckSquare, Square, UtensilsCrossed } from "lucide-react";
import { useRecipes } from "@/hooks/useRecipes";
import { RecipeCard } from "@/components/recipes/RecipeCard";
import { RecipeFilters, type CategoryFilter } from "@/components/recipes/RecipeFilters";
import { PdfGenerator } from "@/components/pdf/PdfGenerator";
import { GridSkeleton } from "@/components/ui/Skeleton";
import { EmptyState } from "@/components/ui/EmptyState";
import { ErrorState } from "@/components/ui/ErrorState";
import { extractApiError } from "@/api/axiosInstance";
export function AllMealsPage() {
const { data: recipes, isLoading, isError, error, refetch } = useRecipes();
const [filter, setFilter] = useState<CategoryFilter>("all");
const [search, setSearch] = useState("");
const [selected, setSelected] = useState<Set<number>>(new Set());
const visible = useMemo(() => {
if (!recipes) return [];
const term = search.trim().toLowerCase();
return recipes.filter((r) => {
const matchesCategory = filter === "all" || r.mealCategory === filter;
const matchesSearch = term === "" || r.name.toLowerCase().includes(term);
return matchesCategory && matchesSearch;
});
}, [recipes, filter, search]);
const toggleSelected = (id: number) => {
setSelected((prev) => {
const next = new Set(prev);
if (next.has(id)) {
next.delete(id);
} else {
next.add(id);
}
return next;
});
};
const selectAllVisible = () => {
setSelected((prev) => {
const next = new Set(prev);
visible.forEach((r) => next.add(r.id));
return next;
});
};
const deselectAll = () => setSelected(new Set());
const selectedIds = useMemo(() => Array.from(selected), [selected]);
return (
<div className="space-y-6">
<header className="flex flex-wrap items-end justify-between gap-4">
<div>
<h1 className="text-2xl font-extrabold tracking-tight sm:text-3xl">All Meals</h1>
<p className="mt-1 text-slate-500 dark:text-slate-400">
Select recipes and export them with a combined shopping list.
</p>
</div>
<span className="inline-flex items-center gap-2 rounded-full bg-brand-100 px-3.5 py-1.5 text-sm font-semibold text-brand-800 dark:bg-brand-900/40 dark:text-brand-300">
{selected.size} selected
</span>
</header>
<RecipeFilters active={filter} onChange={setFilter} search={search} onSearchChange={setSearch} />
<div className="flex flex-wrap items-center gap-3">
<button
type="button"
onClick={selectAllVisible}
disabled={visible.length === 0}
className="btn-secondary"
>
<CheckSquare className="h-4 w-4" />
Select all visible
</button>
<button
type="button"
onClick={deselectAll}
disabled={selected.size === 0}
className="btn-secondary"
>
<Square className="h-4 w-4" />
Deselect all
</button>
<div className="ml-auto">
<PdfGenerator selectedIds={selectedIds} />
</div>
</div>
{isLoading && <GridSkeleton count={9} />}
{isError && (
<ErrorState message={extractApiError(error, "Failed to load recipes.")} onRetry={() => void refetch()} />
)}
{!isLoading && !isError && visible.length === 0 && (
<EmptyState
icon={UtensilsCrossed}
title="No matching recipes"
description="Try adjusting your search or category filter."
/>
)}
{!isLoading && !isError && visible.length > 0 && (
<div className="grid grid-cols-1 gap-5 sm:grid-cols-2 lg:grid-cols-3">
{visible.map((recipe) => (
<RecipeCard
key={recipe.id}
recipe={recipe}
selectable
selected={selected.has(recipe.id)}
onToggleSelected={toggleSelected}
/>
))}
</div>
)}
</div>
);
}