Restored to '84d7de2da64f8f2fd1e2d64959817c64c8fb7c04'
Replit-Restored-To: 84d7de2da6
This commit is contained in:
@@ -74,6 +74,7 @@ function RelatedArticles({ currentSlug }: { currentSlug: string }) {
|
||||
src={article.coverImage ? article.coverImage.replace(".webp", "-thumb.webp") : "/images/article-1.png"}
|
||||
alt={article.title}
|
||||
className="w-full aspect-video object-cover transition-transform duration-500 group-hover:scale-105"
|
||||
style={{ objectPosition: "center 25%" }}
|
||||
loading="lazy"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -60,6 +60,7 @@ export default function CategoryPage() {
|
||||
src={article.coverImage || "/images/article-1.png"}
|
||||
alt={article.title}
|
||||
className="w-full aspect-video object-cover transition-transform duration-500 group-hover:scale-105"
|
||||
style={{ objectPosition: "center 25%" }}
|
||||
loading="lazy"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import Header from "@/components/header";
|
||||
import Footer from "@/components/footer";
|
||||
import GalleryPage from "@/components/photo-gallery";
|
||||
import { ArrowLeft } from "lucide-react";
|
||||
import { Link } from "wouter";
|
||||
|
||||
export default function GalleryPageWrapper() {
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<Header />
|
||||
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<Link href="/">
|
||||
<button className="flex items-center gap-2 text-muted-foreground hover:text-foreground transition-colors mb-6 text-sm" data-testid="button-back">
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
Zurück
|
||||
</button>
|
||||
</Link>
|
||||
<h1 className="text-2xl font-bold text-foreground mb-6" data-testid="text-gallery-title">
|
||||
Fotogalerie
|
||||
</h1>
|
||||
<GalleryPage />
|
||||
</main>
|
||||
<Footer />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+195
-167
@@ -3,13 +3,24 @@ import { Link } from "wouter";
|
||||
import { type Article } from "@shared/schema";
|
||||
import { format } from "date-fns";
|
||||
import { de } from "date-fns/locale";
|
||||
import { Eye, Clock, Play } from "lucide-react";
|
||||
import { Eye, Play, Images } from "lucide-react";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import Header from "@/components/header";
|
||||
import Footer from "@/components/footer";
|
||||
import AdSense, { ArticleCardAd } from "@/components/adsense";
|
||||
import AdSense from "@/components/adsense";
|
||||
import { PhotoGalleryWidget } from "@/components/photo-gallery";
|
||||
import { HoroscopeWidget } from "@/components/horoscope-widget";
|
||||
import { RecipeWidget } from "@/components/recipe-widget";
|
||||
import { NewsWidget } from "@/components/news-widget";
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
|
||||
interface GalleryImage {
|
||||
folder: string;
|
||||
fileName: string;
|
||||
thumb: string;
|
||||
large: string;
|
||||
}
|
||||
|
||||
function thumbUrl(src: string | null): string {
|
||||
if (!src) return "/images/article-1.png";
|
||||
if (src.endsWith(".webp")) return src.replace(".webp", "-thumb.webp");
|
||||
@@ -31,17 +42,9 @@ function HeroCard({ article }: { article: Article }) {
|
||||
const isVideo = article.category === "Video";
|
||||
return (
|
||||
<Link href={`/article/${article.slug}`}>
|
||||
<div
|
||||
className="relative group cursor-pointer rounded-lg overflow-hidden h-full"
|
||||
data-testid={`card-hero-${article.id}`}
|
||||
>
|
||||
<div className="relative h-full min-h-[300px] md:min-h-[380px]">
|
||||
<img
|
||||
src={article.coverImage || "/images/article-1.png"}
|
||||
alt={article.title}
|
||||
className="w-full h-full object-cover absolute inset-0 transition-transform duration-700 group-hover:scale-105"
|
||||
loading="lazy"
|
||||
/>
|
||||
<div className="relative group cursor-pointer rounded-lg overflow-hidden" data-testid={`card-hero-${article.id}`}>
|
||||
<div className="relative aspect-[4/3]">
|
||||
<img src={article.coverImage || "/images/article-1.png"} alt={article.title} className="w-full h-full object-cover absolute inset-0 transition-transform duration-700 group-hover:scale-105" style={{ objectPosition: "center 25%" }} loading="lazy" />
|
||||
{isVideo && (
|
||||
<div className="absolute inset-0 flex items-center justify-center z-10">
|
||||
<div className="w-14 h-14 rounded-full bg-primary/90 flex items-center justify-center group-hover:bg-primary transition-colors shadow-lg">
|
||||
@@ -52,23 +55,40 @@ function HeroCard({ article }: { article: Article }) {
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-black/90 via-black/30 to-transparent" />
|
||||
<div className="absolute bottom-0 left-0 right-0 p-5">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span className="text-xs font-medium text-primary bg-primary/20 px-2 py-0.5 rounded">
|
||||
{article.category}
|
||||
</span>
|
||||
<span className="text-white/60 text-xs">
|
||||
{timeAgo(new Date(article.publishedAt))}
|
||||
<span className="text-xs font-medium text-primary bg-primary/20 px-2 py-0.5 rounded">{article.category}</span>
|
||||
<span className="text-white/60 text-xs">{timeAgo(new Date(article.publishedAt))}</span>
|
||||
</div>
|
||||
<h3 className="text-white font-bold text-lg md:text-xl leading-tight line-clamp-3">{article.title}</h3>
|
||||
<p className="text-white/50 text-sm mt-1.5 line-clamp-2 max-w-lg hidden md:block">{article.excerpt}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
function GalleryHeroCard({ images }: { images: GalleryImage[] }) {
|
||||
const [idx, setIdx] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setInterval(() => setIdx((i) => (i + 1) % images.length), 10000);
|
||||
return () => clearInterval(timer);
|
||||
}, [images.length]);
|
||||
|
||||
return (
|
||||
<Link href="/gallery">
|
||||
<div className="relative group cursor-pointer rounded-lg overflow-hidden" data-testid="card-hero-gallery">
|
||||
<div className="relative aspect-[4/3]">
|
||||
<img src={images[idx].large || images[idx].thumb} alt={images[idx].fileName} className="w-full h-full object-cover absolute inset-0 transition-opacity duration-1000" style={{ objectPosition: "center 25%" }} loading="lazy" />
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-black/90 via-black/30 to-transparent" />
|
||||
<div className="absolute bottom-0 left-0 right-0 p-5">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span className="text-xs font-medium text-primary bg-primary/20 px-2 py-0.5 rounded flex items-center gap-1">
|
||||
<Images className="w-3 h-3" /> Fotogalerie
|
||||
</span>
|
||||
</div>
|
||||
<h3 className="text-white font-bold text-lg md:text-xl leading-tight line-clamp-3">
|
||||
{article.title}
|
||||
</h3>
|
||||
<p className="text-white/50 text-sm mt-1.5 line-clamp-2 max-w-lg hidden md:block">
|
||||
{article.excerpt}
|
||||
</p>
|
||||
<div className="flex items-center gap-2 mt-2 text-white/40 text-xs">
|
||||
<Eye className="w-3 h-3" />
|
||||
{article.views.toLocaleString()}
|
||||
</div>
|
||||
<h3 className="text-white font-bold text-lg md:text-xl leading-tight">Backstage & Events</h3>
|
||||
<p className="text-white/50 text-sm mt-1.5 hidden md:block">{images.length} exklusive Fotos aus der Welt der Volksmusik</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -80,18 +100,10 @@ function MediumCard({ article }: { article: Article }) {
|
||||
const isVideo = article.category === "Video";
|
||||
return (
|
||||
<Link href={`/article/${article.slug}`}>
|
||||
<div
|
||||
className="relative group cursor-pointer rounded-lg overflow-hidden h-full bg-card border border-card-border"
|
||||
data-testid={`card-medium-${article.id}`}
|
||||
>
|
||||
<div className="relative">
|
||||
<div className="relative group cursor-pointer rounded-lg overflow-hidden h-full bg-card border border-card-border flex flex-col" data-testid={`card-medium-${article.id}`}>
|
||||
<div className="relative flex-shrink-0">
|
||||
<div className="overflow-hidden">
|
||||
<img
|
||||
src={thumbUrl(article.coverImage)}
|
||||
alt={article.title}
|
||||
className="w-full aspect-video object-cover transition-transform duration-500 group-hover:scale-105"
|
||||
loading="lazy"
|
||||
/>
|
||||
<img src={thumbUrl(article.coverImage)} alt={article.title} className="w-full aspect-video object-cover transition-transform duration-500 group-hover:scale-105" style={{ objectPosition: "center 25%" }} loading="lazy" />
|
||||
</div>
|
||||
{isVideo && (
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
@@ -101,14 +113,13 @@ function MediumCard({ article }: { article: Article }) {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="p-3.5">
|
||||
<div className="p-3.5 flex flex-col flex-1">
|
||||
<div className="flex items-center gap-2 mb-1.5">
|
||||
<span className="text-[10px] font-medium text-primary">{article.author}</span>
|
||||
<span className="text-muted-foreground text-[10px]">{timeAgo(new Date(article.publishedAt))}</span>
|
||||
</div>
|
||||
<h3 className="font-semibold text-card-foreground text-sm leading-snug line-clamp-2 group-hover:text-primary transition-colors">
|
||||
{article.title}
|
||||
</h3>
|
||||
<h3 className="font-semibold text-card-foreground text-sm leading-snug line-clamp-2 group-hover:text-primary transition-colors">{article.title}</h3>
|
||||
<p className="text-xs text-muted-foreground mt-1.5 leading-relaxed flex-1">{article.excerpt}</p>
|
||||
<div className="flex items-center gap-2 mt-2 text-muted-foreground text-[10px]">
|
||||
<Eye className="w-3 h-3" />
|
||||
{article.views.toLocaleString()}
|
||||
@@ -119,44 +130,54 @@ function MediumCard({ article }: { article: Article }) {
|
||||
);
|
||||
}
|
||||
|
||||
function CompactCard({ article }: { article: Article }) {
|
||||
function SideCard({ article }: { article: Article }) {
|
||||
const isVideo = article.category === "Video";
|
||||
return (
|
||||
<Link href={`/article/${article.slug}`}>
|
||||
<div
|
||||
className="flex gap-3 cursor-pointer group bg-card rounded-lg border border-card-border p-3 h-full"
|
||||
data-testid={`card-compact-${article.id}`}
|
||||
>
|
||||
<div className="relative flex-shrink-0 w-24 h-20 rounded overflow-hidden">
|
||||
<img
|
||||
src={thumbUrl(article.coverImage)}
|
||||
alt={article.title}
|
||||
className="w-full h-full object-cover transition-transform duration-500 group-hover:scale-105"
|
||||
loading="lazy"
|
||||
/>
|
||||
<div className="relative group cursor-pointer rounded-lg overflow-hidden bg-card border border-card-border h-full flex flex-col" data-testid={`card-side-${article.id}`}>
|
||||
<div className="relative flex-shrink-0">
|
||||
<div className="overflow-hidden">
|
||||
<img src={thumbUrl(article.coverImage)} alt={article.title} className="w-full aspect-video object-cover transition-transform duration-500 group-hover:scale-105" style={{ objectPosition: "center 25%" }} loading="lazy" />
|
||||
</div>
|
||||
{isVideo && (
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<div className="w-7 h-7 rounded-full bg-primary/90 flex items-center justify-center">
|
||||
<Play className="w-3 h-3 text-white ml-px" fill="white" />
|
||||
<div className="w-9 h-9 rounded-full bg-primary/90 flex items-center justify-center group-hover:bg-primary transition-colors">
|
||||
<Play className="w-4 h-4 text-white ml-0.5" fill="white" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0 flex flex-col">
|
||||
<h4 className="text-sm font-medium text-card-foreground line-clamp-2 group-hover:text-primary transition-colors leading-snug">
|
||||
{article.title}
|
||||
</h4>
|
||||
<div className="flex items-center gap-2 mt-auto text-[10px] text-muted-foreground">
|
||||
<span>{article.author}</span>
|
||||
<span>·</span>
|
||||
<span>{timeAgo(new Date(article.publishedAt))}</span>
|
||||
<div className="p-3 flex flex-col flex-1">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="text-[10px] font-medium text-primary">{article.author}</span>
|
||||
<span className="text-muted-foreground text-[10px]">{timeAgo(new Date(article.publishedAt))}</span>
|
||||
</div>
|
||||
<h3 className="font-semibold text-card-foreground text-sm leading-snug line-clamp-2 group-hover:text-primary transition-colors">{article.title}</h3>
|
||||
<p className="text-xs text-muted-foreground mt-1 leading-relaxed line-clamp-2">{article.excerpt}</p>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
function NativeAdCard() {
|
||||
return (
|
||||
<div className="relative rounded-lg overflow-hidden h-full bg-card border border-card-border" data-testid="card-native-ad">
|
||||
<div className="relative">
|
||||
<div className="overflow-hidden aspect-video bg-muted">
|
||||
<AdSense
|
||||
slot="auto"
|
||||
format="fluid"
|
||||
layoutKey="-6t+ed+2i-1n-4w"
|
||||
style={{ display: "block" }}
|
||||
className="w-full h-full min-h-[160px]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TopStoriesList({ articles }: { articles: Article[] }) {
|
||||
return (
|
||||
<div className="bg-card rounded-lg border border-card-border p-4 h-full" data-testid="sidebar-top-stories">
|
||||
@@ -172,9 +193,7 @@ function TopStoriesList({ articles }: { articles: Article[] }) {
|
||||
<span className="text-[10px] font-medium text-primary">{article.category}</span>
|
||||
<span className="text-muted-foreground text-[10px]">{timeAgo(new Date(article.publishedAt))}</span>
|
||||
</div>
|
||||
<h4 className="text-xs font-medium text-card-foreground line-clamp-2 group-hover:text-primary transition-colors leading-snug">
|
||||
{article.title}
|
||||
</h4>
|
||||
<h4 className="text-xs font-medium text-card-foreground line-clamp-2 group-hover:text-primary transition-colors leading-snug">{article.title}</h4>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
@@ -183,6 +202,73 @@ function TopStoriesList({ articles }: { articles: Article[] }) {
|
||||
);
|
||||
}
|
||||
|
||||
function FeaturedCarousel({ articles, popular, galleryImages }: { articles: Article[]; popular?: Article[]; galleryImages?: GalleryImage[] }) {
|
||||
const hasGallery = galleryImages && galleryImages.length > 0;
|
||||
const articlePages = Math.min(5, Math.max(1, articles.length));
|
||||
const total = articlePages + (hasGallery ? 1 : 0);
|
||||
const [page, setPage] = useState(0);
|
||||
const [paused, setPaused] = useState(false);
|
||||
|
||||
const next = useCallback(() => {
|
||||
setPage((p) => (p + 1) % total);
|
||||
}, [total]);
|
||||
|
||||
useEffect(() => {
|
||||
if (paused || total <= 1) return;
|
||||
const timer = setInterval(next, 8000);
|
||||
return () => clearInterval(timer);
|
||||
}, [paused, next, total]);
|
||||
|
||||
const isGalleryPage = hasGallery && page === total - 1;
|
||||
const isWidePage = page === 1;
|
||||
|
||||
let hero: Article | null = null;
|
||||
let side: Article[] = [];
|
||||
|
||||
if (!isGalleryPage && articles.length > 0) {
|
||||
hero = articles[page % articles.length];
|
||||
if (!isWidePage) {
|
||||
side = [
|
||||
articles[(page * 2 + 1) % articles.length],
|
||||
articles[(page * 2 + 2) % articles.length],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
const wide = isGalleryPage || isWidePage;
|
||||
|
||||
return (
|
||||
<section data-testid="featured-carousel" onMouseEnter={() => setPaused(true)} onMouseLeave={() => setPaused(false)}>
|
||||
<div className="grid grid-cols-1 lg:grid-cols-6 gap-4">
|
||||
<div className={wide ? "lg:col-span-5" : "lg:col-span-3"}>
|
||||
{isGalleryPage && galleryImages ? (
|
||||
<GalleryHeroCard images={galleryImages.slice(0, 30)} />
|
||||
) : hero ? (
|
||||
<HeroCard article={hero} />
|
||||
) : null}
|
||||
</div>
|
||||
{!wide && (
|
||||
<div className="lg:col-span-2 grid grid-cols-1 gap-3">
|
||||
{side.map((a) => (
|
||||
<SideCard key={a.id} article={a} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="lg:col-span-1">
|
||||
{popular && popular.length > 0 && <TopStoriesList articles={popular} />}
|
||||
</div>
|
||||
</div>
|
||||
{total > 1 && (
|
||||
<div className="flex justify-center gap-2 mt-3" data-testid="carousel-dots">
|
||||
{Array.from({ length: total }).map((_, i) => (
|
||||
<button key={i} onClick={() => setPage(i)} className={`w-2.5 h-2.5 rounded-full transition-all duration-300 ${i === page ? "bg-primary w-6" : "bg-muted-foreground/30 hover:bg-muted-foreground/50"}`} data-testid={`button-carousel-dot-${i}`} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function BentoSkeleton() {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
@@ -199,75 +285,6 @@ function BentoSkeleton() {
|
||||
);
|
||||
}
|
||||
|
||||
function FeaturedCarousel({ articles, popular }: { articles: Article[]; popular?: Article[] }) {
|
||||
const totalPages = Math.ceil(Math.min(articles.length, 9) / 3);
|
||||
const [page, setPage] = useState(0);
|
||||
const [paused, setPaused] = useState(false);
|
||||
|
||||
const next = useCallback(() => {
|
||||
setPage((p) => (p + 1) % totalPages);
|
||||
}, [totalPages]);
|
||||
|
||||
useEffect(() => {
|
||||
if (paused || totalPages <= 1) return;
|
||||
const timer = setInterval(next, 8000);
|
||||
return () => clearInterval(timer);
|
||||
}, [paused, next, totalPages]);
|
||||
|
||||
const pool = articles.slice(0, 9);
|
||||
const start = page * 3;
|
||||
const visible = pool.slice(start, start + 3);
|
||||
while (visible.length < 3 && pool.length >= 3) {
|
||||
visible.push(pool[visible.length % pool.length]);
|
||||
}
|
||||
|
||||
const hero = visible[0];
|
||||
const side = visible.slice(1, 3);
|
||||
|
||||
if (!hero) return null;
|
||||
|
||||
return (
|
||||
<section
|
||||
data-testid="featured-carousel"
|
||||
onMouseEnter={() => setPaused(true)}
|
||||
onMouseLeave={() => setPaused(false)}
|
||||
>
|
||||
<div className="grid grid-cols-1 lg:grid-cols-6 gap-4">
|
||||
<div className="lg:col-span-3">
|
||||
<HeroCard article={hero} />
|
||||
</div>
|
||||
|
||||
<div className="lg:col-span-2 grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-1 gap-4">
|
||||
{side.map((a) => (
|
||||
<MediumCard key={a.id} article={a} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="lg:col-span-1">
|
||||
{popular && popular.length > 0 && (
|
||||
<TopStoriesList articles={popular} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{totalPages > 1 && (
|
||||
<div className="flex justify-center gap-2 mt-3" data-testid="carousel-dots">
|
||||
{Array.from({ length: totalPages }).map((_, i) => (
|
||||
<button
|
||||
key={i}
|
||||
onClick={() => setPage(i)}
|
||||
className={`w-2.5 h-2.5 rounded-full transition-all duration-300 ${
|
||||
i === page ? "bg-primary w-6" : "bg-muted-foreground/30 hover:bg-muted-foreground/50"
|
||||
}`}
|
||||
data-testid={`button-carousel-dot-${i}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Home() {
|
||||
const { data: articles, isLoading } = useQuery<Article[]>({
|
||||
queryKey: ["/api/articles"],
|
||||
@@ -277,60 +294,71 @@ export default function Home() {
|
||||
queryKey: ["/api/articles/popular"],
|
||||
});
|
||||
|
||||
const { data: galleryImages } = useQuery<GalleryImage[]>({
|
||||
queryKey: ["/api/gallery"],
|
||||
});
|
||||
|
||||
if (isLoading || !articles) {
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<Header />
|
||||
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4">
|
||||
<BentoSkeleton />
|
||||
</main>
|
||||
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4"><BentoSkeleton /></main>
|
||||
<Footer />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const all = articles;
|
||||
const row2Items = all.slice(3, 6);
|
||||
const row3Items = all.slice(6);
|
||||
const row2Left = articles.slice(0, 2);
|
||||
const row3Middle = articles.slice(2, 4);
|
||||
const row4Articles = articles.slice(4, 7);
|
||||
const row5Articles = articles.slice(7, 10);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<Header />
|
||||
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4 space-y-4">
|
||||
|
||||
<FeaturedCarousel articles={all} popular={popular} />
|
||||
<FeaturedCarousel articles={articles} popular={popular} galleryImages={galleryImages} />
|
||||
|
||||
<div className="rounded-lg border border-card-border bg-card overflow-hidden">
|
||||
<AdSense
|
||||
slot="auto"
|
||||
format="horizontal"
|
||||
style={{ display: "block", minHeight: "90px" }}
|
||||
/>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{row2Left.map((a) => (
|
||||
<MediumCard key={a.id} article={a} />
|
||||
))}
|
||||
<div className="aspect-[4/5] sm:aspect-auto">
|
||||
<PhotoGalleryWidget />
|
||||
</div>
|
||||
<div className="aspect-[4/5] sm:aspect-auto">
|
||||
<RecipeWidget />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{row2Items.length > 0 && (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<div className="aspect-[4/5] sm:aspect-auto">
|
||||
<HoroscopeWidget />
|
||||
</div>
|
||||
{row3Middle.map((a) => (
|
||||
<MediumCard key={a.id} article={a} />
|
||||
))}
|
||||
<div className="aspect-[4/5] sm:aspect-auto">
|
||||
<NewsWidget />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{row4Articles.length > 0 && (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{row2Items.map((a) => (
|
||||
{row4Articles.map((a) => (
|
||||
<MediumCard key={a.id} article={a} />
|
||||
))}
|
||||
<ArticleCardAd key="ad-row2" />
|
||||
<NativeAdCard />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{row3Items.length > 0 && (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-6 gap-4">
|
||||
<div className="lg:col-span-3">
|
||||
{row3Items[0] && <HeroCard article={row3Items[0]} />}
|
||||
</div>
|
||||
<div className="lg:col-span-3 grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
{row3Items.slice(1, 3).map((a) => (
|
||||
<MediumCard key={a.id} article={a} />
|
||||
))}
|
||||
{row3Items.slice(3).map((a) => (
|
||||
<CompactCard key={a.id} article={a} />
|
||||
))}
|
||||
<ArticleCardAd key="ad-row3" />
|
||||
</div>
|
||||
{row5Articles.length > 0 && (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{row5Articles.map((a) => (
|
||||
<MediumCard key={a.id} article={a} />
|
||||
))}
|
||||
<NativeAdCard />
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -0,0 +1,438 @@
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Link, useParams } from "wouter";
|
||||
import {
|
||||
Star,
|
||||
Heart,
|
||||
Briefcase,
|
||||
TrendingUp,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Calendar,
|
||||
CalendarDays,
|
||||
Sparkles,
|
||||
Lightbulb,
|
||||
AlertTriangle,
|
||||
Moon,
|
||||
Sun,
|
||||
ArrowRight,
|
||||
Loader2,
|
||||
} from "lucide-react";
|
||||
import Header from "@/components/header";
|
||||
import Footer from "@/components/footer";
|
||||
import { InArticleAd } from "@/components/adsense";
|
||||
import {
|
||||
SIGNS,
|
||||
ELEMENT_COLORS,
|
||||
ASTRO_EVENTS,
|
||||
getHoroscope as getStaticHoroscope,
|
||||
getRating,
|
||||
getLuckyNumbers,
|
||||
getDailyColor,
|
||||
} from "@/lib/horoscope-data";
|
||||
|
||||
interface AIHoroscope {
|
||||
signIndex: number;
|
||||
signName: string;
|
||||
general: string;
|
||||
love: string;
|
||||
career: string;
|
||||
health: string;
|
||||
tip: string;
|
||||
weekly: string;
|
||||
monthly: string;
|
||||
}
|
||||
|
||||
function StarRating({ count }: { count: number }) {
|
||||
return (
|
||||
<div className="flex gap-0.5">
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<Star key={i} className={`w-3.5 h-3.5 ${i < count ? "fill-primary text-primary" : "text-muted-foreground/30"}`} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function getEventIcon(icon: string) {
|
||||
switch (icon) {
|
||||
case "mercury": return <AlertTriangle className="w-5 h-5 text-amber-400" />;
|
||||
case "moon": return <Moon className="w-5 h-5 text-blue-300" />;
|
||||
case "venus": return <Heart className="w-5 h-5 text-pink-400" />;
|
||||
case "sun": return <Sun className="w-5 h-5 text-amber-300" />;
|
||||
case "saturn": return <Briefcase className="w-5 h-5 text-slate-400" />;
|
||||
case "newmoon": return <Moon className="w-5 h-5 text-violet-400" />;
|
||||
default: return <Star className="w-5 h-5 text-primary" />;
|
||||
}
|
||||
}
|
||||
|
||||
function getEventColor(type: string) {
|
||||
switch (type) {
|
||||
case "retrograde": return { bg: "bg-amber-500/10", border: "border-amber-500/20", accent: "text-amber-400" };
|
||||
case "moon": return { bg: "bg-blue-500/10", border: "border-blue-500/20", accent: "text-blue-400" };
|
||||
case "transit": return { bg: "bg-pink-500/10", border: "border-pink-500/20", accent: "text-pink-400" };
|
||||
case "season": return { bg: "bg-emerald-500/10", border: "border-emerald-500/20", accent: "text-emerald-400" };
|
||||
default: return { bg: "bg-primary/10", border: "border-primary/20", accent: "text-primary" };
|
||||
}
|
||||
}
|
||||
|
||||
function AstroEventsSection() {
|
||||
return (
|
||||
<section className="mb-10" data-testid="section-astro-events">
|
||||
<h2 className="text-lg font-bold text-foreground flex items-center gap-2 mb-4">
|
||||
<Sparkles className="w-5 h-5 text-primary" />
|
||||
Aktuelle kosmische Ereignisse
|
||||
</h2>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{ASTRO_EVENTS.map((event, i) => {
|
||||
const ec = getEventColor(event.type);
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
className={`${ec.bg} border ${ec.border} rounded-xl p-4 transition-all hover:scale-[1.02]`}
|
||||
data-testid={`card-astro-event-${i}`}
|
||||
>
|
||||
<div className="flex items-start gap-3 mb-2">
|
||||
{getEventIcon(event.icon)}
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className={`font-semibold text-sm ${ec.accent}`}>{event.title}</h3>
|
||||
<p className="text-[11px] text-muted-foreground">{event.dateRange}</p>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-foreground/70 leading-relaxed mb-3">{event.description}</p>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{event.affectedSigns.map((s) => {
|
||||
const sign = SIGNS.find((x) => x.name === s);
|
||||
return (
|
||||
<span key={s} className="text-[10px] bg-white/5 border border-white/10 rounded px-1.5 py-0.5 text-muted-foreground">
|
||||
{sign?.symbol} {s}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function SignGrid({ onSelect, selectedIndex, aiHoroscopes }: { onSelect: (i: number) => void; selectedIndex: number | null; aiHoroscopes: AIHoroscope[] }) {
|
||||
return (
|
||||
<section className="mb-10" data-testid="section-sign-grid">
|
||||
<h2 className="text-lg font-bold text-foreground flex items-center gap-2 mb-4">
|
||||
<Star className="w-5 h-5 text-primary" />
|
||||
Ihr Sternzeichen wählen
|
||||
</h2>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 gap-3">
|
||||
{SIGNS.map((sign, i) => {
|
||||
const ec = ELEMENT_COLORS[sign.element];
|
||||
const aiH = aiHoroscopes.find(h => h.signIndex === i);
|
||||
const horoscope = aiH || getStaticHoroscope(i);
|
||||
const isSelected = selectedIndex === i;
|
||||
return (
|
||||
<button
|
||||
key={sign.name}
|
||||
onClick={() => onSelect(i)}
|
||||
className={`text-left rounded-xl p-4 transition-all duration-200 border ${
|
||||
isSelected
|
||||
? `${ec.bg} ${ec.border} shadow-lg ${ec.glow} ring-1 ring-primary/30`
|
||||
: "bg-card border-card-border hover:border-primary/40 hover:shadow-md"
|
||||
}`}
|
||||
data-testid={`button-sign-grid-${sign.name.toLowerCase()}`}
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span className="text-2xl">{sign.symbol}</span>
|
||||
<div>
|
||||
<span className={`text-sm font-semibold block ${isSelected ? ec.text : "text-foreground"}`}>{sign.name}</span>
|
||||
<span className="text-[10px] text-muted-foreground">{sign.date}</span>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-[11px] text-foreground/60 leading-relaxed line-clamp-3">{horoscope.general.substring(0, 100)}...</p>
|
||||
<div className="flex items-center gap-1 mt-2 text-[10px] text-primary">
|
||||
Lesen <ArrowRight className="w-3 h-3" />
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function SignDetail({ signIndex, onNavigate, aiHoroscopes }: { signIndex: number; onNavigate: (i: number) => void; aiHoroscopes: AIHoroscope[] }) {
|
||||
const [tab, setTab] = useState<"daily" | "weekly" | "monthly">("daily");
|
||||
const sign = SIGNS[signIndex];
|
||||
const ec = ELEMENT_COLORS[sign.element];
|
||||
const aiH = aiHoroscopes.find(h => h.signIndex === signIndex);
|
||||
const horoscope = aiH || getStaticHoroscope(signIndex);
|
||||
const luckyNums = getLuckyNumbers(signIndex);
|
||||
const dailyColor = getDailyColor(signIndex);
|
||||
const detailRef = useRef<HTMLDivElement>(null);
|
||||
const isAI = !!aiH;
|
||||
|
||||
const prevIndex = (signIndex - 1 + SIGNS.length) % SIGNS.length;
|
||||
const nextIndex = (signIndex + 1) % SIGNS.length;
|
||||
|
||||
useEffect(() => {
|
||||
setTab("daily");
|
||||
}, [signIndex]);
|
||||
|
||||
return (
|
||||
<section ref={detailRef} data-testid={`section-sign-detail-${sign.name.toLowerCase()}`}>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<button
|
||||
onClick={() => onNavigate(prevIndex)}
|
||||
className="flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground transition-colors bg-card border border-card-border rounded-lg px-3 py-2"
|
||||
data-testid="button-sign-prev"
|
||||
>
|
||||
<ChevronLeft className="w-4 h-4" />
|
||||
<span className="text-lg">{SIGNS[prevIndex].symbol}</span>
|
||||
<span className="hidden sm:inline">{SIGNS[prevIndex].name}</span>
|
||||
</button>
|
||||
<h2 className="text-lg font-bold text-foreground flex items-center gap-2">
|
||||
<span className="text-3xl">{sign.symbol}</span>
|
||||
{sign.name}
|
||||
</h2>
|
||||
<button
|
||||
onClick={() => onNavigate(nextIndex)}
|
||||
className="flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground transition-colors bg-card border border-card-border rounded-lg px-3 py-2"
|
||||
data-testid="button-sign-next"
|
||||
>
|
||||
<span className="hidden sm:inline">{SIGNS[nextIndex].name}</span>
|
||||
<span className="text-lg">{SIGNS[nextIndex].symbol}</span>
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-0">
|
||||
<div className="bg-card rounded-xl border border-card-border p-6 md:p-8" data-testid={`card-horoscope-${sign.name.toLowerCase()}`}>
|
||||
<div className="flex items-start gap-4 mb-6">
|
||||
<div className={`w-20 h-20 rounded-2xl ${ec.bg} border ${ec.border} flex items-center justify-center flex-shrink-0`}>
|
||||
<span className="text-5xl">{sign.symbol}</span>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h3 className={`text-2xl font-bold ${ec.text}`}>{sign.name}</h3>
|
||||
<p className="text-sm text-muted-foreground">{sign.date}</p>
|
||||
<div className="flex flex-wrap gap-2 mt-2">
|
||||
<span className={`text-[10px] font-medium px-2 py-0.5 rounded ${ec.bg} ${ec.text} border ${ec.border}`}>{sign.element}</span>
|
||||
<span className="text-[10px] bg-muted px-2 py-0.5 rounded text-muted-foreground">Planet: {sign.planet}</span>
|
||||
<span className="text-[10px] bg-muted px-2 py-0.5 rounded text-muted-foreground">Farbe: {sign.color}</span>
|
||||
<span className="text-[10px] bg-muted px-2 py-0.5 rounded text-muted-foreground">Stein: {sign.stone}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-3 sm:grid-cols-6 gap-3 mb-6">
|
||||
<div className="bg-muted/30 rounded-lg p-3 text-center">
|
||||
<Heart className="w-4 h-4 text-red-400 mx-auto mb-1" />
|
||||
<p className="text-[10px] text-muted-foreground mb-1">Liebe</p>
|
||||
<StarRating count={getRating(signIndex, "love")} />
|
||||
</div>
|
||||
<div className="bg-muted/30 rounded-lg p-3 text-center">
|
||||
<Briefcase className="w-4 h-4 text-amber-400 mx-auto mb-1" />
|
||||
<p className="text-[10px] text-muted-foreground mb-1">Beruf</p>
|
||||
<StarRating count={getRating(signIndex, "career")} />
|
||||
</div>
|
||||
<div className="bg-muted/30 rounded-lg p-3 text-center">
|
||||
<TrendingUp className="w-4 h-4 text-emerald-400 mx-auto mb-1" />
|
||||
<p className="text-[10px] text-muted-foreground mb-1">Gesundheit</p>
|
||||
<StarRating count={getRating(signIndex, "health")} />
|
||||
</div>
|
||||
<div className="bg-muted/30 rounded-lg p-3 text-center">
|
||||
<Sparkles className="w-4 h-4 text-violet-400 mx-auto mb-1" />
|
||||
<p className="text-[10px] text-muted-foreground mb-1">Glückszahlen</p>
|
||||
<p className="text-xs font-bold text-foreground" data-testid="text-lucky-numbers">{luckyNums.join(", ")}</p>
|
||||
</div>
|
||||
<div className="bg-muted/30 rounded-lg p-3 text-center">
|
||||
<div className="w-4 h-4 rounded-full bg-primary/60 mx-auto mb-1" />
|
||||
<p className="text-[10px] text-muted-foreground mb-1">Tagesfarbe</p>
|
||||
<p className="text-xs font-bold text-foreground" data-testid="text-daily-color">{dailyColor}</p>
|
||||
</div>
|
||||
<div className="bg-muted/30 rounded-lg p-3 text-center">
|
||||
<Heart className="w-4 h-4 text-pink-400 mx-auto mb-1" />
|
||||
<p className="text-[10px] text-muted-foreground mb-1">Kompatibel</p>
|
||||
<p className="text-[10px] font-medium text-foreground leading-tight" data-testid="text-compatible-signs">{sign.compatible.join(", ")}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-1 mb-6 bg-muted/30 rounded-lg p-1">
|
||||
{(["daily", "weekly", "monthly"] as const).map((t) => (
|
||||
<button
|
||||
key={t}
|
||||
onClick={() => setTab(t)}
|
||||
className={`flex-1 text-xs font-medium py-2 px-3 rounded-md transition-all flex items-center justify-center gap-1.5 ${
|
||||
tab === t ? "bg-primary text-white shadow" : "text-muted-foreground hover:text-foreground"
|
||||
}`}
|
||||
data-testid={`button-tab-${t}`}
|
||||
>
|
||||
{t === "daily" && <Star className="w-3 h-3" />}
|
||||
{t === "weekly" && <Calendar className="w-3 h-3" />}
|
||||
{t === "monthly" && <CalendarDays className="w-3 h-3" />}
|
||||
{t === "daily" ? "Heute" : t === "weekly" ? "Woche" : "Monat"}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{tab === "daily" && (
|
||||
<div className="space-y-5" data-testid="section-horoscope-daily">
|
||||
<div>
|
||||
<h3 className="font-semibold text-foreground flex items-center gap-2 mb-2">
|
||||
<Star className="w-4 h-4 text-primary" /> Allgemein
|
||||
</h3>
|
||||
<p className="text-sm text-foreground/80 leading-relaxed" data-testid="text-horoscope-general">{horoscope.general}</p>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold text-foreground flex items-center gap-2 mb-2">
|
||||
<Heart className="w-4 h-4 text-red-400" /> Liebe & Partnerschaft
|
||||
</h3>
|
||||
<p className="text-sm text-foreground/80 leading-relaxed" data-testid="text-horoscope-love">{horoscope.love}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tab === "weekly" && (
|
||||
<div data-testid="section-horoscope-weekly">
|
||||
<h3 className="font-semibold text-foreground flex items-center gap-2 mb-2">
|
||||
<Calendar className="w-4 h-4 text-primary" /> Wochenhoroskop
|
||||
</h3>
|
||||
<p className="text-sm text-foreground/80 leading-relaxed" data-testid="text-horoscope-weekly">{horoscope.weekly}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tab === "monthly" && (
|
||||
<div data-testid="section-horoscope-monthly">
|
||||
<h3 className="font-semibold text-foreground flex items-center gap-2 mb-2">
|
||||
<CalendarDays className="w-4 h-4 text-primary" /> Monatshoroskop
|
||||
</h3>
|
||||
<p className="text-sm text-foreground/80 leading-relaxed" data-testid="text-horoscope-monthly">{horoscope.monthly}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<InArticleAd />
|
||||
|
||||
{tab === "daily" && (
|
||||
<div className="bg-card rounded-xl border border-card-border p-6 md:p-8" data-testid="card-horoscope-detail-2">
|
||||
<div className="space-y-5">
|
||||
<div>
|
||||
<h3 className="font-semibold text-foreground flex items-center gap-2 mb-2">
|
||||
<Briefcase className="w-4 h-4 text-amber-400" /> Beruf & Finanzen
|
||||
</h3>
|
||||
<p className="text-sm text-foreground/80 leading-relaxed">{horoscope.career}</p>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold text-foreground flex items-center gap-2 mb-2">
|
||||
<TrendingUp className="w-4 h-4 text-emerald-400" /> Gesundheit & Wohlbefinden
|
||||
</h3>
|
||||
<p className="text-sm text-foreground/80 leading-relaxed">{horoscope.health}</p>
|
||||
</div>
|
||||
<div className="bg-primary/10 border border-primary/20 rounded-lg p-4" data-testid="card-horoscope-tip">
|
||||
<p className="text-sm text-foreground/90 font-medium flex items-start gap-2">
|
||||
<Lightbulb className="w-5 h-5 text-primary flex-shrink-0 mt-0.5" />
|
||||
<span><strong>Tipp des Tages:</strong> {horoscope.tip}</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<InArticleAd />
|
||||
|
||||
<div className="bg-card rounded-xl border border-card-border p-6" data-testid="card-horoscope-others">
|
||||
<h3 className="font-semibold text-foreground mb-4">Weitere Sternzeichen entdecken</h3>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-3">
|
||||
{SIGNS.filter((_, i) => i !== signIndex).map((s) => {
|
||||
const origIdx = SIGNS.findIndex((x) => x.name === s.name);
|
||||
const sEc = ELEMENT_COLORS[s.element];
|
||||
return (
|
||||
<button
|
||||
key={s.name}
|
||||
onClick={() => onNavigate(origIdx)}
|
||||
className={`${sEc.bg} hover:opacity-80 border ${sEc.border} rounded-lg p-3 transition-all text-left`}
|
||||
data-testid={`button-sign-other-${s.name.toLowerCase()}`}
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="text-xl">{s.symbol}</span>
|
||||
<span className={`font-medium text-sm ${sEc.text}`}>{s.name}</span>
|
||||
</div>
|
||||
<p className="text-[10px] text-muted-foreground">{s.date}</p>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export default function HoroscopePage() {
|
||||
const params = useParams<{ sign?: string }>();
|
||||
const [selected, setSelected] = useState<number | null>(null);
|
||||
const detailRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const { data: aiHoroscopes = [], isLoading: aiLoading } = useQuery<AIHoroscope[]>({
|
||||
queryKey: ["/api/horoscopes/today"],
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (params.sign) {
|
||||
const idx = SIGNS.findIndex((s) => s.name.toLowerCase() === params.sign?.toLowerCase());
|
||||
if (idx >= 0) setSelected(idx);
|
||||
}
|
||||
}, [params.sign]);
|
||||
|
||||
const handleSelect = (i: number) => {
|
||||
setSelected(i);
|
||||
setTimeout(() => {
|
||||
detailRef.current?.scrollIntoView({ behavior: "smooth", block: "start" });
|
||||
}, 100);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<Header />
|
||||
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<Link href="/">
|
||||
<button className="text-muted-foreground hover:text-foreground transition-colors" data-testid="button-back-home">
|
||||
<ChevronLeft className="w-5 h-5" />
|
||||
</button>
|
||||
</Link>
|
||||
<h1 className="text-2xl font-bold text-foreground flex items-center gap-2" data-testid="text-horoscope-title">
|
||||
<Star className="w-6 h-6 text-primary" />
|
||||
Horoskop
|
||||
</h1>
|
||||
</div>
|
||||
<p className="text-muted-foreground text-sm mb-8 ml-8" data-testid="text-horoscope-subtitle">
|
||||
Entdecken Sie, was die Sterne für Sie bereithalten. Aktuelle kosmische Ereignisse und Ihr persönliches Tageshoroskop.
|
||||
</p>
|
||||
|
||||
{aiLoading && (
|
||||
<div className="flex items-center gap-2 mb-6 text-sm text-primary bg-primary/10 border border-primary/20 rounded-lg px-4 py-2" data-testid="text-horoscope-loading">
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
Horoskope werden von den Sternen gelesen...
|
||||
</div>
|
||||
)}
|
||||
|
||||
<AstroEventsSection />
|
||||
|
||||
<SignGrid onSelect={handleSelect} selectedIndex={selected} aiHoroscopes={aiHoroscopes} />
|
||||
|
||||
<div ref={detailRef}>
|
||||
{selected !== null ? (
|
||||
<SignDetail signIndex={selected} onNavigate={handleSelect} aiHoroscopes={aiHoroscopes} />
|
||||
) : (
|
||||
<div className="text-center py-16 bg-card rounded-xl border border-card-border" data-testid="text-horoscope-prompt">
|
||||
<Sparkles className="w-16 h-16 text-primary mx-auto mb-4" />
|
||||
<p className="text-muted-foreground text-lg">Wählen Sie oben Ihr Sternzeichen</p>
|
||||
<p className="text-muted-foreground/60 text-sm mt-1">für Ihr persönliches Tages-, Wochen- und Monatshoroskop</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
<Footer />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,360 @@
|
||||
import { useState } from "react";
|
||||
import { Link } from "wouter";
|
||||
import { ChefHat, Clock, Users, X, ChevronLeft } from "lucide-react";
|
||||
import Header from "@/components/header";
|
||||
import Footer from "@/components/footer";
|
||||
import { InArticleAd } from "@/components/adsense";
|
||||
|
||||
interface Recipe {
|
||||
title: string;
|
||||
image: string;
|
||||
time: string;
|
||||
servings: string;
|
||||
ingredients: string[];
|
||||
steps: string[];
|
||||
description: string;
|
||||
category: string;
|
||||
}
|
||||
|
||||
interface RecipeRegion {
|
||||
name: string;
|
||||
recipes: Recipe[];
|
||||
}
|
||||
|
||||
const RECIPE_REGIONS: RecipeRegion[] = [
|
||||
{
|
||||
name: "Österreich",
|
||||
recipes: [
|
||||
{
|
||||
title: "Kaiserschmarrn",
|
||||
image: "/uploads/recipe-kaiserschmarrn.png",
|
||||
time: "25 Min.",
|
||||
servings: "2 Portionen",
|
||||
category: "Nachspeise",
|
||||
description: "Der Klassiker der österreichischen Küche -- fluffig, süß und einfach unwiderstehlich.",
|
||||
ingredients: ["3 Eier", "200 ml Milch", "120 g Mehl", "30 g Zucker", "1 Prise Salz", "50 g Butter", "Rosinen nach Belieben", "Puderzucker"],
|
||||
steps: ["Eigelb, Milch, Mehl und Salz verrühren.", "Eiweiß mit Zucker steif schlagen und unterheben.", "Butter in der Pfanne erhitzen, Teig eingießen.", "Rosinen darüber streuen, von unten goldbraun backen.", "Mit zwei Gabeln in Stücke reißen.", "Mit Puderzucker bestreut servieren."],
|
||||
},
|
||||
{
|
||||
title: "Wiener Schnitzel",
|
||||
image: "/uploads/recipe-wiener-schnitzel.png",
|
||||
time: "30 Min.",
|
||||
servings: "4 Portionen",
|
||||
category: "Hauptspeise",
|
||||
description: "Das berühmteste Gericht Wiens -- goldbraun paniert und herrlich knusprig.",
|
||||
ingredients: ["4 Kalbsschnitzel", "2 Eier", "Mehl", "Semmelbrösel", "Butterschmalz", "Salz", "Zitrone"],
|
||||
steps: ["Schnitzel dünn klopfen, salzen.", "In Mehl, verquirltem Ei und Semmelbröseln panieren.", "In reichlich heißem Butterschmalz goldbraun backen.", "Auf Küchenpapier abtropfen lassen.", "Mit Zitrone und Petersilienkartoffeln servieren."],
|
||||
},
|
||||
{
|
||||
title: "Apfelstrudel",
|
||||
image: "/uploads/recipe-apfelstrudel.png",
|
||||
time: "60 Min.",
|
||||
servings: "6 Portionen",
|
||||
category: "Nachspeise",
|
||||
description: "Dünn ausgezogener Strudelteig mit saftiger Apfelfüllung -- ein Traum aus Omas Küche.",
|
||||
ingredients: ["250 g Mehl", "1 Ei", "2 EL Öl", "125 ml Wasser", "1 kg Äpfel", "100 g Zucker", "Zimt", "80 g Semmelbrösel", "80 g Butter"],
|
||||
steps: ["Strudelteig kneten, 30 Min. ruhen lassen.", "Äpfel schälen, in dünne Scheiben schneiden.", "Mit Zucker, Zimt und Rosinen mischen.", "Teig dünn ausziehen, Brösel verteilen.", "Füllung auflegen, einrollen.", "Bei 180°C 40 Min. goldbraun backen."],
|
||||
},
|
||||
{
|
||||
title: "Tiroler Knödel",
|
||||
image: "/uploads/recipe-tiroler-knoedel.png",
|
||||
time: "40 Min.",
|
||||
servings: "4 Portionen",
|
||||
category: "Hauptspeise",
|
||||
description: "Herzhafte Speckknödel aus Tirol -- perfekt zu einer kräftigen Suppe oder Sauerkraut.",
|
||||
ingredients: ["300 g Knödelbrot", "200 ml Milch", "3 Eier", "150 g Speck", "1 Zwiebel", "Petersilie", "Salz, Pfeffer", "Mehl"],
|
||||
steps: ["Knödelbrot in eine Schüssel geben, warme Milch darüber.", "Speck und Zwiebel anbraten.", "Eier, Speck, Petersilie zum Brot geben, mischen.", "30 Min. rasten lassen.", "Knödel formen, in Salzwasser 15 Min. kochen.", "Mit Butter und Schnittlauch servieren."],
|
||||
},
|
||||
{
|
||||
title: "Sachertorte",
|
||||
image: "/uploads/recipe-sachertorte.png",
|
||||
time: "90 Min.",
|
||||
servings: "8 Portionen",
|
||||
category: "Nachspeise",
|
||||
description: "Die weltberühmte Wiener Schokoladentorte mit Marillenmarmelade und Glasur.",
|
||||
ingredients: ["150 g Butter", "110 g Zucker", "6 Eier", "130 g Zartbitterschokolade", "130 g Mehl", "Marillenmarmelade", "200 g Kuvertüre"],
|
||||
steps: ["Butter und Zucker schaumig rühren.", "Geschmolzene Schokolade und Eigelb unterrühren.", "Eiweiß steif schlagen, mit Mehl unterheben.", "Bei 170°C 50 Min. backen.", "Auskühlen lassen, mit Marmelade bestreichen.", "Mit Schokoladen-Glasur überziehen."],
|
||||
},
|
||||
{
|
||||
title: "Kärntner Kasnudeln",
|
||||
image: "/uploads/recipe-kaerntner-kasnudeln.png",
|
||||
time: "50 Min.",
|
||||
servings: "4 Portionen",
|
||||
category: "Hauptspeise",
|
||||
description: "Handgemachte Teigtaschen aus Kärnten mit Topfen-Kartoffel-Füllung und frischer Minze.",
|
||||
ingredients: ["400 g Mehl", "2 Eier", "Wasser", "500 g Topfen", "200 g Kartoffeln", "Minze", "Kerbel", "Butter", "Salz"],
|
||||
steps: ["Nudelteig aus Mehl, Eiern und Wasser herstellen.", "Kartoffeln kochen und stampfen.", "Mit Topfen, Minze und Kerbel mischen.", "Teig ausrollen, Kreise ausstechen.", "Füllung aufsetzen, Ränder krendeln.", "In Salzwasser kochen, mit Butter servieren."],
|
||||
},
|
||||
{
|
||||
title: "Germknödel",
|
||||
image: "/uploads/recipe-germknoedel.png",
|
||||
time: "120 Min.",
|
||||
servings: "4 Portionen",
|
||||
category: "Nachspeise",
|
||||
description: "Fluffige Hefeteig-Knödel mit Powidl-Füllung, Vanillesoße und Mohn-Zucker-Topping.",
|
||||
ingredients: ["500 g Mehl", "250 ml Milch", "1 Würfel Hefe", "60 g Zucker", "60 g Butter", "2 Eigelb", "Powidl (Pflaumenmus)", "Mohn", "Vanillesoße"],
|
||||
steps: ["Hefeteig zubereiten und 1 Stunde gehen lassen.", "Teig in Portionen teilen, flach drücken.", "Je 1 TL Powidl in die Mitte geben.", "Zu Knödeln formen, nochmals 20 Min. gehen lassen.", "In Salzwasser oder über Dampf 15 Min. garen.", "Mit Mohn-Zucker und Vanillesoße servieren."],
|
||||
},
|
||||
{
|
||||
title: "Tafelspitz",
|
||||
image: "/uploads/recipe-tafelspitz.png",
|
||||
time: "180 Min.",
|
||||
servings: "6 Portionen",
|
||||
category: "Hauptspeise",
|
||||
description: "Zart gekochtes Rindfleisch mit Schnittlauchsoße und Apfelkren -- ein Festmahl der Wiener Küche.",
|
||||
ingredients: ["1,5 kg Tafelspitz", "Suppengemüse", "Lorbeerblatt", "Pfefferkörner", "Schnittlauch", "Sauerrahm", "Äpfel", "Kren (Meerrettich)"],
|
||||
steps: ["Fleisch in kaltes Wasser legen, langsam erhitzen.", "Schaum abschöpfen, Gemüse und Gewürze zugeben.", "3 Stunden sanft köcheln lassen.", "Schnittlauchsoße aus Sauerrahm zubereiten.", "Apfelkren frisch reiben und mischen.", "Fleisch aufschneiden, mit Soßen und Gemüse servieren."],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "Bayern",
|
||||
recipes: [
|
||||
{
|
||||
title: "Schweinshaxe",
|
||||
image: "/uploads/recipe-schweinshaxe.png",
|
||||
time: "180 Min.",
|
||||
servings: "4 Portionen",
|
||||
category: "Hauptspeise",
|
||||
description: "Knusprig gebratene Schweinshaxe mit krachender Kruste -- das Herzstück jeder bayerischen Wirtschaft.",
|
||||
ingredients: ["4 Schweinshaxen", "2 Zwiebeln", "2 Karotten", "500 ml Dunkelbier", "Kümmel", "Knoblauch", "Salz, Pfeffer", "Majoran"],
|
||||
steps: ["Haxen waschen, Schwarte rautenförmig einschneiden.", "Mit Salz, Kümmel und Knoblauch einreiben.", "Mit Gemüse und Bier in die Bratpfanne geben.", "Bei 180°C ca. 2,5 Stunden braten.", "Regelmäßig mit Bratensud übergießen.", "Zum Schluss Grill zuschalten für knusprige Kruste."],
|
||||
},
|
||||
{
|
||||
title: "Obatzda",
|
||||
image: "/uploads/recipe-obatzda.png",
|
||||
time: "15 Min.",
|
||||
servings: "4 Portionen",
|
||||
category: "Vorspeise",
|
||||
description: "Der bayerische Biergarten-Klassiker -- würziger Camembert-Aufstrich mit Brezn.",
|
||||
ingredients: ["200 g reifer Camembert", "100 g Frischkäse", "1 kleine Zwiebel", "1 EL Butter", "Paprikapulver", "Kümmel", "Salz, Pfeffer", "Schnittlauch"],
|
||||
steps: ["Camembert mit einer Gabel zerdrücken.", "Frischkäse und weiche Butter untermischen.", "Fein gehackte Zwiebel dazugeben.", "Mit Paprika, Kümmel, Salz und Pfeffer würzen.", "Mindestens 1 Stunde durchziehen lassen.", "Mit Schnittlauch bestreut zu frischen Brezn servieren."],
|
||||
},
|
||||
{
|
||||
title: "Dampfnudeln",
|
||||
image: "/uploads/recipe-dampfnudeln.png",
|
||||
time: "90 Min.",
|
||||
servings: "4 Portionen",
|
||||
category: "Nachspeise",
|
||||
description: "Bayerische Dampfnudeln mit goldener Kruste und Vanillesoße -- wie bei der Oma auf dem Bauernhof.",
|
||||
ingredients: ["500 g Mehl", "250 ml Milch", "1 Würfel Hefe", "80 g Zucker", "80 g Butter", "2 Eier", "1 Prise Salz", "Vanillesoße"],
|
||||
steps: ["Hefe in lauwarmer Milch auflösen.", "Mit Mehl, Zucker, Eiern und Butter zu einem Teig kneten.", "1 Stunde gehen lassen, bis sich das Volumen verdoppelt.", "Kugeln formen und nochmals 20 Min. ruhen lassen.", "In einem geschlossenen Topf mit Milch-Butter-Gemisch dämpfen.", "Servieren, wenn die Kruste goldbraun knistert."],
|
||||
},
|
||||
{
|
||||
title: "Käsespätzle",
|
||||
image: "/uploads/recipe-kaesespaetzle.png",
|
||||
time: "45 Min.",
|
||||
servings: "4 Portionen",
|
||||
category: "Hauptspeise",
|
||||
description: "Handgeschabte Spätzle mit würzigem Bergkäse und knusprigen Röstzwiebeln -- ein Allgäuer Traum.",
|
||||
ingredients: ["400 g Mehl", "4 Eier", "200 ml Wasser", "300 g Bergkäse", "3 große Zwiebeln", "Butter", "Salz, Pfeffer", "Muskatnuss"],
|
||||
steps: ["Mehl, Eier, Wasser und Salz zu einem zähen Teig schlagen.", "Durch ein Spätzlebrett in kochendes Salzwasser schaben.", "Wenn die Spätzle oben schwimmen, abschöpfen.", "Zwiebeln in Butter goldbraun rösten.", "Spätzle und geriebenen Käse schichtweise in eine Form geben.", "Mit Röstzwiebeln krönen und kurz überbacken."],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "Schwaben & Baden",
|
||||
recipes: [
|
||||
{
|
||||
title: "Maultaschen",
|
||||
image: "/uploads/recipe-maultaschen.png",
|
||||
time: "90 Min.",
|
||||
servings: "4 Portionen",
|
||||
category: "Hauptspeise",
|
||||
description: "Schwäbische Herrgottsbscheißerle -- gefüllte Teigtaschen, die einst das Fleisch vor Gott versteckten.",
|
||||
ingredients: ["Nudelteig (400 g Mehl, 4 Eier)", "300 g Hackfleisch", "200 g Spinat", "1 Brötchen", "1 Zwiebel", "2 Eier", "Petersilie", "Muskatnuss, Salz, Pfeffer"],
|
||||
steps: ["Nudelteig kneten und 30 Min. ruhen lassen.", "Brötchen einweichen, Spinat blanchieren und hacken.", "Hackfleisch, Spinat, Brötchen, Eier und Gewürze mischen.", "Teig dünn ausrollen, Füllung in Häufchen setzen.", "Zuklappen, Taschen ausschneiden, Ränder fest andrücken.", "In kräftiger Brühe 10 Min. ziehen lassen oder anbraten."],
|
||||
},
|
||||
{
|
||||
title: "Zwiebelrostbraten",
|
||||
image: "/uploads/recipe-zwiebelrostbraten.png",
|
||||
time: "40 Min.",
|
||||
servings: "4 Portionen",
|
||||
category: "Hauptspeise",
|
||||
description: "Saftiges Rostbraten-Stück mit einem Berg goldener Röstzwiebeln -- der schwäbische Sonntagsklassiker.",
|
||||
ingredients: ["4 Scheiben Roastbeef (je 200 g)", "6 große Zwiebeln", "3 EL Mehl", "Butterschmalz", "200 ml Brühe", "Salz, Pfeffer", "1 Schuss Rotwein"],
|
||||
steps: ["Zwiebeln in Ringe schneiden, in Mehl wenden.", "In heißem Butterschmalz goldbraun und knusprig frittieren.", "Fleisch salzen, pfeffern und scharf anbraten.", "Fleisch herausnehmen und warm halten.", "Bratensatz mit Rotwein und Brühe ablöschen.", "Fleisch mit Soße und einem Berg Röstzwiebeln servieren."],
|
||||
},
|
||||
{
|
||||
title: "Flammkuchen",
|
||||
image: "/uploads/recipe-flammkuchen.png",
|
||||
time: "30 Min.",
|
||||
servings: "4 Portionen",
|
||||
category: "Hauptspeise",
|
||||
description: "Hauchdünner elsässisch-badischer Flammkuchen mit Crème fraîche, Speck und Zwiebeln.",
|
||||
ingredients: ["300 g Mehl", "150 ml Wasser", "3 EL Öl", "200 g Crème fraîche", "200 g Speck", "3 Zwiebeln", "Salz, Pfeffer", "Muskatnuss"],
|
||||
steps: ["Mehl, Wasser, Öl und Salz zu einem dünnen Teig kneten.", "Sehr dünn ausrollen auf einem bemehlten Blech.", "Crème fraîche gleichmäßig verstreichen.", "Mit dünnen Zwiebelringen und Speckwürfeln belegen.", "Bei 250°C auf unterster Schiene 12-15 Min. backen.", "Sofort heiß servieren, am besten mit einem Glas Weißwein."],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "Südtirol & Alpen",
|
||||
recipes: [
|
||||
{
|
||||
title: "Schlutzkrapfen",
|
||||
image: "/uploads/recipe-schlutzkrapfen.png",
|
||||
time: "60 Min.",
|
||||
servings: "4 Portionen",
|
||||
category: "Hauptspeise",
|
||||
description: "Südtiroler Halbmond-Teigtaschen mit Spinat-Ricotta-Füllung und brauner Butter.",
|
||||
ingredients: ["300 g Roggenmehl", "100 g Weizenmehl", "3 Eier", "Wasser", "400 g Spinat", "200 g Ricotta", "1 Zwiebel", "Butter, Parmesan, Salbei"],
|
||||
steps: ["Roggen- und Weizenmehl mit Eiern und Wasser verkneten.", "30 Min. ruhen lassen.", "Spinat dünsten, hacken, mit Ricotta und Zwiebel mischen.", "Teig dünn ausrollen, Kreise ausstechen.", "Füllung aufsetzen, zu Halbmonden falten, Ränder fest drücken.", "In Salzwasser kochen, mit brauner Salbeibutter und Parmesan servieren."],
|
||||
},
|
||||
{
|
||||
title: "Sauerbraten",
|
||||
image: "/uploads/recipe-sauerbraten.png",
|
||||
time: "240 Min.",
|
||||
servings: "6 Portionen",
|
||||
category: "Hauptspeise",
|
||||
description: "Tagelang marinierter Schmorbraten mit dunkler Soße -- das Geduldsgericht der deutschen Küche.",
|
||||
ingredients: ["1,5 kg Rindfleisch", "500 ml Rotweinessig", "500 ml Wasser", "2 Zwiebeln", "Lorbeer, Nelken, Piment", "Wacholderbeeren", "3 EL Lebkuchengewürz", "Rosinen"],
|
||||
steps: ["Fleisch 3-5 Tage in Essig-Wasser-Marinade mit Gewürzen einlegen.", "Fleisch herausnehmen, trocken tupfen, scharf anbraten.", "Mit gefilterter Marinade ablöschen.", "Bei 160°C 3 Stunden schmoren.", "Soße mit Lebkuchen-Bröseln und Rosinen binden.", "Mit Kartoffelklößen und Rotkraut servieren."],
|
||||
},
|
||||
{
|
||||
title: "Schwarzwälder Kirschtorte",
|
||||
image: "/uploads/recipe-schwarzwaelder-kirschtorte.png",
|
||||
time: "120 Min.",
|
||||
servings: "12 Portionen",
|
||||
category: "Nachspeise",
|
||||
description: "Die Königin der deutschen Torten -- Schokolade, Kirschen, Sahne und ein Schuss Kirschwasser.",
|
||||
ingredients: ["6 Eier", "200 g Zucker", "150 g Mehl", "50 g Kakao", "80 g Butter", "1 Glas Sauerkirschen", "600 ml Sahne", "Kirschwasser", "Schokoraspeln"],
|
||||
steps: ["Biskuit aus Eiern, Zucker, Mehl, Kakao und Butter backen.", "Auskühlen lassen und in 3 Böden schneiden.", "Böden mit Kirschwasser tränken.", "Sahne steif schlagen, zwischen die Böden streichen.", "Kirschen auf die Schichten verteilen.", "Torte mit Sahne, Schokoraspeln und Kirschen dekorieren."],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "Norddeutschland",
|
||||
recipes: [
|
||||
{
|
||||
title: "Grünkohl mit Pinkel",
|
||||
image: "/uploads/recipe-gruenkohl.png",
|
||||
time: "120 Min.",
|
||||
servings: "6 Portionen",
|
||||
category: "Hauptspeise",
|
||||
description: "Norddeutscher Winterklassiker -- deftiger Grünkohl mit geräucherter Pinkelwurst nach dem ersten Frost.",
|
||||
ingredients: ["1,5 kg frischer Grünkohl", "4 Pinkelwürste", "250 g Kasseler", "250 g durchwachsener Speck", "2 Zwiebeln", "Schmalz", "Haferflocken", "Senf"],
|
||||
steps: ["Grünkohl waschen, Stiele entfernen und grob hacken.", "Zwiebeln in Schmalz anbraten, Grünkohl zugeben.", "Mit Brühe aufgießen, Speck und Kasseler einlegen.", "90 Min. köcheln lassen, gelegentlich umrühren.", "Pinkelwürste in den letzten 30 Min. mitgaren.", "Mit Haferflocken binden, mit Senf und Bratkartoffeln servieren."],
|
||||
},
|
||||
{
|
||||
title: "Kartoffelpuffer",
|
||||
image: "/uploads/recipe-kartoffelpuffer.png",
|
||||
time: "30 Min.",
|
||||
servings: "4 Portionen",
|
||||
category: "Hauptspeise",
|
||||
description: "Goldbraun gebratene Kartoffelreibekuchen -- ob süß mit Apfelmus oder herzhaft mit Lachs.",
|
||||
ingredients: ["1 kg festkochende Kartoffeln", "1 Zwiebel", "2 Eier", "3 EL Mehl", "Salz, Pfeffer", "Muskatnuss", "Öl zum Braten", "Apfelmus"],
|
||||
steps: ["Kartoffeln und Zwiebel fein reiben.", "Überschüssige Flüssigkeit gut ausdrücken.", "Mit Eiern, Mehl, Salz und Muskatnuss verrühren.", "In heißem Öl löffelweise flache Puffer braten.", "Von beiden Seiten goldbraun und knusprig backen.", "Auf Küchenpapier abtropfen, mit Apfelmus servieren."],
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
function RecipeModal({ recipe, onClose }: { recipe: Recipe; onClose: () => void }) {
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 bg-black/80 flex items-center justify-center p-4" onClick={onClose} data-testid="modal-recipe-page">
|
||||
<div
|
||||
className="bg-card rounded-xl border border-card-border max-w-lg w-full max-h-[85vh] overflow-y-auto"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="relative">
|
||||
<img src={recipe.image} alt={recipe.title} className="w-full h-56 object-cover rounded-t-xl" />
|
||||
<button onClick={onClose} className="absolute top-3 right-3 bg-black/60 rounded-full p-1.5 text-white hover:bg-black/80" data-testid="button-recipe-modal-close">
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="p-5">
|
||||
<h2 className="text-xl font-bold text-foreground mb-1">{recipe.title}</h2>
|
||||
<span className="text-[11px] font-medium text-primary">{recipe.category}</span>
|
||||
<p className="text-sm text-muted-foreground mb-3 mt-1">{recipe.description}</p>
|
||||
<div className="flex items-center gap-4 text-sm text-muted-foreground mb-5">
|
||||
<span className="flex items-center gap-1"><Clock className="w-4 h-4" />{recipe.time}</span>
|
||||
<span className="flex items-center gap-1"><Users className="w-4 h-4" />{recipe.servings}</span>
|
||||
</div>
|
||||
|
||||
<h3 className="font-semibold text-foreground text-sm mb-2">Zutaten</h3>
|
||||
<ul className="text-sm text-foreground/80 mb-5 space-y-1">
|
||||
{recipe.ingredients.map((ing, i) => (
|
||||
<li key={i} className="flex items-start gap-2">
|
||||
<span className="text-primary mt-1">•</span>{ing}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<h3 className="font-semibold text-foreground text-sm mb-2">Zubereitung</h3>
|
||||
<ol className="text-sm text-foreground/80 space-y-2">
|
||||
{recipe.steps.map((step, i) => (
|
||||
<li key={i} className="flex gap-2">
|
||||
<span className="font-bold text-primary flex-shrink-0">{i + 1}.</span>{step}
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function RecipesPage() {
|
||||
const [selectedRecipe, setSelectedRecipe] = useState<Recipe | null>(null);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<Header />
|
||||
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<Link href="/">
|
||||
<button className="text-muted-foreground hover:text-foreground transition-colors" data-testid="button-recipes-back">
|
||||
<ChevronLeft className="w-5 h-5" />
|
||||
</button>
|
||||
</Link>
|
||||
<h1 className="text-2xl font-bold text-foreground flex items-center gap-2" data-testid="text-recipes-title">
|
||||
<ChefHat className="w-6 h-6 text-primary" />
|
||||
Legendäre Bauernküche
|
||||
</h1>
|
||||
</div>
|
||||
<p className="text-muted-foreground text-sm mb-8 ml-8" data-testid="text-recipes-subtitle">
|
||||
Traditionelle Rezepte aus den germanischen Regionen -- von der Alm bis zur Küste, von der Oma überliefert.
|
||||
</p>
|
||||
|
||||
{RECIPE_REGIONS.map((region, ri) => (
|
||||
<div key={region.name} data-testid={`section-region-${ri}`}>
|
||||
<div className="flex items-center gap-3 mb-4 mt-8 first:mt-0">
|
||||
<div className="h-px flex-1 bg-card-border" />
|
||||
<h2 className="text-lg font-bold text-foreground px-3" data-testid={`text-region-${ri}`}>{region.name}</h2>
|
||||
<div className="h-px flex-1 bg-card-border" />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-5 mb-4">
|
||||
{region.recipes.map((recipe, i) => (
|
||||
<button
|
||||
key={recipe.title}
|
||||
onClick={() => setSelectedRecipe(recipe)}
|
||||
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-${ri}-${i}`}
|
||||
>
|
||||
<div className="relative overflow-hidden">
|
||||
<img src={recipe.image} alt={recipe.title} className="w-full aspect-video object-cover transition-transform duration-500 group-hover:scale-105" loading="lazy" />
|
||||
</div>
|
||||
<div className="p-4">
|
||||
<h3 className="font-bold text-card-foreground text-base group-hover:text-primary transition-colors">{recipe.title}</h3>
|
||||
<span className="text-[10px] font-medium text-primary">{recipe.category}</span>
|
||||
<p className="text-xs text-muted-foreground mt-1 line-clamp-2">{recipe.description}</p>
|
||||
<div className="flex items-center gap-3 mt-3 text-xs text-muted-foreground">
|
||||
<span className="flex items-center gap-1"><Clock className="w-3 h-3" />{recipe.time}</span>
|
||||
<span className="flex items-center gap-1"><Users className="w-3 h-3" />{recipe.servings}</span>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{ri % 2 === 0 && <InArticleAd />}
|
||||
</div>
|
||||
))}
|
||||
</main>
|
||||
<Footer />
|
||||
|
||||
{selectedRecipe && <RecipeModal recipe={selectedRecipe} onClose={() => setSelectedRecipe(null)} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user