folx-tv/client/src/pages/article.tsx
sebastjanartic e3414a6e4d Add side advertisements to all pages for increased visibility
Introduce `PageSideAds` component and integrate it across various pages including Home, Article, Category, Videos, Gallery, Horoscope, Recipes, Search, About, Impressum, Datenschutz, and Empfang. This component displays vertical ads on screens wider than 2xl. Additionally, the Home page's existing ad implementation is refactored to use this new component.

Replit-Commit-Author: Agent
Replit-Commit-Session-Id: 1f7e7e89-a520-4970-9645-37daadc466dc
Replit-Commit-Checkpoint-Type: full_checkpoint
Replit-Commit-Event-Id: 6355ba60-5043-4119-a09f-5437b272e829
Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/f209e72a-0939-48fa-84fc-57854de71967/1f7e7e89-a520-4970-9645-37daadc466dc/zWnrdl3
Replit-Helium-Checkpoint-Created: true
2026-03-05 15:59:58 +00:00

278 lines
11 KiB
TypeScript

import { useQuery } from "@tanstack/react-query";
import { useParams, Link } from "wouter";
import { type Article } from "@shared/schema";
import { format } from "date-fns";
import { de } from "date-fns/locale";
import { ArrowLeft, Eye, Calendar, User, Clock } from "lucide-react";
import { usePageMeta } from "@/hooks/use-page-meta";
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
import Header from "@/components/header";
import Footer from "@/components/footer";
import { InArticleAd, PageSideAds } from "@/components/adsense";
import DOMPurify from "dompurify";
import ShareButtons from "@/components/share-buttons";
import { useEffect, useMemo } from "react";
const ALLOWED_IFRAME_DOMAINS = [
"iframe.mediadelivery.net",
"video.bunny.net",
"www.facebook.com",
"www.instagram.com",
"www.tiktok.com",
"www.youtube.com",
"youtube.com",
"player.vimeo.com",
];
function sanitizeContent(html: string): string {
return DOMPurify.sanitize(html, {
ADD_TAGS: ["iframe", "blockquote"],
ADD_ATTR: ["allow", "allowfullscreen", "frameborder", "scrolling", "src", "loading", "style", "class", "title", "data-instgrm-permalink", "data-instgrm-version", "data-instgrm-captioned", "cite"],
ALLOW_UNKNOWN_PROTOCOLS: false,
});
}
function ArticleSkeleton() {
return (
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<Skeleton className="h-8 w-32 mb-6" />
<Skeleton className="w-full aspect-video rounded-md mb-6" />
<Skeleton className="h-10 w-3/4 mb-4" />
<div className="flex gap-4 mb-6">
<Skeleton className="h-4 w-32" />
<Skeleton className="h-4 w-32" />
<Skeleton className="h-4 w-24" />
</div>
<div className="space-y-3">
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-3/4" />
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-5/6" />
</div>
</div>
);
}
function RelatedArticles({ currentSlug }: { currentSlug: string }) {
const { data: articles } = useQuery<Article[]>({
queryKey: ["/api/articles/popular"],
});
const filtered = articles?.filter((a) => a.slug !== currentSlug);
if (!filtered || filtered.length === 0) return null;
return (
<section className="mt-12 border-t border-border pt-8" data-testid="section-related">
<h3 className="text-xl font-semibold text-foreground mb-6">Weitere Artikel</h3>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
{filtered.slice(0, 3).map((article) => (
<Link key={article.id} href={`/article/${article.slug}`}>
<div className="group cursor-pointer" data-testid={`card-related-${article.id}`}>
<div className="relative overflow-hidden rounded-md mb-3">
<img
src={article.coverImage ? article.coverImage.replace(".webp", "-thumb.webp") : "/images/article-1.jpg"}
alt={article.title}
className="w-full aspect-video object-cover transition-transform duration-500 group-hover:scale-105"
style={{ objectPosition: "center 25%" }}
loading="lazy"
/>
</div>
<h4 className="font-medium text-sm text-foreground group-hover:text-primary transition-colors line-clamp-2">
{article.title}
</h4>
<p className="text-xs text-muted-foreground mt-1">
{format(new Date(article.publishedAt), "d. MMMM yyyy", { locale: de })}
</p>
</div>
</Link>
))}
</div>
</section>
);
}
export default function ArticlePage() {
const { slug } = useParams<{ slug: string }>();
const { data: article, isLoading, error } = useQuery<Article>({
queryKey: ["/api/articles", slug],
});
usePageMeta(
article ? `${article.title} - Volksmusik & Schlager` : "Volksmusik & Schlager Artikel",
article?.excerpt || "Aktuelle Nachrichten aus der Volksmusik- und Schlagerszene bei FOLX TV."
);
useEffect(() => {
window.scrollTo(0, 0);
}, [slug]);
useEffect(() => {
if (!article?.content) return;
if (article.content.includes("instagram.com")) {
const existing = document.querySelector('script[src*="instagram.com/embed.js"]');
if (existing) existing.remove();
const script = document.createElement("script");
script.src = "https://www.instagram.com/embed.js";
script.async = true;
document.body.appendChild(script);
script.onload = () => {
if ((window as any).instgrm) {
(window as any).instgrm.Embeds.process();
}
};
}
if (article.content.includes("tiktok.com")) {
if (!document.querySelector('script[src*="tiktok.com/embed.js"]')) {
const script = document.createElement("script");
script.src = "https://www.tiktok.com/embed.js";
script.async = true;
document.body.appendChild(script);
}
}
}, [article?.content]);
if (isLoading) {
return (
<div className="min-h-screen bg-background">
<Header />
<ArticleSkeleton />
</div>
);
}
if (error || !article) {
return (
<div className="min-h-screen bg-background">
<Header />
<div className="max-w-4xl mx-auto px-4 py-16 text-center">
<h1 className="text-2xl font-bold text-foreground mb-4">Artikel nicht gefunden</h1>
<p className="text-muted-foreground mb-6">
Der gesuchte Artikel existiert nicht oder wurde entfernt.
</p>
<Link href="/">
<Button data-testid="button-back-home">Zur Startseite</Button>
</Link>
</div>
</div>
);
}
return (
<div className="min-h-screen bg-background">
<Header />
<PageSideAds />
<main className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<Link href="/">
<Button variant="ghost" size="sm" className="mb-6 gap-2" data-testid="button-back">
<ArrowLeft className="w-4 h-4" />
Zurück
</Button>
</Link>
{article.coverImage && (
<div className="relative overflow-hidden rounded-md mb-8">
<img
src={article.coverImage}
alt={article.title}
className="w-full aspect-video object-cover"
data-testid="img-article-cover"
/>
<div className="absolute inset-0 bg-gradient-to-t from-black/40 to-transparent" />
</div>
)}
<div className="mb-6">
<h1
className="text-3xl md:text-4xl font-bold text-foreground mb-4 leading-tight"
data-testid="text-article-title"
>
{article.title}
</h1>
<div className="flex flex-wrap items-center gap-4 text-sm text-muted-foreground">
<span className="flex items-center gap-1.5">
<User className="w-4 h-4" />
{article.author}
</span>
<span className="flex items-center gap-1.5">
<Calendar className="w-4 h-4" />
{format(new Date(article.publishedAt), "d. MMMM yyyy", { locale: de })}
</span>
<span className="flex items-center gap-1.5">
<Eye className="w-4 h-4" />
{article.views.toLocaleString()} Aufrufe
</span>
</div>
<div className="mt-4">
<ShareButtons
url={`${window.location.origin}/article/${article.slug}`}
title={article.title}
image={article.coverImage ? (article.coverImage.startsWith("http") ? article.coverImage : `${window.location.origin}${article.coverImage}`) : undefined}
/>
</div>
</div>
{(() => {
const sanitized = sanitizeContent(article.content);
const blocks = sanitized.split(/(?=<(?:p|h[2-4]|div)[\s>])/i).filter(Boolean);
const midPoint = Math.max(2, Math.floor(blocks.length / 2));
const firstHalf = blocks.slice(0, midPoint).join("");
const secondHalf = blocks.slice(midPoint).join("");
const proseClasses = `prose prose-lg dark:prose-invert max-w-none
prose-headings:text-foreground prose-headings:font-semibold
prose-p:text-foreground/85 prose-p:leading-relaxed
prose-a:text-primary prose-a:no-underline hover:prose-a:underline
prose-strong:text-foreground
prose-img:rounded-md prose-img:w-full prose-img:object-cover
[&_iframe]:rounded-md [&_iframe]:my-6 [&_iframe]:max-w-full
[&_div[style]]:flex [&_div[style]]:justify-center
[&_blockquote:not(.instagram-media)]:border-l-primary [&_blockquote:not(.instagram-media)]:bg-accent/50 [&_blockquote:not(.instagram-media)]:rounded-r-md [&_blockquote:not(.instagram-media)]:py-1
[&_.instagram-media]:!bg-transparent [&_.instagram-media]:!border-0 [&_.instagram-media]:!shadow-none [&_.instagram-media]:!p-0 [&_.instagram-media]:mx-auto`;
return (
<div ref={(el) => {
if (!el) return;
el.querySelectorAll("a[href]").forEach((a) => {
const href = a.getAttribute("href") || "";
const isBunny = href.includes("mediadelivery.net") || href.includes("bunny.net") || href.includes("b-cdn.net");
const isInternal = href.startsWith("/") || href.includes("folx.tv");
if (!isBunny && !isInternal && href.startsWith("http")) {
a.setAttribute("target", "_blank");
a.setAttribute("rel", "noopener noreferrer");
}
});
}}>
<article
className={proseClasses}
dangerouslySetInnerHTML={{ __html: firstHalf }}
data-testid="article-content"
/>
<InArticleAd />
<article
className={proseClasses}
dangerouslySetInnerHTML={{ __html: secondHalf }}
data-testid="article-content-continued"
/>
</div>
);
})()}
<div className="mt-8 pt-6 border-t border-border">
<ShareButtons
url={`${window.location.origin}/article/${article.slug}`}
title={article.title}
image={article.coverImage ? (article.coverImage.startsWith("http") ? article.coverImage : `${window.location.origin}${article.coverImage}`) : undefined}
/>
</div>
<RelatedArticles currentSlug={slug || ""} />
</main>
<Footer />
</div>
);
}