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:
parent
8ef815824a
commit
d28c36131c
@ -8,6 +8,7 @@ import Home from "@/pages/home";
|
||||
import ArticlePage from "@/pages/article";
|
||||
import CategoryPage from "@/pages/category";
|
||||
import VideosPage from "@/pages/videos";
|
||||
import GalleryPageWrapper from "@/pages/gallery";
|
||||
|
||||
function Router() {
|
||||
return (
|
||||
@ -16,6 +17,7 @@ function Router() {
|
||||
<Route path="/article/:slug" component={ArticlePage} />
|
||||
<Route path="/category/:category" component={CategoryPage} />
|
||||
<Route path="/videos" component={VideosPage} />
|
||||
<Route path="/gallery" component={GalleryPageWrapper} />
|
||||
<Route component={NotFound} />
|
||||
</Switch>
|
||||
);
|
||||
|
||||
@ -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() {
|
||||
|
||||
138
client/src/components/horoscope-widget.tsx
Normal file
138
client/src/components/horoscope-widget.tsx
Normal file
@ -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)} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
65
client/src/components/news-widget.tsx
Normal file
65
client/src/components/news-widget.tsx
Normal file
@ -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>
|
||||
);
|
||||
}
|
||||
190
client/src/components/photo-gallery.tsx
Normal file
190
client/src/components/photo-gallery.tsx
Normal file
@ -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>
|
||||
);
|
||||
}
|
||||
141
client/src/components/recipe-widget.tsx
Normal file
141
client/src/components/recipe-widget.tsx
Normal file
@ -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)} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
26
client/src/pages/gallery.tsx
Normal file
26
client/src/pages/gallery.tsx
Normal file
@ -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>
|
||||
);
|
||||
}
|
||||
@ -3,11 +3,15 @@ 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, ChevronRight } 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 { 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";
|
||||
|
||||
function thumbUrl(src: string | null): string {
|
||||
@ -31,17 +35,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 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"
|
||||
/>
|
||||
<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" />
|
||||
{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 +48,11 @@ 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>
|
||||
</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()}
|
||||
<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>
|
||||
@ -80,18 +64,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 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="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" loading="lazy" />
|
||||
</div>
|
||||
{isVideo && (
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
@ -106,9 +82,7 @@ function MediumCard({ article }: { article: Article }) {
|
||||
<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>
|
||||
<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 +93,6 @@ function MediumCard({ article }: { article: Article }) {
|
||||
);
|
||||
}
|
||||
|
||||
function CompactCard({ 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"
|
||||
/>
|
||||
{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>
|
||||
</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>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
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 +108,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,22 +117,6 @@ function TopStoriesList({ articles }: { articles: Article[] }) {
|
||||
);
|
||||
}
|
||||
|
||||
function BentoSkeleton() {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-6 gap-4">
|
||||
<div className="col-span-6 lg:col-span-3"><Skeleton className="w-full h-[380px] rounded-lg" /></div>
|
||||
<div className="col-span-6 lg:col-span-3 grid grid-cols-2 gap-4">
|
||||
<Skeleton className="w-full h-[180px] rounded-lg" />
|
||||
<Skeleton className="w-full h-[180px] rounded-lg" />
|
||||
<Skeleton className="w-full h-[180px] rounded-lg" />
|
||||
<Skeleton className="w-full h-[180px] rounded-lg" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FeaturedCarousel({ articles, popular }: { articles: Article[]; popular?: Article[] }) {
|
||||
const totalPages = Math.ceil(Math.min(articles.length, 9) / 3);
|
||||
const [page, setPage] = useState(0);
|
||||
@ -227,40 +145,24 @@ function FeaturedCarousel({ articles, popular }: { articles: Article[]; popular?
|
||||
if (!hero) return null;
|
||||
|
||||
return (
|
||||
<section
|
||||
data-testid="featured-carousel"
|
||||
onMouseEnter={() => setPaused(true)}
|
||||
onMouseLeave={() => setPaused(false)}
|
||||
>
|
||||
<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} />
|
||||
)}
|
||||
{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}`}
|
||||
/>
|
||||
<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>
|
||||
)}
|
||||
@ -268,6 +170,32 @@ function FeaturedCarousel({ articles, popular }: { articles: Article[]; popular?
|
||||
);
|
||||
}
|
||||
|
||||
function SectionLink({ href, label }: { href: string; label: string }) {
|
||||
return (
|
||||
<Link href={href}>
|
||||
<span className="flex items-center gap-1 text-xs text-primary hover:underline cursor-pointer mt-2">
|
||||
{label} <ChevronRight className="w-3 h-3" />
|
||||
</span>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
function BentoSkeleton() {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-6 gap-4">
|
||||
<div className="col-span-6 lg:col-span-3"><Skeleton className="w-full h-[380px] rounded-lg" /></div>
|
||||
<div className="col-span-6 lg:col-span-3 grid grid-cols-2 gap-4">
|
||||
<Skeleton className="w-full h-[180px] rounded-lg" />
|
||||
<Skeleton className="w-full h-[180px] rounded-lg" />
|
||||
<Skeleton className="w-full h-[180px] rounded-lg" />
|
||||
<Skeleton className="w-full h-[180px] rounded-lg" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Home() {
|
||||
const { data: articles, isLoading } = useQuery<Article[]>({
|
||||
queryKey: ["/api/articles"],
|
||||
@ -281,56 +209,58 @@ export default function Home() {
|
||||
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 row2Articles = articles.slice(3, 6);
|
||||
const row3Articles = articles.slice(6);
|
||||
|
||||
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} />
|
||||
|
||||
<div className="rounded-lg border border-card-border bg-card overflow-hidden">
|
||||
<AdSense
|
||||
slot="auto"
|
||||
format="horizontal"
|
||||
style={{ display: "block", minHeight: "90px" }}
|
||||
/>
|
||||
<AdSense slot="auto" format="horizontal" style={{ display: "block", minHeight: "90px" }} />
|
||||
</div>
|
||||
|
||||
{row2Items.length > 0 && (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{row2Articles.map((a) => (
|
||||
<MediumCard key={a.id} article={a} />
|
||||
))}
|
||||
<ArticleCardAd key="ad-row2" />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<div className="lg:col-span-1">
|
||||
<PhotoGalleryWidget />
|
||||
<SectionLink href="/gallery" label="Alle Fotos" />
|
||||
</div>
|
||||
|
||||
<div className="lg:col-span-1">
|
||||
<RecipeWidget />
|
||||
</div>
|
||||
|
||||
<div className="lg:col-span-1">
|
||||
<HoroscopeWidget />
|
||||
</div>
|
||||
|
||||
<div className="lg:col-span-1">
|
||||
<NewsWidget />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{row3Articles.length > 0 && (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{row2Items.map((a) => (
|
||||
{row3Articles.map((a) => (
|
||||
<MediumCard key={a.id} article={a} />
|
||||
))}
|
||||
<ArticleCardAd key="ad-row2" />
|
||||
</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>
|
||||
<ArticleCardAd key="ad-row3" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
59
replit.md
59
replit.md
@ -1,7 +1,7 @@
|
||||
# news.folx.tv - Blog Platform
|
||||
# news.folx.tv - MSN-Style News Portal
|
||||
|
||||
## Overview
|
||||
A clean, modern blog/news platform for Folx Music Television (news.folx.tv). Dark-themed, content-first design inspired by Medium and The Verge, with support for video embeds from bunny.net, Facebook, Instagram, and TikTok.
|
||||
A professional MSN-style news portal for Folx Music Television (news.folx.tv). Dark-themed bento grid layout with content for folk music (Volksmusik/Schlager) fans. Features articles, videos, photo gallery, horoscope widget, recipe widget, Google News feed, and integrated AdSense ads. All content is hardcoded in seed for production deployments.
|
||||
|
||||
## Architecture
|
||||
- **Frontend**: React + Vite + TailwindCSS + shadcn/ui (dark mode)
|
||||
@ -10,14 +10,16 @@ A clean, modern blog/news platform for Folx Music Television (news.folx.tv). Dar
|
||||
- **Routing**: wouter (frontend), Express (backend API)
|
||||
|
||||
## Key Features
|
||||
- Article listing with featured carousel, grid layout, and popular sidebar
|
||||
- Individual article pages with full HTML content rendering
|
||||
- Category filtering (News, Star-News)
|
||||
- MSN-style bento grid homepage with FeaturedCarousel, HeroCard, MediumCard, TopStoriesList
|
||||
- Photo gallery widget (156 Dropbox images) with fullscreen lightbox carousel
|
||||
- Horoscope widget (12 zodiac signs, daily rotating texts)
|
||||
- Recipe widget (6 Austrian/Slovenian recipes with modal)
|
||||
- Google News RSS widget (Volksmusik/Schlager news)
|
||||
- Google AdSense integration (ca-pub-4465464714854276)
|
||||
- Article listing with featured carousel and category filtering
|
||||
- HTML content supports embedded iframes (bunny.net, YouTube, Facebook, Instagram, TikTok)
|
||||
- DOMPurify sanitization for safe HTML rendering
|
||||
- Image upload endpoint (multer) for article images
|
||||
- Responsive design with mobile navigation
|
||||
- SEO meta tags
|
||||
|
||||
## Data Model
|
||||
- `articles`: id (serial), title, slug (unique), excerpt, content (HTML), coverImage, category, author, featured, views, publishedAt
|
||||
@ -32,27 +34,38 @@ A clean, modern blog/news platform for Folx Music Television (news.folx.tv). Dar
|
||||
- `PATCH /api/articles/:id` - Update article
|
||||
- `DELETE /api/articles/:id` - Delete article
|
||||
- `POST /api/upload` - Upload image file
|
||||
- `GET /api/gallery` - Shuffled Dropbox gallery images
|
||||
- `GET /api/news-feed` - Google News RSS feed for Volksmusik/Schlager
|
||||
|
||||
## File Structure
|
||||
- `shared/schema.ts` - Drizzle schema + Zod validation
|
||||
- `server/db.ts` - Database connection (pg Pool)
|
||||
- `server/storage.ts` - Storage interface + DatabaseStorage implementation
|
||||
- `server/routes.ts` - API routes + file upload (multer)
|
||||
- `server/seed.ts` - Seed data for initial articles
|
||||
- `client/src/pages/home.tsx` - Homepage
|
||||
- `server/db.ts` - Database connection
|
||||
- `server/storage.ts` - Storage interface + DatabaseStorage
|
||||
- `server/routes.ts` - API routes + gallery + news feed
|
||||
- `server/seed.ts` - Hardcoded seed data (7 articles)
|
||||
- `server/gallery-data.json` - 156 Dropbox gallery images
|
||||
- `client/src/pages/home.tsx` - MSN-style bento grid homepage
|
||||
- `client/src/pages/article.tsx` - Article detail page
|
||||
- `client/src/pages/category.tsx` - Category listing page
|
||||
- `client/src/components/header.tsx` - Site header with nav
|
||||
- `client/src/components/footer.tsx` - Site footer
|
||||
|
||||
## Video Embeds
|
||||
Article content (HTML) supports iframe embeds. Allowed domains:
|
||||
- iframe.mediadelivery.net / video.bunny.net (Bunny.net)
|
||||
- www.facebook.com, www.instagram.com, www.tiktok.com
|
||||
- www.youtube.com, player.vimeo.com
|
||||
- `client/src/pages/videos.tsx` - Videos page
|
||||
- `client/src/pages/gallery.tsx` - Full gallery page
|
||||
- `client/src/components/header.tsx` - Header with nav (Start, News, Video, Galerie)
|
||||
- `client/src/components/footer.tsx` - Footer with links
|
||||
- `client/src/components/photo-gallery.tsx` - Gallery widget + lightbox carousel
|
||||
- `client/src/components/horoscope-widget.tsx` - Horoscope widget
|
||||
- `client/src/components/recipe-widget.tsx` - Recipe widget
|
||||
- `client/src/components/news-widget.tsx` - Google News RSS widget
|
||||
- `client/src/components/adsense.tsx` - AdSense ad components
|
||||
|
||||
## Branding
|
||||
- Dark theme by default (class="dark" on html)
|
||||
- Primary color: crimson/red (hsl 342 85% 53% light, hsl 9 75% 61% dark)
|
||||
- Dark theme (class="dark" on html)
|
||||
- Primary/brand color: crimson/red (RGB 218,35,77)
|
||||
- Background: 0 0% 5%, Card: 0 0% 9%
|
||||
- Font: Poppins
|
||||
- Logo: Folx TV branding image in header
|
||||
- Logo: folx_MT_poz_b_1772296729169.png
|
||||
|
||||
## External Services
|
||||
- Bunny.net: Library 476412, CDN vz-7982dfc4-cc8.b-cdn.net (NO autoplay)
|
||||
- Google AdSense: ca-pub-4465464714854276
|
||||
- Dropbox: Gallery image thumbnails
|
||||
- Google News RSS: Volksmusik/Schlager news feed
|
||||
|
||||
1094
server/gallery-data.json
Normal file
1094
server/gallery-data.json
Normal file
File diff suppressed because it is too large
Load Diff
@ -172,5 +172,70 @@ export async function registerRoutes(
|
||||
}
|
||||
});
|
||||
|
||||
// Gallery API - serves shuffled photos from Dropbox
|
||||
app.get("/api/gallery", (_req, res) => {
|
||||
try {
|
||||
const galleryPath = path.join(process.cwd(), "server/gallery-data.json");
|
||||
const data = JSON.parse(fs.readFileSync(galleryPath, "utf-8"));
|
||||
// Shuffle using Fisher-Yates
|
||||
const shuffled = [...data];
|
||||
for (let i = shuffled.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1));
|
||||
[shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]];
|
||||
}
|
||||
res.json(shuffled);
|
||||
} catch (err: any) {
|
||||
res.json([]);
|
||||
}
|
||||
});
|
||||
|
||||
// News feed - Volksmusik/Schlager news from Google News RSS
|
||||
app.get("/api/news-feed", async (_req, res) => {
|
||||
try {
|
||||
const topics = ["Volksmusik", "Schlager+Musik", "Oberkrainer"];
|
||||
const topic = topics[Math.floor(Date.now() / 3600000) % topics.length];
|
||||
const rssUrl = `https://news.google.com/rss/search?q=${topic}&hl=de&gl=DE&ceid=DE:de`;
|
||||
|
||||
const response = await new Promise<string>((resolve, reject) => {
|
||||
https.get(rssUrl, (resp) => {
|
||||
let data = "";
|
||||
resp.on("data", (chunk: Buffer) => (data += chunk.toString()));
|
||||
resp.on("end", () => resolve(data));
|
||||
resp.on("error", reject);
|
||||
}).on("error", reject);
|
||||
});
|
||||
|
||||
const items: { title: string; link: string; source: string; pubDate: string }[] = [];
|
||||
const itemRegex = /<item>([\s\S]*?)<\/item>/g;
|
||||
let match;
|
||||
while ((match = itemRegex.exec(response)) !== null && items.length < 10) {
|
||||
const block = match[1];
|
||||
const title = block.match(/<title>(.*?)<\/title>/)?.[1]?.replace(/<!\[CDATA\[|\]\]>/g, "") || "";
|
||||
const link = block.match(/<link>(.*?)<\/link>/)?.[1] || "";
|
||||
const source = block.match(/<source[^>]*>(.*?)<\/source>/)?.[1]?.replace(/<!\[CDATA\[|\]\]>/g, "") || "";
|
||||
const pubDateRaw = block.match(/<pubDate>(.*?)<\/pubDate>/)?.[1] || "";
|
||||
|
||||
let pubDate = "";
|
||||
try {
|
||||
const d = new Date(pubDateRaw);
|
||||
const diffH = Math.floor((Date.now() - d.getTime()) / 3600000);
|
||||
if (diffH < 1) pubDate = "Gerade eben";
|
||||
else if (diffH < 24) pubDate = `vor ${diffH} Std.`;
|
||||
else pubDate = `vor ${Math.floor(diffH / 24)} T.`;
|
||||
} catch {
|
||||
pubDate = "";
|
||||
}
|
||||
|
||||
if (title && link) {
|
||||
items.push({ title, link, source, pubDate });
|
||||
}
|
||||
}
|
||||
|
||||
res.json(items);
|
||||
} catch (err: any) {
|
||||
res.json([]);
|
||||
}
|
||||
});
|
||||
|
||||
return httpServer;
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user