234 lines
9.5 KiB
TypeScript
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>
|
|
);
|
|
}
|