folx-tv/client/src/pages/home.tsx

930 lines
40 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useQuery } from "@tanstack/react-query";
import { Link } from "wouter";
import { type Article } from "@shared/schema";
import { format } from "date-fns";
import { de } from "date-fns/locale";
import { usePageMeta } from "@/hooks/use-page-meta";
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, SidebarAd, PageSideAds } 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 { SidebarWeatherWidget } from "@/components/weather-widget";
import { BreakingNewsWidget } from "@/components/breaking-news-widget";
import PushPromptBanner from "@/components/push-prompt-banner";
import { useState, useEffect, useCallback, useRef, useMemo, lazy, Suspense } from "react";
function useFocalPoints() {
const { data } = useQuery<Record<string, { x: number; y: number }>>({
queryKey: ["/api/focal-points"],
staleTime: Infinity,
});
return data || {};
}
function SmartImage({ src, alt, className = "", focalPoints }: { src: string; alt: string; className?: string; focalPoints?: Record<string, { x: number; y: number }> }) {
const imgRef = useRef<HTMLImageElement>(null);
const [isPortrait, setIsPortrait] = useState(false);
useEffect(() => {
const img = new window.Image();
img.onload = () => {
if (img.naturalHeight > img.naturalWidth * 1.2) {
setIsPortrait(true);
}
};
img.src = src;
}, [src]);
const fp = focalPoints?.[src];
const posStyle = fp
? { objectPosition: `${fp.x}% ${fp.y}%` }
: { objectPosition: "center 20%" };
if (isPortrait) {
return (
<div className="absolute inset-0 overflow-hidden">
<img src={src} alt="" className="absolute inset-0 w-full h-full object-cover scale-150 blur-2xl brightness-50" aria-hidden="true" />
<img ref={imgRef} src={src} alt={alt} className={`relative w-full h-full object-contain z-[1] ${className}`} />
</div>
);
}
return (
<img ref={imgRef} src={src} alt={alt} className={`w-full h-full object-cover absolute inset-0 ${className}`} style={posStyle} />
);
}
interface GalleryImage {
folder: string;
fileName: string;
thumb: string;
large: string;
}
function thumbUrl(src: string | null): string {
if (!src) return "/images/article-1.jpg";
if (src.endsWith(".webp")) return src.replace(".webp", "-thumb.webp");
return src;
}
function timeAgo(date: Date): string {
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffH = Math.floor(diffMs / 3600000);
const diffD = Math.floor(diffMs / 86400000);
if (diffH < 1) return "Gerade eben";
if (diffH < 24) return `vor ${diffH} Std.`;
if (diffD < 7) return `vor ${diffD} T.`;
return format(date, "d. MMM yyyy", { locale: de });
}
function HeroCard({ article, focalPoints }: { article: Article; focalPoints?: Record<string, { x: number; y: number }> }) {
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-[280px]">
<SmartImage src={article.coverImage || "/images/article-1.jpg"} alt={article.title} className="transition-transform duration-700 group-hover:scale-105" focalPoints={focalPoints} />
{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">
<Play className="w-6 h-6 text-white ml-0.5" fill="white" />
</div>
</div>
)}
<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>
</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 h-full" data-testid="card-hero-gallery">
<div className="relative h-full min-h-[300px]">
<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" />
<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">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>
</Link>
);
}
function getObjectPosition(coverImage: string | null, focalPoints?: Record<string, { x: number; y: number }>): string {
if (!coverImage || !focalPoints) return "center 20%";
const fp = focalPoints[coverImage];
if (!fp) return "center 20%";
return `${fp.x}% ${fp.y}%`;
}
function MosaicTile({ article, focalPoints, tall = false }: { article: Article; focalPoints?: Record<string, { x: number; y: number }>; tall?: boolean }) {
const objPos = getObjectPosition(article.coverImage, focalPoints);
return (
<Link href={`/article/${article.slug}`}>
<div className="relative rounded-lg overflow-hidden" data-testid={`tile-mosaic-${article.id}`}>
<img
src={thumbUrl(article.coverImage)}
alt={article.title}
className={`w-full object-cover ${tall ? "aspect-[16/10]" : "aspect-square"}`}
style={{ objectPosition: objPos }}
loading="lazy"
/>
<div className="absolute inset-0 bg-gradient-to-t from-black/85 via-black/30 to-transparent" />
<span className="absolute top-2 left-2 bg-primary text-white text-[10px] font-semibold px-2 py-0.5 rounded">
{article.category || "News"}
</span>
<h3 className={`absolute bottom-2 left-2 right-2 text-white font-semibold leading-snug ${tall ? "text-base line-clamp-2" : "text-xs line-clamp-3"}`}>
{article.title}
</h3>
</div>
</Link>
);
}
function MosaicHeading({ children }: { children: React.ReactNode }) {
return (
<div className="mb-3">
<h2 className="text-primary font-bold text-lg leading-none mb-2">{children}</h2>
<div className="h-0.5 w-16 bg-primary" />
<div className="h-px w-full bg-card-border -mt-px" />
</div>
);
}
function MobileMosaic({ latest, popular, focalPoints }: { latest: Article[]; popular?: Article[]; focalPoints?: Record<string, { x: number; y: number }> }) {
const pageSize = 3;
const pool = (latest || []).slice(0, 9);
const totalPages = Math.max(1, Math.ceil(pool.length / pageSize));
const [page, setPage] = useState(0);
const [displayPage, setDisplayPage] = useState(0);
const [fading, setFading] = useState(false);
const [paused, setPaused] = useState(false);
const changePage = useCallback((newPage: number) => {
if (newPage === displayPage) return;
setFading(true);
setTimeout(() => {
setDisplayPage(newPage);
setFading(false);
}, 300);
}, [displayPage]);
useEffect(() => {
changePage(page);
}, [page]);
useEffect(() => {
if (paused || totalPages <= 1) return;
const timer = setInterval(() => setPage((p) => (p + 1) % totalPages), 8000);
return () => clearInterval(timer);
}, [paused, totalPages]);
const touchX = useRef<number | null>(null);
const resumeTimer = useRef<any>(null);
const goTo = useCallback((dir: number) => {
setPage((p) => (p + dir + totalPages) % totalPages);
}, [totalPages]);
const onTouchStart = useCallback((e: React.TouchEvent) => {
touchX.current = e.touches[0].clientX;
setPaused(true);
if (resumeTimer.current) clearTimeout(resumeTimer.current);
}, []);
const onTouchEnd = useCallback((e: React.TouchEvent) => {
const x0 = touchX.current;
touchX.current = null;
if (x0 !== null) {
const dx = e.changedTouches[0].clientX - x0;
if (Math.abs(dx) > 40) goTo(dx < 0 ? 1 : -1);
}
if (resumeTimer.current) clearTimeout(resumeTimer.current);
resumeTimer.current = setTimeout(() => setPaused(false), 6000);
}, [goTo]);
useEffect(() => () => { if (resumeTimer.current) clearTimeout(resumeTimer.current); }, []);
const l = pool.slice(displayPage * pageSize, displayPage * pageSize + pageSize);
if (l.length === 0) return null;
const usedIds = new Set(pool.map((a) => a.id));
const p = (popular || []).filter((a) => !usedIds.has(a.id)).slice(0, 3);
const nextStart = ((displayPage + 1) % totalPages) * pageSize;
const preload = pool.slice(nextStart, nextStart + pageSize);
return (
<div className="md:hidden space-y-6" data-testid="section-mobile-mosaic">
<section
onTouchStart={onTouchStart}
onTouchEnd={onTouchEnd}
style={{ touchAction: "pan-y" }}
>
<MosaicHeading>Neueste Artikel</MosaicHeading>
{preload.length > 0 && (
<div className="hidden" aria-hidden="true">
{preload.map((a) => a.coverImage && <img key={`pre-${a.id}`} src={thumbUrl(a.coverImage)} alt="" />)}
</div>
)}
<div className="transition-opacity duration-300" style={{ opacity: fading ? 0 : 1 }}>
<div className="grid grid-cols-2 gap-2">
{l.slice(0, 2).map((a) => (
<MosaicTile key={a.id} article={a} focalPoints={focalPoints} />
))}
</div>
{l[2] && (
<div className="mt-2">
<MosaicTile article={l[2]} focalPoints={focalPoints} tall />
</div>
)}
</div>
{totalPages > 1 && (
<div className="flex justify-center gap-2 mt-3" data-testid="mosaic-dots">
{Array.from({ length: totalPages }).map((_, i) => (
<button
key={i}
onClick={() => setPage(i)}
className={`h-2 rounded-full transition-all duration-300 ${i === page ? "bg-primary w-5" : "bg-muted-foreground/30 w-2"}`}
data-testid={`button-mosaic-dot-${i}`}
aria-label={`Seite ${i + 1}`}
/>
))}
</div>
)}
</section>
{p.length > 0 && (
<section>
<MosaicHeading>Beliebt</MosaicHeading>
<MosaicTile article={p[0]} focalPoints={focalPoints} tall />
{p.length > 1 && (
<div className="grid grid-cols-2 gap-2 mt-2">
{p.slice(1, 3).map((a) => (
<MosaicTile key={a.id} article={a} focalPoints={focalPoints} />
))}
</div>
)}
</section>
)}
</div>
);
}
function MediumCard({ article, focalPoints }: { article: Article; focalPoints?: Record<string, { x: number; y: number }> }) {
const isVideo = article.category === "Video";
const objPos = getObjectPosition(article.coverImage, focalPoints);
return (
<Link href={`/article/${article.slug}`}>
<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" style={{ objectPosition: objPos }} loading="lazy" />
</div>
{isVideo && (
<div className="absolute inset-0 flex items-center justify-center">
<div className="w-10 h-10 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="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>
<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()}
</div>
</div>
</div>
</Link>
);
}
function WideCard({ article, focalPoints }: { article: Article; focalPoints?: Record<string, { x: number; y: number }> }) {
const isVideo = article.category === "Video";
const objPos = getObjectPosition(article.coverImage, focalPoints);
return (
<Link href={`/article/${article.slug}`}>
<div className="relative group cursor-pointer rounded-lg overflow-hidden bg-card border border-card-border h-full" data-testid={`card-wide-${article.id}`}>
<div className="relative aspect-[16/9] 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" style={{ objectPosition: objPos }} loading="lazy" />
{isVideo && (
<div className="absolute inset-0 flex items-center justify-center">
<div className="w-12 h-12 rounded-full bg-primary/90 flex items-center justify-center group-hover:bg-primary transition-colors">
<Play className="w-5 h-5 text-white ml-0.5" fill="white" />
</div>
</div>
)}
<div className="absolute inset-0 bg-gradient-to-t from-black/80 via-black/20 to-transparent" />
<div className="absolute bottom-0 left-0 right-0 p-4">
<div className="flex items-center gap-2 mb-1.5">
<span className="text-xs font-medium text-primary">{article.author}</span>
<span className="text-white/60 text-xs">{timeAgo(new Date(article.publishedAt))}</span>
</div>
<h3 className="font-bold text-white text-base leading-snug line-clamp-2 group-hover:text-primary transition-colors">{article.title}</h3>
<div className="flex items-center gap-2 mt-2 text-white/50 text-[10px]">
<Eye className="w-3 h-3" />
{article.views.toLocaleString()}
</div>
</div>
</div>
</div>
</Link>
);
}
function WideCardClassic({ article, focalPoints }: { article: Article; focalPoints?: Record<string, { x: number; y: number }> }) {
const isVideo = article.category === "Video";
const objPos = getObjectPosition(article.coverImage, focalPoints);
return (
<Link href={`/article/${article.slug}`}>
<div className="relative group cursor-pointer rounded-lg overflow-hidden bg-card border border-card-border flex flex-col sm:flex-row h-full" data-testid={`card-wide-classic-${article.id}`}>
<div className="relative flex-shrink-0 sm:w-1/2">
<div className="overflow-hidden h-full">
<img src={thumbUrl(article.coverImage)} alt={article.title} className="w-full h-full aspect-video sm:aspect-auto object-cover transition-transform duration-500 group-hover:scale-105" style={{ objectPosition: objPos, minHeight: "100%" }} loading="lazy" />
</div>
{isVideo && (
<div className="absolute inset-0 flex items-center justify-center">
<div className="w-12 h-12 rounded-full bg-primary/90 flex items-center justify-center group-hover:bg-primary transition-colors">
<Play className="w-5 h-5 text-white ml-0.5" fill="white" />
</div>
</div>
)}
</div>
<div className="p-4 sm:p-5 flex flex-col flex-1 justify-center">
<div className="flex items-center gap-2 mb-2">
<span className="text-xs font-medium text-primary">{article.author}</span>
<span className="text-muted-foreground text-xs">{timeAgo(new Date(article.publishedAt))}</span>
</div>
<h3 className="font-bold text-card-foreground text-lg leading-snug line-clamp-2 group-hover:text-primary transition-colors">{article.title}</h3>
<p className="text-sm text-muted-foreground mt-2 leading-relaxed line-clamp-4 flex-1">{article.excerpt}</p>
<div className="flex items-center gap-2 mt-3 text-muted-foreground text-[10px]">
<Eye className="w-3 h-3" />
{article.views.toLocaleString()}
</div>
</div>
</div>
</Link>
);
}
function BlogCard({ article, focalPoints }: { article: Article; focalPoints?: Record<string, { x: number; y: number }> }) {
const isVideo = article.category === "Video";
const objPos = getObjectPosition(article.coverImage, focalPoints);
return (
<Link href={`/article/${article.slug}`}>
<div className="relative group cursor-pointer rounded-lg overflow-hidden bg-card border border-card-border flex flex-col" data-testid={`card-blog-${article.id}`}>
<div className="relative flex-shrink-0">
<div className="overflow-hidden">
<img src={article.coverImage} alt={article.title} className="w-full aspect-[16/9] object-cover transition-transform duration-500 group-hover:scale-105" style={{ objectPosition: objPos }} loading="lazy" />
</div>
{isVideo && (
<div className="absolute inset-0 flex items-center justify-center">
<div className="w-12 h-12 rounded-full bg-primary/90 flex items-center justify-center group-hover:bg-primary transition-colors">
<Play className="w-5 h-5 text-white ml-0.5" fill="white" />
</div>
</div>
)}
</div>
<div className="p-4 flex flex-col flex-1">
<div className="flex items-center gap-2 mb-2">
<span className="text-xs font-medium text-primary">{article.author}</span>
<span className="text-muted-foreground text-xs">{timeAgo(new Date(article.publishedAt))}</span>
</div>
<h3 className="font-bold text-card-foreground text-base leading-snug line-clamp-2 group-hover:text-primary transition-colors">{article.title}</h3>
<p className="text-sm text-muted-foreground mt-2 leading-relaxed line-clamp-3 flex-1">{article.excerpt}</p>
<div className="flex items-center gap-2 mt-3 text-muted-foreground text-xs">
<Eye className="w-3.5 h-3.5" />
{article.views.toLocaleString()}
</div>
</div>
</div>
</Link>
);
}
function SideCard({ article, focalPoints }: { article: Article; focalPoints?: Record<string, { x: number; y: number }> }) {
const isVideo = article.category === "Video";
const objPos = getObjectPosition(article.coverImage, focalPoints);
return (
<Link href={`/article/${article.slug}`}>
<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-1 min-h-0">
<div className="overflow-hidden h-full">
<img src={thumbUrl(article.coverImage)} alt={article.title} className="w-full h-full object-cover transition-transform duration-500 group-hover:scale-105" style={{ objectPosition: objPos }} loading="lazy" />
</div>
{isVideo && (
<div className="absolute inset-0 flex items-center justify-center">
<div className="w-10 h-10 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="p-3 flex-shrink-0">
<div className="flex items-center gap-2 mb-1">
<span className="text-xs font-medium text-primary">{article.author}</span>
<span className="text-muted-foreground text-xs">{timeAgo(new Date(article.publishedAt))}</span>
</div>
<h3 className="font-bold 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-1.5 mt-2 text-muted-foreground text-xs">
<Eye className="w-3 h-3" />
{article.views.toLocaleString()}
</div>
</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="3854634730"
format="fluid"
layoutKey="-7o+et-x-1e+6a"
style={{ display: "block" }}
className="w-full h-full min-h-[160px]"
/>
</div>
</div>
</div>
);
}
function TopStoriesList({ articles, className }: { articles: Article[]; className?: string }) {
return (
<div className={`bg-card rounded-lg border border-card-border p-4 ${className || ""}`} data-testid="sidebar-top-stories">
<h3 className="font-bold text-card-foreground text-sm mb-3 flex items-center gap-2">
<span className="w-1 h-4 bg-primary rounded-full" />
Zuletzt hinzugefügt
</h3>
<div className="space-y-0">
{articles.slice(0, 5).map((article) => (
<Link key={article.id} href={`/article/${article.slug}`}>
<div className="group cursor-pointer py-1.5 border-b border-card-border last:border-0" data-testid={`card-top-${article.id}`}>
<h4 className="text-xs 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-1.5 mt-0.5">
<span className="text-[10px] text-muted-foreground">{article.author}</span>
<span className="text-muted-foreground/50 text-[10px]">{timeAgo(new Date(article.publishedAt))}</span>
</div>
</div>
</Link>
))}
</div>
</div>
);
}
function FeaturedHeroCard({ article, focalPoints }: { article: Article; focalPoints?: Record<string, { x: number; y: number }> }) {
const isVideo = article.category === "Video";
const objPos = getObjectPosition(article.coverImage, focalPoints);
return (
<Link href={`/article/${article.slug}`}>
<div className="relative group cursor-pointer rounded-lg overflow-hidden bg-card border border-card-border h-full flex flex-col" data-testid={`card-featured-hero-${article.id}`}>
<div className="relative flex-1 min-h-[300px]">
<img src={article.coverImage || ""} alt={article.title} className="w-full h-full object-cover absolute inset-0 transition-transform duration-700 group-hover:scale-105" style={{ objectPosition: objPos }} 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">
<Play className="w-6 h-6 text-white ml-0.5" fill="white" />
</div>
</div>
)}
</div>
<div className="p-4 flex-shrink-0">
<div className="flex items-center gap-2 mb-1.5">
<span className="text-xs font-medium text-primary">{article.category}</span>
<span className="text-muted-foreground text-xs">{timeAgo(new Date(article.publishedAt))}</span>
</div>
<h3 className="font-bold text-card-foreground text-lg leading-tight line-clamp-3 group-hover:text-primary transition-colors">{article.title}</h3>
<p className="text-sm text-muted-foreground mt-2 leading-relaxed line-clamp-3">{article.excerpt}</p>
<div className="flex items-center gap-2 mt-3 text-muted-foreground text-xs">
<Eye className="w-3.5 h-3.5" />
{article.views.toLocaleString()}
</div>
</div>
</div>
</Link>
);
}
function FeaturedCarousel({ articles, popular, galleryImages, focalPoints }: { articles: Article[]; popular?: Article[]; galleryImages?: GalleryImage[]; focalPoints?: Record<string, { x: number; y: number }> }) {
const pageSize = 3;
const totalPages = Math.max(1, Math.ceil(Math.min(articles.length, 9) / pageSize));
const [page, setPage] = useState(0);
const [displayPage, setDisplayPage] = useState(0);
const [fading, setFading] = useState(false);
const [paused, setPaused] = useState(false);
const changePage = useCallback((newPage: number) => {
if (newPage === displayPage) return;
setFading(true);
setTimeout(() => {
setDisplayPage(newPage);
setFading(false);
}, 300);
}, [displayPage]);
const next = useCallback(() => {
setPage((p) => {
const np = (p + 1) % totalPages;
return np;
});
}, [totalPages]);
useEffect(() => {
changePage(page);
}, [page]);
useEffect(() => {
if (paused || totalPages <= 1) return;
const timer = setInterval(next, 8000);
return () => clearInterval(timer);
}, [paused, next, totalPages]);
const start = displayPage * pageSize;
const hero = articles[start];
const sideCards = articles.slice(start + 1, start + 3);
const nextPageStart = ((displayPage + 1) % totalPages) * pageSize;
const nextPageArticles = articles.slice(nextPageStart, nextPageStart + pageSize);
return (
<section data-testid="featured-carousel" onMouseEnter={() => setPaused(true)} onMouseLeave={() => setPaused(false)}>
{nextPageArticles.length > 0 && (
<div className="hidden" aria-hidden="true">
{nextPageArticles.map((a) => a.coverImage && <img key={`preload-${a.id}`} src={a.coverImage} alt="" />)}
</div>
)}
<div
className="grid grid-cols-1 lg:grid-cols-5 gap-4 transition-opacity duration-300"
style={{ opacity: fading ? 0 : 1 }}
>
<div className="lg:col-span-3">
{hero && <FeaturedHeroCard article={hero} focalPoints={focalPoints} />}
</div>
<div className="lg:col-span-2 grid grid-rows-2 gap-4">
{sideCards.map((a) => (
<SideCard key={a.id} article={a} focalPoints={focalPoints} />
))}
</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 h-2 rounded-full transition-all duration-300 ${i === page ? "bg-primary w-5" : "bg-muted-foreground/30 hover:bg-muted-foreground/50"}`} data-testid={`button-carousel-dot-${i}`} />
))}
</div>
)}
</section>
);
}
function LazySection({ children, minHeight = "200px" }: { children: JSX.Element; minHeight?: string }) {
const ref = useRef<HTMLDivElement>(null);
const [visible, setVisible] = useState(false);
useEffect(() => {
const el = ref.current;
if (!el) return;
const obs = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
setVisible(true);
obs.disconnect();
}
},
{ rootMargin: "300px" }
);
obs.observe(el);
return () => obs.disconnect();
}, []);
return (
<div ref={ref} style={{ minHeight: visible ? undefined : minHeight }}>
{visible ? children : null}
</div>
);
}
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() {
usePageMeta("Volksmusik & Schlager TV", "FOLX TV Ihr Fernsehsender Nr. 1 für Volksmusik und Schlager. Aktuelle Nachrichten, Musikvideos, Künstlerporträts, Live-Shows und Interviews aus der Welt der volkstümlichen Musik.");
useEffect(() => {
const jsonLd = {
"@context": "https://schema.org",
"@type": "WebSite",
"name": "Folx Music Television",
"alternateName": ["FOLX TV", "Volksmusik TV"],
"url": "https://folx.tv",
"description": "FOLX TV Ihr Fernsehsender Nr. 1 für Volksmusik und Schlager. Aktuelle Nachrichten, Musikvideos, Künstlerporträts, Live-Shows und Interviews aus der Welt der volkstümlichen Musik.",
"publisher": {
"@type": "Organization",
"name": "Folx Music Television",
"url": "https://folx.tv",
"logo": {
"@type": "ImageObject",
"url": `${window.location.origin}/favicon.png`
},
"sameAs": [
"https://www.facebook.com/folxtvmusic",
"https://www.instagram.com/folxtv",
"https://www.youtube.com/@FolxTV",
"https://www.tiktok.com/@folx.tv"
]
},
"potentialAction": {
"@type": "SearchAction",
"target": "https://folx.tv/search?q={search_term_string}",
"query-input": "required name=search_term_string"
},
"inLanguage": "de"
};
const script = document.createElement("script");
script.type = "application/ld+json";
script.id = "home-jsonld";
script.textContent = JSON.stringify(jsonLd);
const existing = document.getElementById("home-jsonld");
if (existing) existing.remove();
document.head.appendChild(script);
return () => {
const el = document.getElementById("home-jsonld");
if (el) el.remove();
};
}, []);
const { data: articles, isLoading } = useQuery<Article[]>({
queryKey: ["/api/articles"],
});
const { data: popular } = useQuery<Article[]>({
queryKey: ["/api/articles/popular"],
});
const { data: galleryImages } = useQuery<GalleryImage[]>({
queryKey: ["/api/gallery", "home"],
queryFn: () => fetch("/api/gallery?limit=20").then((r) => r.json()),
});
const focalPoints = useFocalPoints();
const shuffled = useMemo(() => {
if (!articles) return [];
const arr = [...articles];
for (let i = arr.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[arr[i], arr[j]] = [arr[j], arr[i]];
}
return arr;
}, [articles]);
const widgets = useMemo(() => [
{ id: "horoscope", el: <HoroscopeWidget key="horoscope" /> },
{ id: "news", el: <div key="news" className="flex flex-col gap-4"><NewsWidget /></div> },
{ id: "gallery", el: <PhotoGalleryWidget key="gallery" /> },
{ id: "recipe", el: <RecipeWidget key="recipe" /> },
{ id: "breaking", el: <div key="breaking" className="flex flex-col gap-4"><BreakingNewsWidget /></div> },
{ id: "gallery2", el: <PhotoGalleryWidget key="gallery2" reverseOrder={true} /> },
{ id: "horoscope2", el: <HoroscopeWidget key="horoscope2" /> },
{ id: "news2", el: <div key="news2" className="flex flex-col gap-4"><NewsWidget /></div> },
{ id: "recipe2", el: <RecipeWidget key="recipe2" /> },
{ id: "breaking2", el: <div key="breaking2" className="flex flex-col gap-4"><BreakingNewsWidget /></div> },
], []);
const gridItems = useMemo(() => {
const items: { type: "article" | "widget" | "ad"; key: string; article?: Article; widget?: typeof widgets[0] }[] = [];
let ai = 0;
let wi = 0;
const widgetRows = Math.ceil(widgets.length / 2);
for (let r = 0; r < widgetRows; r++) {
if (wi < widgets.length) items.push({ type: "widget", key: `w-${widgets[wi].id}`, widget: widgets[wi++] });
if (wi < widgets.length) items.push({ type: "widget", key: `w-${widgets[wi].id}`, widget: widgets[wi++] });
if (ai < shuffled.length) items.push({ type: "article", key: `a-${shuffled[ai].id}`, article: shuffled[ai++] });
if (ai < shuffled.length) items.push({ type: "article", key: `a-${shuffled[ai].id}`, article: shuffled[ai++] });
}
while (ai < shuffled.length) {
items.push({ type: "article", key: `a-${shuffled[ai].id}`, article: shuffled[ai++] });
}
const remainder = items.length % 4;
if (remainder > 0 && shuffled.length > 0) {
let fill = 0;
while (items.length % 4 !== 0) {
const art = shuffled[fill % shuffled.length];
items.push({ type: "article", key: `fill-${fill}`, article: art });
fill++;
}
}
let adCount = 0;
const totalRows = items.length / 4;
for (let r = 0; r < totalRows; r++) {
const rowStart = r * 4;
const hasWidget = items.slice(rowStart, rowStart + 4).some((it) => it.type === "widget");
if (!hasWidget) {
const pos = adCount % 2 === 0 ? 0 : 3;
items[rowStart + pos] = { type: "ad", key: `ad-row-${adCount++}` };
}
}
return items;
}, [shuffled, widgets]);
const gridRows: (typeof gridItems)[] = useMemo(() => {
const rows: (typeof gridItems)[] = [];
for (let i = 0; i < gridItems.length; i += 4) {
rows.push(gridItems.slice(i, i + 4));
}
return rows;
}, [gridItems]);
const widePickedArticles = useMemo(() => {
if (!articles || articles.length < 3) return [];
const carouselIds = new Set(articles.slice(0, 9).map((a) => a.id));
const candidates = articles.filter((a) => !carouselIds.has(a.id));
const pool = candidates.length >= 2 ? candidates : articles;
const copy = [...pool];
for (let i = copy.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[copy[i], copy[j]] = [copy[j], copy[i]];
}
return copy.slice(0, 2);
}, [articles]);
const bottomArticles = useMemo(() => {
if (!articles || articles.length === 0) return [];
const carouselIds = new Set(articles.slice(0, 9).map((a) => a.id));
const nonCarousel = articles.filter((a) => !carouselIds.has(a.id));
const pool = nonCarousel.length > 0 ? nonCarousel : articles;
const result: Article[] = [];
let idx = 0;
while (result.length < 9) {
result.push(pool[idx % pool.length]);
idx++;
}
return result;
}, [articles]);
const bottomSection = useMemo(() => {
const items: { type: "widget" | "ad" | "article"; el: JSX.Element }[] = [
{ type: "widget", el: <NewsWidget key="bottom-news" /> },
{ type: "widget", el: <HoroscopeWidget key="bottom-horoscope" /> },
{ type: "widget", el: <BreakingNewsWidget key="bottom-breaking" /> },
{ type: "article", el: <BlogCard key={`bottom-art-0`} article={bottomArticles[0]} focalPoints={focalPoints} /> },
{ type: "ad", el: <div key="ad-bottom-1"><ArticleCardAd /></div> },
{ type: "article", el: <BlogCard key={`bottom-art-1`} article={bottomArticles[1]} focalPoints={focalPoints} /> },
{ type: "widget", el: <PhotoGalleryWidget key="bottom-gallery" /> },
{ type: "article", el: <BlogCard key={`bottom-art-2`} article={bottomArticles[2]} focalPoints={focalPoints} /> },
{ type: "article", el: <BlogCard key={`bottom-art-3`} article={bottomArticles[3]} focalPoints={focalPoints} /> },
{ type: "article", el: <BlogCard key={`bottom-art-4`} article={bottomArticles[4]} focalPoints={focalPoints} /> },
{ type: "ad", el: <div key="ad-bottom-2"><ArticleCardAd /></div> },
{ type: "article", el: <BlogCard key={`bottom-art-5`} article={bottomArticles[5]} focalPoints={focalPoints} /> },
];
return items;
}, [bottomArticles, focalPoints]);
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>
<Footer />
</div>
);
}
return (
<div className="min-h-screen bg-background">
<Header />
<PageSideAds />
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4 space-y-4">
<h1 className="sr-only">FOLX TV Volksmusik & Schlager Fernsehsender: Nachrichten, Videos und Live-Shows</h1>
<div className="grid grid-cols-1 lg:grid-cols-4 gap-4">
<div className="lg:col-span-3 space-y-4">
<div className="hidden md:block">
<FeaturedCarousel articles={articles.filter(a => a.featured)} popular={popular} galleryImages={galleryImages} focalPoints={focalPoints} />
</div>
<MobileMosaic
latest={[...articles].sort((a, b) => new Date(b.publishedAt).getTime() - new Date(a.publishedAt).getTime())}
popular={popular}
focalPoints={focalPoints}
/>
</div>
<div className="lg:col-span-1 space-y-4">
<SidebarWeatherWidget />
{articles && articles.length > 0 && <TopStoriesList articles={[...articles].sort((a, b) => new Date(b.publishedAt).getTime() - new Date(a.publishedAt).getTime()).slice(0, 5)} />}
<SidebarAd />
</div>
</div>
{gridRows.map((row, ri) => (
<LazySection key={`row-group-${ri}`} minHeight="280px">
<div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
{row.map((item) =>
item.type === "widget"
? <div key={item.key}>{item.widget!.el}</div>
: item.type === "ad"
? <div key={item.key} className="h-full" data-testid={`ad-grid-${item.key}`}><ArticleCardAd /></div>
: item.article
? <MediumCard key={item.key} article={item.article} focalPoints={focalPoints} />
: null
)}
{ri === gridRows.length - 1 && widePickedArticles.length > 0 && (
<div className="sm:col-span-2 lg:col-span-4 grid grid-cols-1 sm:grid-cols-2 gap-4">
<WideCardClassic article={widePickedArticles[0]} focalPoints={focalPoints} />
{widePickedArticles[1] && <WideCardClassic article={widePickedArticles[1]} focalPoints={focalPoints} />}
</div>
)}
</div>
</div>
</LazySection>
))}
<LazySection minHeight="280px">
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
{bottomSection.map((item, i) => (
<div key={`bottom-${i}`}>
{item.el}
</div>
))}
</div>
</LazySection>
<LazySection minHeight="280px">
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
<PhotoGalleryWidget key="extra-gallery" />
<NewsWidget key="extra-news" />
<RecipeWidget key="extra-recipe" />
<div className="h-full"><ArticleCardAd /></div>
</div>
</LazySection>
</main>
<Footer />
<PushPromptBanner />
</div>
);
}