Add new content widgets and a dynamic bento grid homepage
Integrates photo gallery, horoscope, recipe, and news widgets into a dynamic MSN-style bento grid homepage. Adds new routes for gallery and updates navigation. Replit-Commit-Author: Agent Replit-Commit-Session-Id: 413891e8-d784-4bea-b9f5-91a5a68316b4 Replit-Commit-Checkpoint-Type: full_checkpoint Replit-Commit-Event-Id: ac196a32-ec08-4953-9df7-633cb142cc48 Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/f209e72a-0939-48fa-84fc-57854de71967/413891e8-d784-4bea-b9f5-91a5a68316b4/RVXhOPb Replit-Helium-Checkpoint-Created: true
This commit is contained in:
@@ -17,9 +17,17 @@ export default function Footer() {
|
||||
<ul className="space-y-2 text-sm">
|
||||
<li>
|
||||
<Link href="/category/News">
|
||||
<span className="text-muted-foreground cursor-pointer hover:text-primary transition-colors" data-testid="link-footer-news">
|
||||
News
|
||||
</span>
|
||||
<span className="text-muted-foreground cursor-pointer hover:text-primary transition-colors" data-testid="link-footer-news">News</span>
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link href="/videos">
|
||||
<span className="text-muted-foreground cursor-pointer hover:text-primary transition-colors" data-testid="link-footer-video">Video</span>
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link href="/gallery">
|
||||
<span className="text-muted-foreground cursor-pointer hover:text-primary transition-colors" data-testid="link-footer-gallery">Fotogalerie</span>
|
||||
</Link>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
@@ -8,6 +8,7 @@ const navItems = [
|
||||
{ label: "Start", href: "/" },
|
||||
{ label: "News", href: "/category/News" },
|
||||
{ label: "Video", href: "/videos" },
|
||||
{ label: "Galerie", href: "/gallery" },
|
||||
];
|
||||
|
||||
export default function Header() {
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
import { useState } from "react";
|
||||
import { Star, ChevronLeft, ChevronRight, X } from "lucide-react";
|
||||
|
||||
const SIGNS = [
|
||||
{ name: "Widder", symbol: "♈", date: "21.03 – 19.04", element: "Feuer" },
|
||||
{ name: "Stier", symbol: "♉", date: "20.04 – 20.05", element: "Erde" },
|
||||
{ name: "Zwillinge", symbol: "♊", date: "21.05 – 20.06", element: "Luft" },
|
||||
{ name: "Krebs", symbol: "♋", date: "21.06 – 22.07", element: "Wasser" },
|
||||
{ name: "Löwe", symbol: "♌", date: "23.07 – 22.08", element: "Feuer" },
|
||||
{ name: "Jungfrau", symbol: "♍", date: "23.08 – 22.09", element: "Erde" },
|
||||
{ name: "Waage", symbol: "♎", date: "23.09 – 22.10", element: "Luft" },
|
||||
{ name: "Skorpion", symbol: "♏", date: "23.10 – 21.11", element: "Wasser" },
|
||||
{ name: "Schütze", symbol: "♐", date: "22.11 – 21.12", element: "Feuer" },
|
||||
{ name: "Steinbock", symbol: "♑", date: "22.12 – 19.01", element: "Erde" },
|
||||
{ name: "Wassermann", symbol: "♒", date: "20.01 – 18.02", element: "Luft" },
|
||||
{ name: "Fische", symbol: "♓", date: "19.02 – 20.03", element: "Wasser" },
|
||||
];
|
||||
|
||||
const DAILY_TEXTS = [
|
||||
"Ein wunderbarer Tag für neue Begegnungen. Die Sterne stehen günstig für spontane Entscheidungen.",
|
||||
"Heute ist Geduld gefragt. Lassen Sie sich Zeit und genießen Sie die kleinen Freuden des Alltags.",
|
||||
"Kommunikation steht im Vordergrund. Ein gutes Gespräch kann heute Wunder wirken.",
|
||||
"Vertrauen Sie auf Ihre Intuition. Ihr Bauchgefühl weist Ihnen den richtigen Weg.",
|
||||
"Kreativität und Mut werden heute belohnt. Zeigen Sie, was in Ihnen steckt!",
|
||||
"Ordnung und Struktur bringen heute den Erfolg. Nutzen Sie die Energie für wichtige Aufgaben.",
|
||||
"Harmonie und Ausgeglichenheit bestimmen den Tag. Genießen Sie die Zeit mit Ihren Liebsten.",
|
||||
"Tiefgründige Erkenntnisse warten auf Sie. Nehmen Sie sich Zeit für Reflexion.",
|
||||
"Abenteuerlust liegt in der Luft! Planen Sie etwas Besonderes für heute.",
|
||||
"Disziplin und Ausdauer zahlen sich aus. Bleiben Sie an Ihren Zielen dran.",
|
||||
"Originelle Ideen kommen heute von ganz allein. Lassen Sie Ihrer Fantasie freien Lauf.",
|
||||
"Einfühlungsvermögen und Mitgefühl stärken heute Ihre Beziehungen.",
|
||||
];
|
||||
|
||||
function getCurrentSignIndex(): number {
|
||||
const now = new Date();
|
||||
const month = now.getMonth() + 1;
|
||||
const day = now.getDate();
|
||||
if ((month === 3 && day >= 21) || (month === 4 && day <= 19)) return 0;
|
||||
if ((month === 4 && day >= 20) || (month === 5 && day <= 20)) return 1;
|
||||
if ((month === 5 && day >= 21) || (month === 6 && day <= 20)) return 2;
|
||||
if ((month === 6 && day >= 21) || (month === 7 && day <= 22)) return 3;
|
||||
if ((month === 7 && day >= 23) || (month === 8 && day <= 22)) return 4;
|
||||
if ((month === 8 && day >= 23) || (month === 9 && day <= 22)) return 5;
|
||||
if ((month === 9 && day >= 23) || (month === 10 && day <= 22)) return 6;
|
||||
if ((month === 10 && day >= 23) || (month === 11 && day <= 21)) return 7;
|
||||
if ((month === 11 && day >= 22) || (month === 12 && day <= 21)) return 8;
|
||||
if ((month === 12 && day >= 22) || (month === 1 && day <= 19)) return 9;
|
||||
if ((month === 1 && day >= 20) || (month === 2 && day <= 18)) return 10;
|
||||
return 11;
|
||||
}
|
||||
|
||||
function getDailyText(signIndex: number): string {
|
||||
const dayOfYear = Math.floor((Date.now() - new Date(new Date().getFullYear(), 0, 0).getTime()) / 86400000);
|
||||
return DAILY_TEXTS[(signIndex + dayOfYear) % DAILY_TEXTS.length];
|
||||
}
|
||||
|
||||
function HoroscopeModal({ onClose }: { onClose: () => void }) {
|
||||
const [selected, setSelected] = useState(getCurrentSignIndex());
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 bg-black/80 flex items-center justify-center p-4" onClick={onClose} data-testid="modal-horoscope">
|
||||
<div
|
||||
className="bg-card rounded-xl border border-card-border max-w-lg w-full max-h-[80vh] overflow-y-auto p-6"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-lg font-bold text-foreground flex items-center gap-2">
|
||||
<Star className="w-5 h-5 text-primary" />
|
||||
Tageshoroskop
|
||||
</h2>
|
||||
<button onClick={onClose} className="text-muted-foreground hover:text-foreground" data-testid="button-horoscope-close">
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-4 sm:grid-cols-6 gap-2 mb-6">
|
||||
{SIGNS.map((sign, i) => (
|
||||
<button
|
||||
key={sign.name}
|
||||
onClick={() => setSelected(i)}
|
||||
className={`flex flex-col items-center p-2 rounded-lg transition-colors ${
|
||||
i === selected ? "bg-primary/20 border border-primary" : "hover:bg-muted border border-transparent"
|
||||
}`}
|
||||
data-testid={`button-sign-${sign.name}`}
|
||||
>
|
||||
<span className="text-2xl">{sign.symbol}</span>
|
||||
<span className="text-[10px] text-muted-foreground mt-1">{sign.name}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="bg-muted/50 rounded-lg p-4">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span className="text-3xl">{SIGNS[selected].symbol}</span>
|
||||
<div>
|
||||
<h3 className="font-bold text-foreground">{SIGNS[selected].name}</h3>
|
||||
<p className="text-xs text-muted-foreground">{SIGNS[selected].date} · {SIGNS[selected].element}</p>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-sm text-foreground/80 leading-relaxed mt-3">
|
||||
{getDailyText(selected)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function HoroscopeWidget() {
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const currentIndex = getCurrentSignIndex();
|
||||
const sign = SIGNS[currentIndex];
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
onClick={() => setShowModal(true)}
|
||||
className="bg-card rounded-lg border border-card-border overflow-hidden h-full w-full text-left cursor-pointer group hover:border-primary/50 transition-colors"
|
||||
data-testid="widget-horoscope"
|
||||
>
|
||||
<div className="p-3 flex items-center gap-2 border-b border-card-border">
|
||||
<Star className="w-4 h-4 text-primary" />
|
||||
<h3 className="font-bold text-card-foreground text-sm">Horoskop</h3>
|
||||
</div>
|
||||
<div className="p-4 flex items-center gap-3">
|
||||
<span className="text-4xl">{sign.symbol}</span>
|
||||
<div>
|
||||
<p className="font-semibold text-card-foreground text-sm">{sign.name}</p>
|
||||
<p className="text-[10px] text-muted-foreground">{sign.date}</p>
|
||||
<p className="text-xs text-muted-foreground mt-1 line-clamp-2">{getDailyText(currentIndex)}</p>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{showModal && <HoroscopeModal onClose={() => setShowModal(false)} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Newspaper, ExternalLink } from "lucide-react";
|
||||
|
||||
interface NewsItem {
|
||||
title: string;
|
||||
link: string;
|
||||
source: string;
|
||||
pubDate: string;
|
||||
}
|
||||
|
||||
export function NewsWidget() {
|
||||
const { data: news, isLoading } = useQuery<NewsItem[]>({
|
||||
queryKey: ["/api/news-feed"],
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="bg-card rounded-lg border border-card-border overflow-hidden h-full" data-testid="widget-news">
|
||||
<div className="p-3 flex items-center gap-2 border-b border-card-border">
|
||||
<Newspaper className="w-4 h-4 text-primary" />
|
||||
<h3 className="font-bold text-card-foreground text-sm">Musiknachrichten</h3>
|
||||
</div>
|
||||
<div className="p-3 space-y-3">
|
||||
{(news || []).slice(0, 5).map((item, i) => (
|
||||
<a
|
||||
key={i}
|
||||
href={item.link}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="block group cursor-pointer"
|
||||
data-testid={`link-news-${i}`}
|
||||
>
|
||||
<div className="flex items-start gap-2">
|
||||
<div className="flex-1 min-w-0">
|
||||
<h4 className="text-xs font-medium text-card-foreground line-clamp-2 group-hover:text-primary transition-colors leading-snug">
|
||||
{item.title}
|
||||
</h4>
|
||||
<div className="flex items-center gap-1.5 mt-1">
|
||||
<span className="text-[10px] text-primary font-medium">{item.source}</span>
|
||||
<span className="text-[10px] text-muted-foreground">{item.pubDate}</span>
|
||||
</div>
|
||||
</div>
|
||||
<ExternalLink className="w-3 h-3 text-muted-foreground flex-shrink-0 mt-0.5 opacity-0 group-hover:opacity-100 transition-opacity" />
|
||||
</div>
|
||||
{i < 4 && <div className="border-b border-card-border mt-3" />}
|
||||
</a>
|
||||
))}
|
||||
{isLoading && (
|
||||
<div className="space-y-3">
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<div key={i} className="space-y-1.5">
|
||||
<div className="h-3 bg-muted animate-pulse rounded w-full" />
|
||||
<div className="h-3 bg-muted animate-pulse rounded w-3/4" />
|
||||
<div className="h-2 bg-muted animate-pulse rounded w-1/3 mt-1" />
|
||||
{i < 3 && <div className="border-b border-card-border mt-3" />}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{!isLoading && (!news || news.length === 0) && (
|
||||
<p className="text-xs text-muted-foreground text-center py-2">Keine Nachrichten verfügbar</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { ChevronLeft, ChevronRight, X, Images } from "lucide-react";
|
||||
|
||||
interface GalleryImage {
|
||||
folder: string;
|
||||
fileName: string;
|
||||
thumb: string;
|
||||
large: string;
|
||||
}
|
||||
|
||||
function Lightbox({
|
||||
images,
|
||||
startIndex,
|
||||
onClose,
|
||||
}: {
|
||||
images: GalleryImage[];
|
||||
startIndex: number;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const [index, setIndex] = useState(startIndex);
|
||||
|
||||
const prev = useCallback(() => setIndex((i) => (i - 1 + images.length) % images.length), [images.length]);
|
||||
const next = useCallback(() => setIndex((i) => (i + 1) % images.length), [images.length]);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") onClose();
|
||||
if (e.key === "ArrowLeft") prev();
|
||||
if (e.key === "ArrowRight") next();
|
||||
};
|
||||
window.addEventListener("keydown", handler);
|
||||
return () => window.removeEventListener("keydown", handler);
|
||||
}, [onClose, prev, next]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 bg-black/95 flex items-center justify-center"
|
||||
onClick={onClose}
|
||||
data-testid="lightbox-overlay"
|
||||
>
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); onClose(); }}
|
||||
className="absolute top-4 right-4 text-white/70 hover:text-white z-50 p-2"
|
||||
data-testid="button-lightbox-close"
|
||||
>
|
||||
<X className="w-8 h-8" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); prev(); }}
|
||||
className="absolute left-2 md:left-6 text-white/70 hover:text-white z-50 p-2"
|
||||
data-testid="button-lightbox-prev"
|
||||
>
|
||||
<ChevronLeft className="w-10 h-10" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); next(); }}
|
||||
className="absolute right-2 md:right-6 text-white/70 hover:text-white z-50 p-2"
|
||||
data-testid="button-lightbox-next"
|
||||
>
|
||||
<ChevronRight className="w-10 h-10" />
|
||||
</button>
|
||||
|
||||
<div className="max-w-[90vw] max-h-[85vh] flex items-center justify-center" onClick={(e) => e.stopPropagation()}>
|
||||
<img
|
||||
src={images[index].large}
|
||||
alt={images[index].fileName}
|
||||
className="max-w-full max-h-[85vh] object-contain rounded-lg"
|
||||
data-testid="img-lightbox"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="absolute bottom-4 text-white/60 text-sm" data-testid="text-lightbox-counter">
|
||||
{index + 1} / {images.length}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function PhotoGalleryWidget() {
|
||||
const { data: images, isLoading } = useQuery<GalleryImage[]>({
|
||||
queryKey: ["/api/gallery"],
|
||||
});
|
||||
|
||||
const [lightboxIndex, setLightboxIndex] = useState<number | null>(null);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="bg-card rounded-lg border border-card-border overflow-hidden h-full" data-testid="widget-gallery-loading">
|
||||
<div className="p-3 flex items-center gap-2 border-b border-card-border">
|
||||
<Images className="w-4 h-4 text-primary" />
|
||||
<h3 className="font-bold text-card-foreground text-sm">Fotogalerie</h3>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-0.5 p-0.5">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<div key={i} className="aspect-square bg-muted animate-pulse" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!images || images.length === 0) return null;
|
||||
|
||||
const preview = images.slice(0, 6);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="bg-card rounded-lg border border-card-border overflow-hidden h-full" data-testid="widget-gallery">
|
||||
<div className="p-3 flex items-center gap-2 border-b border-card-border">
|
||||
<Images className="w-4 h-4 text-primary" />
|
||||
<h3 className="font-bold text-card-foreground text-sm">Fotogalerie</h3>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-0.5 p-0.5">
|
||||
{preview.map((img, i) => (
|
||||
<button
|
||||
key={i}
|
||||
onClick={() => setLightboxIndex(i)}
|
||||
className="aspect-square overflow-hidden cursor-pointer group"
|
||||
data-testid={`button-gallery-${i}`}
|
||||
>
|
||||
<img
|
||||
src={img.thumb}
|
||||
alt={img.fileName}
|
||||
className="w-full h-full object-cover transition-transform duration-300 group-hover:scale-110"
|
||||
loading="lazy"
|
||||
/>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{lightboxIndex !== null && (
|
||||
<Lightbox
|
||||
images={images}
|
||||
startIndex={lightboxIndex}
|
||||
onClose={() => setLightboxIndex(null)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default function GalleryPage() {
|
||||
const { data: images, isLoading } = useQuery<GalleryImage[]>({
|
||||
queryKey: ["/api/gallery"],
|
||||
});
|
||||
|
||||
const [lightboxIndex, setLightboxIndex] = useState<number | null>(null);
|
||||
|
||||
return (
|
||||
<div data-testid="page-gallery">
|
||||
{isLoading ? (
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-2">
|
||||
{Array.from({ length: 20 }).map((_, i) => (
|
||||
<div key={i} className="aspect-square bg-card rounded-lg animate-pulse" />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-2">
|
||||
{(images || []).map((img, i) => (
|
||||
<button
|
||||
key={i}
|
||||
onClick={() => setLightboxIndex(i)}
|
||||
className="aspect-square overflow-hidden rounded-lg cursor-pointer group"
|
||||
data-testid={`button-gallery-full-${i}`}
|
||||
>
|
||||
<img
|
||||
src={img.thumb}
|
||||
alt={img.fileName}
|
||||
className="w-full h-full object-cover transition-transform duration-300 group-hover:scale-110"
|
||||
loading="lazy"
|
||||
/>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{lightboxIndex !== null && images && (
|
||||
<Lightbox
|
||||
images={images}
|
||||
startIndex={lightboxIndex}
|
||||
onClose={() => setLightboxIndex(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
import { useState } from "react";
|
||||
import { ChefHat, X, Clock, Users } from "lucide-react";
|
||||
|
||||
interface Recipe {
|
||||
title: string;
|
||||
image: string;
|
||||
time: string;
|
||||
servings: string;
|
||||
ingredients: string[];
|
||||
steps: string[];
|
||||
}
|
||||
|
||||
const RECIPES: Recipe[] = [
|
||||
{
|
||||
title: "Kaiserschmarrn",
|
||||
image: "https://images.unsplash.com/photo-1621939514649-280e2ee25f60?w=400&h=300&fit=crop",
|
||||
time: "25 Min.",
|
||||
servings: "2 Portionen",
|
||||
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: "https://images.unsplash.com/photo-1599921841143-819065a55cc6?w=400&h=300&fit=crop",
|
||||
time: "30 Min.",
|
||||
servings: "4 Portionen",
|
||||
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: "https://images.unsplash.com/photo-1535920527002-b35e96722eb9?w=400&h=300&fit=crop",
|
||||
time: "60 Min.",
|
||||
servings: "6 Portionen",
|
||||
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: "https://images.unsplash.com/photo-1548940740-204726a19be3?w=400&h=300&fit=crop",
|
||||
time: "40 Min.",
|
||||
servings: "4 Portionen",
|
||||
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: "https://images.unsplash.com/photo-1578985545062-69928b1d9587?w=400&h=300&fit=crop",
|
||||
time: "90 Min.",
|
||||
servings: "8 Portionen",
|
||||
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: "https://images.unsplash.com/photo-1551183053-bf91a1d81141?w=400&h=300&fit=crop",
|
||||
time: "50 Min.",
|
||||
servings: "4 Portionen",
|
||||
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."],
|
||||
},
|
||||
];
|
||||
|
||||
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">
|
||||
<div
|
||||
className="bg-card rounded-xl border border-card-border max-w-lg w-full max-h-[80vh] overflow-y-auto"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="relative">
|
||||
<img src={recipe.image} alt={recipe.title} className="w-full h-48 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-close">
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="p-5">
|
||||
<h2 className="text-xl font-bold text-foreground mb-2">{recipe.title}</h2>
|
||||
<div className="flex items-center gap-4 text-sm text-muted-foreground mb-4">
|
||||
<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-4 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 function RecipeWidget() {
|
||||
const [showRecipe, setShowRecipe] = useState<Recipe | null>(null);
|
||||
const dayIndex = Math.floor(Date.now() / 86400000) % RECIPES.length;
|
||||
const recipe = RECIPES[dayIndex];
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
onClick={() => setShowRecipe(recipe)}
|
||||
className="bg-card rounded-lg border border-card-border overflow-hidden h-full w-full text-left cursor-pointer group hover:border-primary/50 transition-colors"
|
||||
data-testid="widget-recipe"
|
||||
>
|
||||
<div className="p-3 flex items-center gap-2 border-b border-card-border">
|
||||
<ChefHat className="w-4 h-4 text-primary" />
|
||||
<h3 className="font-bold text-card-foreground text-sm">Rezept des Tages</h3>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<img
|
||||
src={recipe.image}
|
||||
alt={recipe.title}
|
||||
className="w-full aspect-[16/10] object-cover transition-transform duration-500 group-hover:scale-105"
|
||||
loading="lazy"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-black/70 to-transparent" />
|
||||
<div className="absolute bottom-0 left-0 right-0 p-3">
|
||||
<h4 className="text-white font-semibold text-sm">{recipe.title}</h4>
|
||||
<p className="text-white/60 text-xs mt-0.5">{recipe.time} · {recipe.servings}</p>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{showRecipe && <RecipeModal recipe={showRecipe} onClose={() => setShowRecipe(null)} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user