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
278 lines
11 KiB
TypeScript
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>
|
|
);
|
|
}
|