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:
sebastjanartic
2026-02-28 16:38:38 +00:00
parent bf2021ba3d
commit 4a7639b15d
30 changed files with 1231 additions and 69 deletions
+194
View File
@@ -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>
);
}
+107
View File
@@ -0,0 +1,107 @@
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 { Eye, ArrowLeft } 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";
export default function CategoryPage() {
const { category } = useParams<{ category: string }>();
const { data: articles, isLoading } = useQuery<Article[]>({
queryKey: ["/api/articles/category", category],
});
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-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>
<h1 className="text-2xl font-bold text-foreground mb-6" data-testid="text-category-title">
{category}
</h1>
{isLoading ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{Array.from({ length: 6 }).map((_, i) => (
<div key={i} className="bg-card rounded-md border border-card-border">
<Skeleton className="w-full aspect-video rounded-t-md" />
<div className="p-4 space-y-3">
<Skeleton className="h-3 w-2/3" />
<Skeleton className="h-5 w-full" />
<Skeleton className="h-4 w-3/4" />
</div>
</div>
))}
</div>
) : articles && articles.length > 0 ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{articles.map((article) => (
<Link key={article.id} href={`/article/${article.slug}`}>
<article
className="group cursor-pointer bg-card rounded-md border border-card-border transition-all duration-300"
data-testid={`card-article-${article.id}`}
>
<div className="relative overflow-hidden rounded-t-md">
<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"
/>
<Badge className="absolute top-3 left-3 text-xs no-default-active-elevate">
{article.category}
</Badge>
</div>
<div className="p-4">
<div className="flex items-center gap-2 text-muted-foreground text-xs mb-2">
<span>{article.author}</span>
<span>&middot;</span>
<span>{format(new Date(article.publishedAt), "d. MMMM yyyy", { locale: de })}</span>
</div>
<h3 className="font-semibold text-card-foreground mb-2 line-clamp-2 group-hover:text-primary transition-colors">
{article.title}
</h3>
<p className="text-muted-foreground text-sm line-clamp-3 mb-3">
{article.excerpt}
</p>
<div className="flex items-center justify-between gap-2">
<span className="flex items-center gap-1 text-xs text-muted-foreground">
<Eye className="w-3.5 h-3.5" />
{article.views.toLocaleString()}
</span>
<Button size="sm" data-testid={`button-read-${article.id}`}>
Weiterlesen
</Button>
</div>
</div>
</article>
</Link>
))}
</div>
) : (
<div className="text-center py-16">
<p className="text-muted-foreground text-lg">
Keine Artikel in dieser Kategorie gefunden.
</p>
<Link href="/">
<Button className="mt-4" data-testid="button-back-home">Zur Startseite</Button>
</Link>
</div>
)}
</main>
<Footer />
</div>
);
}
+215
View File
@@ -0,0 +1,215 @@
import { useQuery } from "@tanstack/react-query";
import { Link } from "wouter";
import { type Article } from "@shared/schema";
import { format } from "date-fns";
import { de } from "date-fns/locale";
import { Eye, MessageSquare, ChevronRight, ChevronLeft, Clock } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Skeleton } from "@/components/ui/skeleton";
import { Button } from "@/components/ui/button";
import { useState, useEffect } from "react";
import Header from "@/components/header";
import Footer from "@/components/footer";
function FeaturedCarousel({ articles }: { articles: Article[] }) {
const [current, setCurrent] = useState(0);
useEffect(() => {
if (articles.length === 0) return;
const timer = setInterval(() => {
setCurrent((prev) => (prev + 1) % articles.length);
}, 5000);
return () => clearInterval(timer);
}, [articles.length]);
if (articles.length === 0) return null;
return (
<section className="mb-10" data-testid="featured-carousel">
<h2 className="text-primary font-semibold text-lg mb-4" data-testid="text-featured-heading">Neueste Artikel</h2>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{articles.map((article, i) => (
<Link key={article.id} href={`/article/${article.slug}`}>
<div
className={`relative group cursor-pointer rounded-md overflow-hidden ${i === 0 ? "md:row-span-1" : ""}`}
data-testid={`card-featured-${article.id}`}
>
<div className="aspect-video relative">
<img
src={article.coverImage || "/images/article-1.png"}
alt={article.title}
className="w-full h-full object-cover transition-transform duration-500 group-hover:scale-105"
loading="lazy"
/>
<div className="absolute inset-0 bg-gradient-to-t from-black/80 via-black/30 to-transparent" />
<Badge
className="absolute top-3 left-3 text-xs no-default-active-elevate"
variant={article.category === "Star-News" ? "default" : "default"}
>
{article.category}
</Badge>
<div className="absolute bottom-0 left-0 right-0 p-4">
<span className="text-white/70 text-xs">
{format(new Date(article.publishedAt), "d. MMMM yyyy", { locale: de })}
</span>
<h3 className="text-white font-semibold text-sm md:text-base mt-1 line-clamp-2">
{article.title}
</h3>
</div>
</div>
</div>
</Link>
))}
</div>
</section>
);
}
function ArticleCard({ article }: { article: Article }) {
return (
<Link href={`/article/${article.slug}`}>
<article
className="group cursor-pointer bg-card rounded-md border border-card-border transition-all duration-300"
data-testid={`card-article-${article.id}`}
>
<div className="relative overflow-hidden rounded-t-md">
<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"
/>
<Badge className="absolute top-3 left-3 text-xs no-default-active-elevate">
{article.category}
</Badge>
</div>
<div className="p-4">
<div className="flex items-center gap-2 text-muted-foreground text-xs mb-2">
<span>{article.author}</span>
<span>·</span>
<span>{format(new Date(article.publishedAt), "d. MMMM yyyy", { locale: de })}</span>
</div>
<h3 className="font-semibold text-card-foreground mb-2 line-clamp-2 group-hover:text-primary transition-colors">
{article.title}
</h3>
<p className="text-muted-foreground text-sm line-clamp-3 mb-3">
{article.excerpt}
</p>
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-3 text-xs text-muted-foreground">
<span className="flex items-center gap-1">
<Eye className="w-3.5 h-3.5" />
{article.views.toLocaleString()}
</span>
</div>
<Button size="sm" data-testid={`button-read-${article.id}`}>
Weiterlesen
</Button>
</div>
</div>
</article>
</Link>
);
}
function PopularSidebar({ articles }: { articles: Article[] }) {
return (
<aside className="bg-card rounded-md border border-card-border p-5" data-testid="sidebar-popular">
<h3 className="font-semibold text-card-foreground mb-4 text-base flex items-center justify-between gap-1">
Beliebte Artikel
</h3>
<div className="space-y-4">
{articles.map((article, index) => (
<Link key={article.id} href={`/article/${article.slug}`}>
<div
className="flex gap-3 cursor-pointer group"
data-testid={`card-popular-${article.id}`}
>
<div className="flex-shrink-0 w-8 h-8 rounded-full bg-primary flex items-center justify-center text-primary-foreground font-bold text-sm">
{index + 1}
</div>
<div className="flex-1 min-w-0">
<h4 className="text-sm font-medium text-card-foreground line-clamp-2 group-hover:text-primary transition-colors">
{article.title}
</h4>
<div className="flex items-center gap-1 text-xs text-muted-foreground mt-1">
<Clock className="w-3 h-3" />
{format(new Date(article.publishedAt), "d. MMMM yyyy", { locale: de })}
</div>
</div>
</div>
</Link>
))}
</div>
</aside>
);
}
function ArticleCardSkeleton() {
return (
<div className="bg-card rounded-md border border-card-border">
<Skeleton className="w-full aspect-video rounded-t-md" />
<div className="p-4 space-y-3">
<Skeleton className="h-3 w-2/3" />
<Skeleton className="h-5 w-full" />
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-3/4" />
</div>
</div>
);
}
export default function Home() {
const { data: articles, isLoading } = useQuery<Article[]>({
queryKey: ["/api/articles"],
});
const { data: featured } = useQuery<Article[]>({
queryKey: ["/api/articles/featured"],
});
const { data: popular } = useQuery<Article[]>({
queryKey: ["/api/articles/popular"],
});
const nonFeaturedArticles = articles?.filter((a) => !a.featured) || [];
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-8">
{featured && featured.length > 0 && (
<FeaturedCarousel articles={featured} />
)}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
<div className="lg:col-span-2">
<h2 className="text-primary font-semibold text-lg mb-4" data-testid="text-articles-heading">
Alle Artikel
</h2>
{isLoading ? (
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{Array.from({ length: 4 }).map((_, i) => (
<ArticleCardSkeleton key={i} />
))}
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{(articles || []).map((article) => (
<ArticleCard key={article.id} article={article} />
))}
</div>
)}
</div>
<div className="lg:col-span-1">
{popular && popular.length > 0 && (
<PopularSidebar articles={popular} />
)}
</div>
</div>
</main>
<Footer />
</div>
);
}
+15 -15
View File
@@ -1,21 +1,21 @@
import { Card, CardContent } from "@/components/ui/card";
import { AlertCircle } from "lucide-react";
import { Link } from "wouter";
import { Button } from "@/components/ui/button";
import Header from "@/components/header";
export default function NotFound() {
return (
<div className="min-h-screen w-full flex items-center justify-center bg-gray-50">
<Card className="w-full max-w-md mx-4">
<CardContent className="pt-6">
<div className="flex mb-4 gap-2">
<AlertCircle className="h-8 w-8 text-red-500" />
<h1 className="text-2xl font-bold text-gray-900">404 Page Not Found</h1>
</div>
<p className="mt-4 text-sm text-gray-600">
Did you forget to add the page to the router?
</p>
</CardContent>
</Card>
<div className="min-h-screen bg-background">
<Header />
<div className="flex flex-col items-center justify-center py-32 px-4">
<h1 className="text-6xl font-bold text-primary mb-4" data-testid="text-404">404</h1>
<p className="text-xl text-foreground mb-2">Seite nicht gefunden</p>
<p className="text-muted-foreground mb-8 text-center max-w-md">
Die gesuchte Seite existiert nicht oder wurde verschoben.
</p>
<Link href="/">
<Button data-testid="button-back-home">Zur Startseite</Button>
</Link>
</div>
</div>
);
}