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
+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>
);
}