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

1163 lines
52 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 { artistGroup } from "@/lib/artists";
import { objectPositionFor } from "@/lib/focal";
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, MultiplexAd, TopBannerAd, GridBannerAd } from "@/components/adsense";
import { NewsWidget } from "@/components/news-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>
);
}
/**
* Zaris naslovnice. Kljuci v /api/focal-points so relativni (/uploads/x.webp),
* coverImage pa je pogosto absoluten (https://folx.tv/uploads/x.webp) - prej se
* kljuc ni ujel, zaris se ni uporabil in kvadratne ploscice so rezale glave.
* Zato uporabimo skupno objectPositionFor, ki obe obliki poravna.
*/
function getObjectPosition(coverImage: string | null, focalPoints?: Record<string, { x: number; y: number }>): string {
return objectPositionFor(coverImage, focalPoints as any);
}
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>
);
}
/** Isti izvajalec ne sme stati drug ob drugem: med dvema clankoma vsaj 5 mest. */
function razmakniPoIzvajalcu(list: Article[], razmik = 5, seed: string[] = []): Article[] {
const rest = [...(list || [])];
const out: Article[] = [];
const recent: string[] = seed.filter(Boolean).slice(-(razmik - 1));
while (rest.length) {
let idx = rest.findIndex((a) => {
const k = artistGroup(a?.title || "");
return !k || !recent.includes(k);
});
if (idx < 0) idx = 0;
const a = rest.splice(idx, 1)[0];
out.push(a);
recent.push(artistGroup(a?.title || ""));
if (recent.length >= razmik) recent.shift();
}
return out;
}
function MobileMosaic({ latest, popular, focalPoints }: { latest: Article[]; popular?: Article[]; focalPoints?: Record<string, { x: number; y: number }> }) {
const pageSize = 3;
const pool = razmakniPoIzvajalcu(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 poolGroups = new Set(pool.map((a) => artistGroup(a?.title || "")).filter(Boolean));
const kandidati = (popular || []).filter((a) => !usedIds.has(a.id));
const brezPodvojitve = kandidati.filter((a) => {
const g = artistGroup(a?.title || "");
return !g || !poolGroups.has(g);
});
const p = razmakniPoIzvajalcu(
brezPodvojitve.length >= 3 ? brezPodvojitve : kandidati,
).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.5 rounded-full transition-all duration-300 ${i === page ? "bg-primary w-8" : "bg-foreground/35 w-2.5"}`}
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>
);
}
interface YtVideo { ytId: string; slug: string; shortTitle?: string; title: string; rubrik?: string; publishedAt?: string; durationS?: number; teaser?: string }
// Video kartica v mrezi: enaka velikost kot kartica clanka, da v vrstici ni
// luknje. Pelje na /video/<slug>, kjer je oglasni prostor, ne na YouTube.
function VideoCard({ video }: { video: YtVideo }) {
const mm = Math.floor((video.durationS || 0) / 60);
const ss = String((video.durationS || 0) % 60).padStart(2, "0");
return (
<Link href={`/video/${video.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-video-${video.ytId}`}>
<div className="relative flex-shrink-0">
<img
src={`https://i.ytimg.com/vi/${video.ytId}/hqdefault.jpg`}
alt={video.shortTitle || video.title}
className="w-full aspect-video object-cover transition-transform duration-500 group-hover:scale-105"
loading="lazy"
/>
<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>
{video.durationS ? (
<span className="absolute bottom-1.5 right-1.5 rounded bg-black/75 px-1.5 py-0.5 text-[10px] font-medium text-white">
{mm}:{ss}
</span>
) : null}
</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">VIDEO</span>
{video.publishedAt && (
<span className="text-muted-foreground text-[10px]">{timeAgo(new Date(video.publishedAt))}</span>
)}
</div>
<h3 className="font-semibold text-card-foreground text-sm leading-snug line-clamp-2 group-hover:text-primary transition-colors">
{video.shortTitle || video.title}
</h3>
<div className="flex-1" />
<div className="flex items-center gap-2 mt-2 text-muted-foreground text-[10px]">
<Play className="w-3 h-3" />
Video ansehen
</div>
</div>
</div>
</Link>
);
}
// compact: kartica ob oglasu je visoka 292 px namesto 399, zato se izvlecek
// omeji na dve vrstici, sicer ga spodaj odreze sredi stavka.
function MediumCard({ article, focalPoints, compact }: { article: Article; focalPoints?: Record<string, { x: number; y: number }>; compact?: boolean }) {
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>
{/* Kartica je visoka 300 px, kolikor meri oglas 300x250 z napisom
Anzeige. Za izvlecek ni prostora, ostanejo slika, rubrika, cas,
naslov na dve vrstici in stevilo ogledov. */}
<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">
{razmakniPoIzvajalcu(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}`}>
{/* Visek visine gre v sliko, ne v prazen prostor pod besedilom. Prej je
imela slika fiksno razmerje in se je raztegnil besedilni del, kar je
pod novico naredilo velik crn pas. */}
<div className="relative flex-none aspect-[16/10] lg:flex-1 lg:aspect-auto lg:min-h-0">
<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-none">
<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 className="lg:h-full lg:flex lg:flex-col" 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 lg:grid-rows-1 gap-4 lg:flex-1 lg:min-h-[460px] lg:max-h-[560px] 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 items-center gap-2.5 mt-4" data-testid="carousel-dots">
{Array.from({ length: totalPages }).map((_, i) => (
<button
key={i}
onClick={() => setPage(i)}
aria-label={`Seite ${i + 1}`}
className={`h-2.5 rounded-full transition-all duration-300 ${i === page ? "bg-primary w-8" : "bg-foreground/35 hover:bg-foreground/60 w-2.5"}`}
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();
};
}, []);
// Prvi paket je majhen in brez polja content, da se naslovnica izrise takoj;
// ostalo se dolije v ozadju. Prej je sla naslovnica po vseh 550 clankov s
// celotnim besedilom (1,77 MB).
const { data: firstBatch, isLoading } = useQuery<Article[]>({
queryKey: ["/api/articles?limit=30&light=1"],
});
// 31. 8. 2026: naslovnica je nalozila se vseh 273 preostalih clankov (142 kB)
// in jih izrisala v eno neskoncno mrezo. Zdaj se ustavi pri 48, naprej pa
// pelje gumb v rubriko, kjer je stranicenje.
const { data: restBatch } = useQuery<Article[]>({
queryKey: ["/api/articles?offset=30&limit=40&light=1"],
enabled: !!firstBatch,
});
const allArticles = useMemo(() => {
const seen = new Set<number>();
const out: Article[] = [];
for (const a of [...(firstBatch || []), ...(restBatch || [])]) {
if (!seen.has(a.id)) { seen.add(a.id); out.push(a); }
}
return out;
}, [firstBatch, restBatch]);
// Splosne novice imajo svojo rubriko /aktuelles in ne sodijo na naslovnico,
// ki ostaja Volksmusik in Schlager.
const articles = useMemo(
() =>
(allArticles || []).filter(
(a) => !["Aktuelles", "Politik", "Promis", "Sport", "Musik & Film", "Wetter", "Finanzen"].includes(a.category)
),
[allArticles]
);
const { data: popular } = useQuery<Article[]>({
queryKey: ["/api/articles/popular"],
});
// Videi v mrezi: gledalca zadrzijo pri nas in odprejo se eno stran z oglasi.
const { data: ytData } = useQuery<{ items: YtVideo[] }>({
queryKey: ["/api/yt-videos?limit=12"],
});
const videos = ytData?.items || [];
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 key = (id: number) => {
let h = id * 2654435761 % 4294967296;
h ^= h >>> 13; h = (h * 1274126177) % 4294967296;
return h;
};
const head = articles.slice(0, 30).sort((a, b) => key(a.id) - key(b.id));
const tail = articles.slice(30).sort((a, b) => key(a.id) - key(b.id));
return [...head, ...tail];
}, [articles]);
// Na naslovnici isti izvajalec ne sme stati drug ob drugem: med dvema clankoma
// istega izvajalca mora biti vsaj 5 mest. Clanek ostane v rubriki News in v indeksu.
const RAZMIK = 5;
const spaced = useMemo(() => {
const rest = [...shuffled];
const out: Article[] = [];
const recent: string[] = [];
while (rest.length) {
let idx = rest.findIndex((a) => {
const k = artistGroup(a.title || "");
return !k || !recent.includes(k);
});
if (idx < 0) idx = 0;
const a = rest.splice(idx, 1)[0];
out.push(a);
recent.push(artistGroup(a.title || ""));
if (recent.length >= RAZMIK) recent.shift();
}
return out;
}, [shuffled]);
// 30. 8. 2026: horoskop, recept in galerija so vzeti z naslovnice. Po GA4 so
// v 28 dneh naredili 242, 60 in 16 ogledov od 96.307, clanki pa 93.001.
// Sprosceni prostor gre clankom.
const widgets = useMemo(() => [
{ id: "news", el: <div key="news" className="flex flex-col gap-4"><NewsWidget /></div> },
{ id: "breaking", el: <div key="breaking" className="flex flex-col gap-4"><BreakingNewsWidget /></div> },
{ id: "news2", el: <div key="news2" className="flex flex-col gap-4"><NewsWidget startOffset={5} /></div> },
{ id: "breaking2", el: <div key="breaking2" className="flex flex-col gap-4"><BreakingNewsWidget startOffset={5} /></div> },
], []);
const gridItems = useMemo(() => {
const items: { type: "article" | "widget" | "ad"; key: string; article?: Article; widget?: typeof widgets[0] }[] = [];
let ai = 0;
let wi = 0;
// Pripomocki z zunanjimi viri ne smejo stati skupaj: prej sta bila dva v isti
// vrstici in nato spet dva v naslednji, kar je izgledalo kot podvojen blok.
// Zdaj gre en pripomocek in za njim pet clankov.
const RAZMIK_PRIPOMOCKOV = 5;
while (wi < widgets.length) {
items.push({ type: "widget", key: `w-${widgets[wi].id}`, widget: widgets[wi++] });
for (let k = 0; k < RAZMIK_PRIPOMOCKOV && ai < spaced.length; k++) {
items.push({ type: "article", key: `a-${spaced[ai].id}`, article: spaced[ai++] });
}
}
while (ai < spaced.length) {
items.push({ type: "article", key: `a-${spaced[ai].id}`, article: spaced[ai++] });
}
const remainder = items.length % 4;
if (remainder > 0 && spaced.length > 0) {
let fill = 0;
while (items.length % 4 !== 0) {
const art = spaced[fill % spaced.length];
items.push({ type: "article", key: `fill-${fill}`, article: art });
fill++;
}
}
// Oglas gre le v vsako drugo vrstico. Meritev 31. 8. 2026: od 13 enot na
// naslovnici jih je 9 vrnilo unfilled, torej je mest vec, kot jih Google
// za to stran zapolni. Manj mest z visjo zapolnjenostjo prinese vec.
let adCount = 0;
let vidCount = 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) continue;
if (r % 2 === 1) {
const pos = r === 1 ? 3 : adCount % 2 === 0 ? 0 : 3;
items[rowStart + pos] = { type: "ad", key: `ad-row-${adCount++}` };
} else if (r > 0 && videos.length) {
// video namesto zadnjega clanka v vrstici brez oglasa
const v = videos[vidCount % videos.length];
vidCount += 1;
items[rowStart + 2] = { type: "video", key: `v-${v.ytId}`, video: v };
}
}
return items;
}, [spaced, widgets, videos]);
const NA_NASLOVNICI = 48;
const gridRows: (typeof gridItems)[] = useMemo(() => {
const rows: (typeof gridItems)[] = [];
let clankov = 0;
const omejeno = gridItems.filter((it) => {
if (it.type !== "article") return true;
clankov += 1;
return clankov <= NA_NASLOVNICI;
});
// Zadnja vrstica ne sme biti nepopolna, sicer ostanejo prazne celice.
const polne = omejeno.slice(0, omejeno.length - (omejeno.length % 4));
for (let i = 0; i < polne.length; i += 4) {
rows.push(polne.slice(i, i + 4));
}
return rows;
}, [gridItems]);
// Siroka postavitev z dvema novicama: prej samo na koncu mreze, zdaj po
// vsaki tretji vrstici. Clanke jemljemo izza omejitve naslovnice, da se
// nobeden ne ponovi.
const widePairs = useMemo(() => {
const vMrezi = new Set(spaced.slice(0, 48).map((a) => a.id));
const ostanek = spaced.filter((a) => !vMrezi.has(a.id));
const pari: Article[][] = [];
for (let i = 0; i + 1 < ostanek.length && pari.length < 6; i += 2) {
pari.push([ostanek[i], ostanek[i + 1]]);
}
return pari;
}, [spaced]);
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: <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: "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>
{/* Prej lastna kopija pasu s trdo visino 90 px in responsive=false:
Google je vanjo postavil 320x50 in okrog je ostal siv rob.
Zdaj skupna odzivna komponenta - kreativa zapolni sirino, prazna
enota pa se sploh ne izrise. */}
<TopBannerAd />
<div className="grid grid-cols-1 lg:grid-cols-4 gap-4">
<div className="lg:col-span-3 space-y-4 lg:flex lg:flex-col">
{/* Prej je bila visina karusela fiksna, desni stolpec pa visji —
pod karuselom je zato ostal prazen pas. Zdaj se karusel
raztegne do visine stranskega stolpca. */}
<div className="hidden md:block lg:flex-1 lg:min-h-0">
<FeaturedCarousel articles={razmakniPoIzvajalcu(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 lg:flex lg:flex-col">
{articles && articles.length > 0 && <TopStoriesList articles={razmakniPoIzvajalcu([...articles].sort((a, b) => new Date(b.publishedAt).getTime() - new Date(a.publishedAt).getTime())).slice(0, 14)} className="lg:flex-1 lg:min-h-0 overflow-hidden" />}
<SidebarAd />
</div>
</div>
{gridRows.map((row, ri) => {
// Oglas je visok kot dve kartici, zato vrstico z oglasom zdruzimo z
// naslednjo: oglas zasede levi stolpec cez dve vrstici, ob njem pa
// gresta dve vrsti novic (sest kartic). Prej je pod tremi karticami
// ob visokem oglasu ostal prazen prostor.
// 31. 8. 2026: oglas je zdaj 300x250 in gre v eno samo celico, zato
// zdruzevanja dveh vrstic ne rabimo vec.
return (
<LazySection key={`row-group-${ri}`} minHeight="280px">
<div>
{/* Trdna visina vrstice: brez nje je vsaka vrstica visoka toliko kot
najdaljsi izvlecek v njej (izmerjeno 369, 399, 418 px) in stran
ob vsakem nalaganju poskakuje. */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 lg:auto-rows-[300px]">
{row.map((item) =>
item.type === "widget"
? <div key={item.key} className="h-full overflow-hidden">{item.widget!.el}</div>
: item.type === "ad"
? <div key={item.key} className="h-full empty:hidden" data-testid={`ad-grid-${item.key}`}><ArticleCardAd /></div>
: item.type === "video" && item.video
? <VideoCard key={item.key} video={item.video} />
: item.article
? <MediumCard key={item.key} article={item.article} focalPoints={focalPoints} />
: null
)}
{ri > 0 && (ri + 1) % 3 === 0 && (
<div className="sm:col-span-2 lg:col-span-4 lg:row-auto lg:h-auto self-start">
<GridBannerAd />
</div>
)}
{ri > 0 && (ri + 1) % 3 === 0 && widePairs[Math.floor(ri / 3) % Math.max(1, widePairs.length)] && (
{/* Siroka dvojica ni kartica in ne sme prevzeti trdne visine
vrstice 399 px - z njo je pod besedilom ostal prazen pas. */}
<div className="sm:col-span-2 lg:col-span-4 lg:row-auto lg:h-auto self-start grid grid-cols-1 sm:grid-cols-2 gap-4">
<WideCardClassic article={widePairs[Math.floor(ri / 3) % widePairs.length][0]} focalPoints={focalPoints} />
<WideCardClassic article={widePairs[Math.floor(ri / 3) % widePairs.length][1]} focalPoints={focalPoints} />
</div>
)}
{ri === gridRows.length - 1 && widePickedArticles.length > 0 && (
<div className="sm:col-span-2 lg:col-span-4 lg:row-auto lg:h-auto self-start 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>
);
})}
{/* Naslovnica se konca pri 48 clankih, naprej pelje rubrika s stranicenjem. */}
<div className="flex justify-center my-8">
<a
href="/category/News"
data-testid="link-alle-nachrichten"
className="inline-flex items-center gap-2 rounded-lg border border-border bg-card px-6 py-3 text-sm font-semibold hover:border-primary hover:text-primary transition-colors"
>
Alle Nachrichten
<span aria-hidden="true">→</span>
</a>
</div>
<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">
{/* items-start: oglas 300x600 je sicer raztegnil oba seznama na svojo
visino in med novicami so nastale velike luknje. */}
{/* Trdna visina: pripomocka se vsakih pet sekund zavrtita in ob
razlicno dolgih naslovih sta prej spreminjala visino, zato je
stran pod njima poskakovala. */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 items-start">
<div className="h-[600px] overflow-hidden"><NewsWidget key="extra-news" startOffset={10} /></div>
<div className="h-[600px] overflow-hidden"><BreakingNewsWidget key="extra-breaking" startOffset={10} /></div>
<div className="h-[600px]"><ArticleCardAd /></div>
</div>
</LazySection>
{/* Multiplex je zadnjih sedem dni najhitreje rastoca enota (+500 %),
naslovnica ga edina ni imela. */}
<div className="mt-6" data-testid="ad-multiplex-home">
<div className="text-[9px] text-muted-foreground/40 text-center pb-1 uppercase tracking-widest">Anzeige</div>
<MultiplexAd />
</div>
</main>
<Footer />
<PushPromptBanner />
</div>
);
}