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>
|
||||
);
|
||||
}
|
||||
@@ -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>·</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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user