Add new pages and improve blog content display functionality
Implement new routes for articles, categories, and individual articles. Update the UI to display articles with improved content rendering, including safe HTML and media embeds. Refactor storage to use a database and add image upload capabilities. Replit-Commit-Author: Agent Replit-Commit-Session-Id: 413891e8-d784-4bea-b9f5-91a5a68316b4 Replit-Commit-Checkpoint-Type: full_checkpoint Replit-Commit-Event-Id: b96b221e-0ed6-418f-80df-e4670bf5ba4b Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/f209e72a-0939-48fa-84fc-57854de71967/413891e8-d784-4bea-b9f5-91a5a68316b4/cftwqyT Replit-Helium-Checkpoint-Created: true
This commit is contained in:
@@ -0,0 +1,194 @@
|
||||
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 { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import Header from "@/components/header";
|
||||
import Footer from "@/components/footer";
|
||||
import DOMPurify from "dompurify";
|
||||
|
||||
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"],
|
||||
ADD_ATTR: ["allow", "allowfullscreen", "frameborder", "scrolling", "src", "loading"],
|
||||
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() {
|
||||
const { data: articles } = useQuery<Article[]>({
|
||||
queryKey: ["/api/articles/popular"],
|
||||
});
|
||||
|
||||
if (!articles || articles.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">
|
||||
{articles.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 || "/images/article-1.png"}
|
||||
alt={article.title}
|
||||
className="w-full aspect-video object-cover transition-transform duration-500 group-hover:scale-105"
|
||||
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],
|
||||
});
|
||||
|
||||
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 />
|
||||
<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\u00fcck
|
||||
</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">
|
||||
<Badge className="mb-3 no-default-active-elevate" data-testid="badge-category">
|
||||
{article.category}
|
||||
</Badge>
|
||||
<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>
|
||||
|
||||
<article
|
||||
className="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]:w-full [&_iframe]:aspect-video [&_iframe]:rounded-md [&_iframe]:my-6
|
||||
[&_.embed-container]:relative [&_.embed-container]:w-full [&_.embed-container]:my-6
|
||||
[&_.embed-container_iframe]:absolute [&_.embed-container_iframe]:inset-0 [&_.embed-container_iframe]:w-full [&_.embed-container_iframe]:h-full
|
||||
[&_blockquote]:border-l-primary [&_blockquote]:bg-accent/50 [&_blockquote]:rounded-r-md [&_blockquote]:py-1"
|
||||
dangerouslySetInnerHTML={{ __html: sanitizeContent(article.content) }}
|
||||
data-testid="article-content"
|
||||
/>
|
||||
|
||||
<RelatedArticles />
|
||||
</main>
|
||||
<Footer />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user