Files
folx-tv/client/src/pages/recipes.tsx
T

234 lines
9.5 KiB
TypeScript

import { useState, Fragment } from "react";
import { useQuery } from "@tanstack/react-query";
import { ChefHat, X, Calendar } from "lucide-react";
import { usePageMeta } from "@/hooks/use-page-meta";
import Header from "@/components/header";
import Footer from "@/components/footer";
import { InArticleAd, PageSideAds, TopBannerAd, MultiplexAd } from "@/components/adsense";
interface DailyRecipe {
id: number;
dateStr: string;
name: string;
intro?: string;
recipe?: string;
seasonNote?: string | null;
imageUrl?: string | null;
}
const MONTHS_DE = ["Januar", "Februar", "März", "April", "Mai", "Juni", "Juli",
"August", "September", "Oktober", "November", "Dezember"];
function formatDate(dateStr: string): string {
const [y, m, d] = dateStr.split("-").map((x) => parseInt(x, 10));
if (!y || !m || !d) return dateStr;
return `${d}. ${MONTHS_DE[m - 1]} ${y}`;
}
function RecipeBody({ recipe }: { recipe: DailyRecipe }) {
return (
<>
{recipe.intro && <p className="text-sm text-muted-foreground mb-4">{recipe.intro}</p>}
{recipe.recipe && (
<div className="text-sm text-foreground/85 whitespace-pre-line leading-relaxed">{recipe.recipe}</div>
)}
{recipe.seasonNote && <p className="text-xs text-primary mt-4">{recipe.seasonNote}</p>}
</>
);
}
export default function RecipesPage() {
usePageMeta(
"Rezepte - Alpenküche & Volksmusik",
"Das Rezept des Tages und alle bisherigen Rezepte aus der Alpenküche bei FOLX TV — österreichische, bayerische und schweizer Hausmannskost."
);
const [selected, setSelected] = useState<DailyRecipe | null>(null);
const [page, setPage] = useState(0);
const PAGE_SIZE = 12;
const { data = [], isLoading } = useQuery<DailyRecipe[]>({
queryKey: ["/api/recipes"],
staleTime: 1000 * 60 * 30,
});
const today = data[0];
const rest = data.slice(1);
const totalPages = Math.max(1, Math.ceil(rest.length / PAGE_SIZE));
const pageItems = rest.slice(page * PAGE_SIZE, (page + 1) * PAGE_SIZE);
const goPage = (p: number) => {
setPage(Math.max(0, Math.min(totalPages - 1, p)));
document.getElementById("rezept-archiv")?.scrollIntoView({ behavior: "smooth", block: "start" });
};
return (
<div className="min-h-screen bg-background">
<Header />
<PageSideAds />
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4">
<TopBannerAd />
<h1 className="text-2xl font-bold text-foreground flex items-center gap-2 mb-1" data-testid="text-recipes-title">
<ChefHat className="w-6 h-6 text-primary" />
Rezepte
</h1>
<p className="text-muted-foreground text-sm mb-8">
Jeden Tag ein neues Rezept aus der Alpenküche — und das ganze Archiv zum Nachkochen.
</p>
{today && (
<section className="mb-10" data-testid="section-recipe-of-day">
<div className="flex items-center gap-3 mb-4">
<div className="h-px flex-1 bg-card-border" />
<h2 className="text-lg font-bold text-foreground px-3">Rezept des Tages</h2>
<div className="h-px flex-1 bg-card-border" />
</div>
<div className="bg-card rounded-xl border border-card-border overflow-hidden md:flex">
{today.imageUrl && (
<img
src={today.imageUrl}
alt={today.name}
className="w-full md:w-1/2 aspect-video object-cover"
data-testid="img-recipe-of-day"
/>
)}
<div className="p-5 md:w-1/2">
<p className="text-[11px] text-muted-foreground flex items-center gap-1 mb-1">
<Calendar className="w-3 h-3" /> {formatDate(today.dateStr)}
</p>
<h3 className="text-xl font-bold text-foreground mb-3">{today.name}</h3>
<RecipeBody recipe={today} />
</div>
</div>
</section>
)}
<InArticleAd />
{rest.length > 0 && (
<section data-testid="section-recipe-archive" id="rezept-archiv">
<div className="flex items-center gap-3 mb-4 mt-8">
<div className="h-px flex-1 bg-card-border" />
<h2 className="text-lg font-bold text-foreground px-3">Alle Rezepte</h2>
<div className="h-px flex-1 bg-card-border" />
</div>
<p className="text-xs text-muted-foreground mb-4">
{rest.length} Rezepte · Seite {page + 1} von {totalPages}
</p>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-5">
{pageItems.map((r, idx) => (
<Fragment key={r.id}>
{idx > 0 && idx % 4 === 0 && (
<div key={`ad-${idx}`} className="sm:col-span-2 lg:col-span-3 xl:col-span-4">
<InArticleAd />
</div>
)}
<button
onClick={() => setSelected(r)}
className="bg-card rounded-xl border border-card-border overflow-hidden text-left group hover:border-primary/50 transition-all hover:shadow-lg hover:shadow-primary/5"
data-testid={`card-recipe-${r.id}`}
>
{r.imageUrl && (
<img
src={r.imageUrl}
alt={r.name}
loading="lazy"
className="w-full aspect-video object-cover transition-transform duration-500 group-hover:scale-105"
/>
)}
<div className="p-4">
<p className="text-[11px] text-muted-foreground mb-1">{formatDate(r.dateStr)}</p>
<h3 className="font-semibold text-card-foreground leading-snug line-clamp-2 group-hover:text-primary transition-colors">
{r.name}
</h3>
{r.intro && (
<p className="text-xs text-muted-foreground mt-2 line-clamp-3">{r.intro}</p>
)}
</div>
</button>
</Fragment>
))}
</div>
{totalPages > 1 && (
<div className="flex flex-wrap items-center justify-center gap-2 mt-8" data-testid="recipe-pagination">
<button
onClick={() => goPage(page - 1)}
disabled={page === 0}
className="px-3 py-2 text-sm rounded-lg border border-card-border bg-card text-foreground disabled:opacity-40 hover:border-primary/50 transition-colors"
>
Zurück
</button>
{Array.from({ length: totalPages }).map((_, i) => (
<button
key={i}
onClick={() => goPage(i)}
className={`w-9 h-9 text-sm rounded-lg border transition-colors ${
i === page
? "bg-primary text-white border-primary"
: "bg-card text-foreground border-card-border hover:border-primary/50"
}`}
>
{i + 1}
</button>
))}
<button
onClick={() => goPage(page + 1)}
disabled={page === totalPages - 1}
className="px-3 py-2 text-sm rounded-lg border border-card-border bg-card text-foreground disabled:opacity-40 hover:border-primary/50 transition-colors"
>
Weiter
</button>
</div>
)}
</section>
)}
{!isLoading && data.length === 0 && (
<p className="text-muted-foreground text-sm">Noch keine Rezepte vorhanden.</p>
)}
<div className="mt-8" data-testid="ad-multiplex-end">
<div className="text-[9px] text-muted-foreground/40 text-center pb-1 uppercase tracking-widest">Anzeige</div>
<MultiplexAd />
</div>
</main>
{selected && (
<div
className="fixed inset-0 z-[120] bg-black/80 flex items-start justify-center overflow-y-auto p-4"
onClick={() => setSelected(null)}
data-testid="modal-recipe"
>
<div
className="bg-card rounded-xl border border-card-border max-w-2xl w-full my-8 overflow-hidden"
onClick={(e) => e.stopPropagation()}
>
{selected.imageUrl && (
<img src={selected.imageUrl} alt={selected.name} className="w-full aspect-video object-cover" />
)}
<div className="p-5">
<div className="flex items-start justify-between gap-4 mb-3">
<div>
<p className="text-[11px] text-muted-foreground flex items-center gap-1 mb-1">
<Calendar className="w-3 h-3" /> {formatDate(selected.dateStr)}
</p>
<h3 className="text-xl font-bold text-foreground">{selected.name}</h3>
</div>
<button
onClick={() => setSelected(null)}
className="text-muted-foreground hover:text-foreground transition-colors"
data-testid="button-recipe-close"
>
<X className="w-5 h-5" />
</button>
</div>
<RecipeBody recipe={selected} />
</div>
</div>
</div>
)}
<Footer />
</div>
);
}