Modify the Footer component to accept a `narrow` prop, and apply it on article pages to constrain the footer's max-width to match the article content area. Replit-Commit-Author: Agent Replit-Commit-Session-Id: 23852c00-4779-460a-9e0c-d09fee4b6c92 Replit-Commit-Checkpoint-Type: full_checkpoint Replit-Commit-Event-Id: d3e5a724-3096-4ab6-a1fa-da666b50e4c7 Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/f209e72a-0939-48fa-84fc-57854de71967/23852c00-4779-460a-9e0c-d09fee4b6c92/aNXfGlM Replit-Helium-Checkpoint-Created: true
344 lines
13 KiB
TypeScript
344 lines
13 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],
|
|
});
|
|
|
|
const articleUrl = article ? `${window.location.origin}/article/${article.slug}` : "";
|
|
const articleImage = article?.coverImage
|
|
? (article.coverImage.startsWith("http") ? article.coverImage : `${window.location.origin}${article.coverImage}`)
|
|
: "";
|
|
|
|
usePageMeta(
|
|
article ? `${article.title} - Volksmusik & Schlager` : "Volksmusik & Schlager Artikel",
|
|
article?.excerpt || "Aktuelle Nachrichten aus der Volksmusik- und Schlagerszene bei FOLX TV.",
|
|
article ? {
|
|
ogTitle: article.title,
|
|
ogDescription: article.excerpt,
|
|
ogImage: articleImage || undefined,
|
|
ogType: "article",
|
|
ogUrl: articleUrl,
|
|
articlePublishedTime: new Date(article.publishedAt).toISOString(),
|
|
articleAuthor: article.author || "Folx Music Television",
|
|
articleSection: article.category || "News",
|
|
} : undefined
|
|
);
|
|
|
|
useEffect(() => {
|
|
if (!article) return;
|
|
const jsonLd = {
|
|
"@context": "https://schema.org",
|
|
"@type": "NewsArticle",
|
|
"headline": article.title,
|
|
"description": article.excerpt,
|
|
"image": articleImage ? [articleImage] : [],
|
|
"datePublished": new Date(article.publishedAt).toISOString(),
|
|
"dateModified": new Date(article.publishedAt).toISOString(),
|
|
"author": {
|
|
"@type": "Organization",
|
|
"name": article.author || "Folx Music Television",
|
|
"url": "https://folx.tv"
|
|
},
|
|
"publisher": {
|
|
"@type": "Organization",
|
|
"name": "Folx Music Television",
|
|
"url": "https://folx.tv",
|
|
"logo": {
|
|
"@type": "ImageObject",
|
|
"url": `${window.location.origin}/favicon.png`
|
|
}
|
|
},
|
|
"mainEntityOfPage": {
|
|
"@type": "WebPage",
|
|
"@id": articleUrl
|
|
},
|
|
"articleSection": article.category || "News",
|
|
"inLanguage": "de",
|
|
"keywords": `Volksmusik, Schlager, ${article.category || "News"}, ${article.title}`
|
|
};
|
|
const breadcrumbLd = {
|
|
"@context": "https://schema.org",
|
|
"@type": "BreadcrumbList",
|
|
"itemListElement": [
|
|
{ "@type": "ListItem", "position": 1, "name": "FOLX TV", "item": "https://folx.tv" },
|
|
{ "@type": "ListItem", "position": 2, "name": article.category || "News", "item": `https://folx.tv/category/${encodeURIComponent(article.category || "News")}` },
|
|
{ "@type": "ListItem", "position": 3, "name": article.title, "item": articleUrl }
|
|
]
|
|
};
|
|
const script = document.createElement("script");
|
|
script.type = "application/ld+json";
|
|
script.id = "article-jsonld";
|
|
script.textContent = JSON.stringify([jsonLd, breadcrumbLd]);
|
|
const existing = document.getElementById("article-jsonld");
|
|
if (existing) existing.remove();
|
|
document.head.appendChild(script);
|
|
return () => {
|
|
const el = document.getElementById("article-jsonld");
|
|
if (el) el.remove();
|
|
};
|
|
}, [article, articleUrl, articleImage]);
|
|
|
|
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 contentHalfWidth={480} />
|
|
<main className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-4">
|
|
<Link href="/">
|
|
<Button variant="ghost" size="sm" className="mb-3 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-5 max-h-[420px]">
|
|
<img
|
|
src={article.coverImage}
|
|
alt={article.title}
|
|
className="w-full h-full object-cover"
|
|
style={{ maxHeight: "420px" }}
|
|
data-testid="img-article-cover"
|
|
/>
|
|
<div className="absolute inset-0 bg-gradient-to-t from-black/40 to-transparent" />
|
|
</div>
|
|
)}
|
|
|
|
<div className="mb-5">
|
|
<h1
|
|
className="text-2xl md:text-3xl font-bold text-foreground mb-3 leading-tight"
|
|
data-testid="text-article-title"
|
|
>
|
|
{article.title}
|
|
</h1>
|
|
|
|
<div className="flex flex-wrap items-center gap-3 text-xs text-muted-foreground">
|
|
<span className="flex items-center gap-1.5">
|
|
<User className="w-3.5 h-3.5" />
|
|
{article.author}
|
|
</span>
|
|
<span className="flex items-center gap-1.5">
|
|
<Calendar className="w-3.5 h-3.5" />
|
|
{format(new Date(article.publishedAt), "d. MMMM yyyy", { locale: de })}
|
|
</span>
|
|
<span className="flex items-center gap-1.5">
|
|
<Eye className="w-3.5 h-3.5" />
|
|
{article.views.toLocaleString()} Aufrufe
|
|
</span>
|
|
</div>
|
|
|
|
<div className="mt-3">
|
|
<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-base dark:prose-invert max-w-none
|
|
prose-headings:text-foreground prose-headings:font-semibold prose-headings:text-lg
|
|
prose-p:text-foreground/85 prose-p:leading-relaxed prose-p:text-[15px]
|
|
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-5 [&_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-6 pt-5 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 narrow />
|
|
</div>
|
|
);
|
|
}
|