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

Binary file not shown.

After

Width:  |  Height:  |  Size: 121 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 358 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 206 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 149 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 91 KiB

View File

@ -1,8 +1,13 @@
<!DOCTYPE html>
<html lang="en">
<html lang="de" class="dark">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1" />
<title>news.folx.tv - Folx Music Television News</title>
<meta name="description" content="Das Newsportal von Folx Music Television. Aktuelle Nachrichten, Interviews und Hintergrundberichte aus der Welt der Volksmusik und des Schlagers." />
<meta property="og:title" content="news.folx.tv - Folx Music Television News" />
<meta property="og:description" content="Aktuelle Nachrichten aus der Welt der Volksmusik und des Schlagers." />
<meta property="og:type" content="website" />
<link rel="icon" type="image/png" href="/favicon.png" />
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

View File

@ -4,13 +4,16 @@ import { QueryClientProvider } from "@tanstack/react-query";
import { Toaster } from "@/components/ui/toaster";
import { TooltipProvider } from "@/components/ui/tooltip";
import NotFound from "@/pages/not-found";
import Home from "@/pages/home";
import ArticlePage from "@/pages/article";
import CategoryPage from "@/pages/category";
function Router() {
return (
<Switch>
{/* Add pages below */}
{/* <Route path="/" component={Home}/> */}
{/* Fallback to 404 */}
<Route path="/" component={Home} />
<Route path="/article/:slug" component={ArticlePage} />
<Route path="/category/:category" component={CategoryPage} />
<Route component={NotFound} />
</Switch>
);

View File

@ -0,0 +1,59 @@
import { Link } from "wouter";
export default function Footer() {
return (
<footer className="border-t border-border bg-card mt-16" data-testid="footer">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-10">
<div className="grid grid-cols-1 md:grid-cols-3 gap-8">
<div>
<h3 className="font-bold text-card-foreground text-lg mb-3">news.folx.tv</h3>
<p className="text-muted-foreground text-sm leading-relaxed">
Das Newsportal von Folx Music Television. Aktuelle Nachrichten, Interviews und
Hintergrundberichte aus der Welt der Volksmusik und des Schlagers.
</p>
</div>
<div>
<h4 className="font-semibold text-card-foreground mb-3">Kategorien</h4>
<ul className="space-y-2 text-sm">
<li>
<Link href="/category/News">
<span className="text-muted-foreground cursor-pointer hover:text-primary transition-colors" data-testid="link-footer-news">
News
</span>
</Link>
</li>
<li>
<Link href="/category/Star-News">
<span className="text-muted-foreground cursor-pointer hover:text-primary transition-colors" data-testid="link-footer-star-news">
Star-News
</span>
</Link>
</li>
</ul>
</div>
<div>
<h4 className="font-semibold text-card-foreground mb-3">Links</h4>
<ul className="space-y-2 text-sm">
<li>
<a
href="https://folx.tv"
target="_blank"
rel="noopener noreferrer"
className="text-muted-foreground hover:text-primary transition-colors"
data-testid="link-footer-folxtv"
>
folx.tv
</a>
</li>
</ul>
</div>
</div>
<div className="border-t border-border mt-8 pt-6 text-center">
<p className="text-xs text-muted-foreground">
&copy; {new Date().getFullYear()} Folx Music Television. Alle Rechte vorbehalten.
</p>
</div>
</div>
</footer>
);
}

View File

@ -0,0 +1,77 @@
import { Link, useLocation } from "wouter";
import { Menu, X, Search } from "lucide-react";
import { Button } from "@/components/ui/button";
import { useState } from "react";
import folxLogo from "@assets/webf_1772296176957.jpg";
const navItems = [
{ label: "Start", href: "/" },
{ label: "News", href: "/category/News" },
{ label: "Star-News", href: "/category/Star-News" },
];
export default function Header() {
const [mobileOpen, setMobileOpen] = useState(false);
const [location] = useLocation();
return (
<header className="sticky top-0 z-50 bg-card/95 backdrop-blur-md border-b border-card-border" data-testid="header">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex items-center justify-between gap-4 h-16">
<Link href="/">
<div className="flex items-center gap-2 cursor-pointer" data-testid="link-logo">
<div className="h-10 w-auto flex items-center">
<img src={folxLogo} alt="Folx TV" className="h-10 w-auto object-contain rounded-sm" />
</div>
</div>
</Link>
<nav className="hidden md:flex items-center gap-1" data-testid="nav-desktop">
{navItems.map((item) => (
<Link key={item.href} href={item.href}>
<Button
variant={location === item.href ? "default" : "ghost"}
size="sm"
data-testid={`link-nav-${item.label.toLowerCase().replace(/\s/g, "-")}`}
>
{item.label}
</Button>
</Link>
))}
</nav>
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="icon"
className="md:hidden"
onClick={() => setMobileOpen(!mobileOpen)}
data-testid="button-mobile-menu"
>
{mobileOpen ? <X className="w-5 h-5" /> : <Menu className="w-5 h-5" />}
</Button>
</div>
</div>
</div>
{mobileOpen && (
<div className="md:hidden border-t border-card-border bg-card" data-testid="nav-mobile">
<nav className="flex flex-col p-4 gap-1">
{navItems.map((item) => (
<Link key={item.href} href={item.href}>
<Button
variant={location === item.href ? "default" : "ghost"}
className="w-full justify-start"
onClick={() => setMobileOpen(false)}
data-testid={`link-mobile-${item.label.toLowerCase().replace(/\s/g, "-")}`}
>
{item.label}
</Button>
</Link>
))}
</nav>
</div>
)}
</header>
);
}

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

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
client/src/pages/home.tsx Normal file
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>
);
}

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?
<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>
</CardContent>
</Card>
<Link href="/">
<Button data-testid="button-back-home">Zur Startseite</Button>
</Link>
</div>
</div>
);
}

217
package-lock.json generated
View File

@ -11,6 +11,7 @@
"dependencies": {
"@hookform/resolvers": "^3.10.0",
"@jridgewell/trace-mapping": "^0.3.25",
"@neondatabase/serverless": "^1.0.2",
"@radix-ui/react-accordion": "^1.2.4",
"@radix-ui/react-alert-dialog": "^1.1.7",
"@radix-ui/react-aspect-ratio": "^1.1.3",
@ -39,11 +40,14 @@
"@radix-ui/react-toggle-group": "^1.1.3",
"@radix-ui/react-tooltip": "^1.2.0",
"@tanstack/react-query": "^5.60.5",
"@types/dompurify": "^3.0.5",
"@types/multer": "^2.0.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"connect-pg-simple": "^10.0.0",
"date-fns": "^3.6.0",
"dompurify": "^3.3.1",
"drizzle-orm": "^0.39.3",
"drizzle-zod": "^0.7.0",
"embla-carousel-react": "^8.6.0",
@ -53,6 +57,7 @@
"input-otp": "^1.4.2",
"lucide-react": "^0.453.0",
"memorystore": "^1.6.7",
"multer": "^2.1.0",
"next-themes": "^0.4.6",
"passport": "^0.7.0",
"passport-local": "^1.0.0",
@ -961,6 +966,28 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
"node_modules/@neondatabase/serverless": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/@neondatabase/serverless/-/serverless-1.0.2.tgz",
"integrity": "sha512-I5sbpSIAHiB+b6UttofhrN/UJXII+4tZPAq1qugzwCwLIL8EZLV7F/JyHUrEIiGgQpEXzpnjlJ+zwcEhheGvCw==",
"license": "MIT",
"dependencies": {
"@types/node": "^22.15.30",
"@types/pg": "^8.8.0"
},
"engines": {
"node": ">=19.0.0"
}
},
"node_modules/@neondatabase/serverless/node_modules/@types/node": {
"version": "22.19.13",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.13.tgz",
"integrity": "sha512-akNQMv0wW5uyRpD2v2IEyRSZiR+BeGuoB6L310EgGObO44HSMNT8z1xzio28V8qOrgYaopIDNA18YgdXd+qTiw==",
"license": "MIT",
"dependencies": {
"undici-types": "~6.21.0"
}
},
"node_modules/@nodelib/fs.scandir": {
"version": "2.1.5",
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
@ -3014,7 +3041,6 @@
"version": "1.19.5",
"resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.5.tgz",
"integrity": "sha512-fB3Zu92ucau0iQ0JMCFQE7b/dv8Ot07NI3KaZIkIUNXq82k4eBAqUaneXfleGY9JWskeS9y+u0nXMyspcuQrCg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/connect": "*",
@ -3025,7 +3051,6 @@
"version": "3.4.38",
"resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz",
"integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
@ -3106,6 +3131,15 @@
"integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==",
"license": "MIT"
},
"node_modules/@types/dompurify": {
"version": "3.0.5",
"resolved": "https://registry.npmjs.org/@types/dompurify/-/dompurify-3.0.5.tgz",
"integrity": "sha512-1Wg0g3BtQF7sSb27fJQAKck1HECM6zV1EB66j8JH9i3LCjYabJa0FSdiSgsD5K/RbrsR0SiraKacLB+T8ZVYAg==",
"license": "MIT",
"dependencies": {
"@types/trusted-types": "*"
}
},
"node_modules/@types/estree": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
@ -3117,7 +3151,6 @@
"version": "5.0.6",
"resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.6.tgz",
"integrity": "sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/body-parser": "*",
@ -3129,7 +3162,6 @@
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-5.1.1.tgz",
"integrity": "sha512-v4zIMr/cX7/d2BpAEX3KNKL/JrT1s43s96lLvvdTmza1oEvDudCqK9aF/djc/SWgy8Yh0h30TZx5VpzqFCxk5A==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*",
@ -3152,14 +3184,21 @@
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz",
"integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/multer": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/@types/multer/-/multer-2.0.0.tgz",
"integrity": "sha512-C3Z9v9Evij2yST3RSBktxP9STm6OdMc5uR1xF1SGr98uv8dUlAL2hqwrZ3GVB3uyMyiegnscEK6PGtYvNrjTjw==",
"license": "MIT",
"dependencies": {
"@types/express": "*"
}
},
"node_modules/@types/node": {
"version": "20.19.27",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.27.tgz",
"integrity": "sha512-N2clP5pJhB2YnZJ3PIHFk5RkygRX5WO/5f0WC08tp0wd+sv0rsJk3MqWn3CbNmT2J505a5336jaQj4ph1AdMug==",
"devOptional": true,
"license": "MIT",
"dependencies": {
"undici-types": "~6.21.0"
@ -3202,7 +3241,6 @@
"version": "8.11.6",
"resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.11.6.tgz",
"integrity": "sha512-/2WmmBXHLsfRqzfHW7BNZ8SbYzE8OSk7i3WjFYvfgRHj7S1xj+16Je5fUKv3lVdVzk/zn9TXOqf+avFCFIE0yQ==",
"devOptional": true,
"license": "MIT",
"dependencies": {
"@types/node": "*",
@ -3221,14 +3259,12 @@
"version": "6.14.0",
"resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.14.0.tgz",
"integrity": "sha512-eOunJqu0K1923aExK6y8p6fsihYEn/BYuQ4g0CxAAgFc4b/ZLN4CrsRZ55srTdqoiLzU2B2evC+apEIxprEzkQ==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/range-parser": {
"version": "1.2.7",
"resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz",
"integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/react": {
@ -3256,7 +3292,6 @@
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz",
"integrity": "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
@ -3266,13 +3301,18 @@
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-2.2.0.tgz",
"integrity": "sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/http-errors": "*",
"@types/node": "*"
}
},
"node_modules/@types/trusted-types": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
"license": "MIT"
},
"node_modules/@types/ws": {
"version": "8.5.13",
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.5.13.tgz",
@ -3360,6 +3400,12 @@
"node": ">= 8"
}
},
"node_modules/append-field": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz",
"integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==",
"license": "MIT"
},
"node_modules/arg": {
"version": "5.0.2",
"resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz",
@ -3529,6 +3575,12 @@
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
}
},
"node_modules/buffer-from": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
"integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==",
"license": "MIT"
},
"node_modules/bufferutil": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/bufferutil/-/bufferutil-4.1.0.tgz",
@ -3543,6 +3595,17 @@
"node": ">=6.14.2"
}
},
"node_modules/busboy": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz",
"integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==",
"dependencies": {
"streamsearch": "^1.1.0"
},
"engines": {
"node": ">=10.16.0"
}
},
"node_modules/bytes": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
@ -3708,6 +3771,21 @@
"node": ">= 6"
}
},
"node_modules/concat-stream": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz",
"integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==",
"engines": [
"node >= 6.0"
],
"license": "MIT",
"dependencies": {
"buffer-from": "^1.0.0",
"inherits": "^2.0.3",
"readable-stream": "^3.0.2",
"typedarray": "^0.0.6"
}
},
"node_modules/connect-pg-simple": {
"version": "10.0.0",
"resolved": "https://registry.npmjs.org/connect-pg-simple/-/connect-pg-simple-10.0.0.tgz",
@ -3996,6 +4074,15 @@
"csstype": "^3.0.2"
}
},
"node_modules/dompurify": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.1.tgz",
"integrity": "sha512-qkdCKzLNtrgPFP1Vo+98FRzJnBRGe4ffyCea9IwHB1fyxPOeNTHpLKYGd4Uk9xvNoH0ZoOjwZxNptyMwqrId1Q==",
"license": "(MPL-2.0 OR Apache-2.0)",
"optionalDependencies": {
"@types/trusted-types": "^2.0.7"
}
},
"node_modules/drizzle-kit": {
"version": "0.31.8",
"resolved": "https://registry.npmjs.org/drizzle-kit/-/drizzle-kit-0.31.8.tgz",
@ -5998,6 +6085,68 @@
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT"
},
"node_modules/multer": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/multer/-/multer-2.1.0.tgz",
"integrity": "sha512-TBm6j41rxNohqawsxlsWsNNh/VdV4QFXcBvRcPhXaA05EZ79z0qJ2bQFpync6JBoHTeNY5Q1JpG7AlTjdlfAEA==",
"license": "MIT",
"dependencies": {
"append-field": "^1.0.0",
"busboy": "^1.6.0",
"concat-stream": "^2.0.0",
"type-is": "^1.6.18"
},
"engines": {
"node": ">= 10.16.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/multer/node_modules/media-typer": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz",
"integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/multer/node_modules/mime-db": {
"version": "1.52.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/multer/node_modules/mime-types": {
"version": "2.1.35",
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
"license": "MIT",
"dependencies": {
"mime-db": "1.52.0"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/multer/node_modules/type-is": {
"version": "1.6.18",
"resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
"integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==",
"license": "MIT",
"dependencies": {
"media-typer": "0.3.0",
"mime-types": "~2.1.24"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/mz": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz",
@ -6117,7 +6266,6 @@
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/obuf/-/obuf-1.1.2.tgz",
"integrity": "sha512-PX1wu0AmAdPqOL1mWhqmlOd8kOIZQwGZw6rh7uby9fTc5lhaOWFLX3I6R1hrF9k3zUY40e6igsLGkDXK92LJNg==",
"devOptional": true,
"license": "MIT"
},
"node_modules/on-finished": {
@ -6307,7 +6455,6 @@
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/pg-numeric/-/pg-numeric-1.0.2.tgz",
"integrity": "sha512-BM/Thnrw5jm2kKLE5uJkXqqExRUY/toLHda65XgFTBTFYZyopbKjBe29Ii3RbkvlsMoFwD+tHeGaCjjv0gHlyw==",
"devOptional": true,
"license": "ISC",
"engines": {
"node": ">=4"
@ -6332,7 +6479,6 @@
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/pg-types/-/pg-types-4.0.2.tgz",
"integrity": "sha512-cRL3JpS3lKMGsKaWndugWQoLOCoP+Cic8oseVcbr0qhPzYD5DWXK+RZ9LY9wxRf7RQia4SCwQlXk0q6FCPrVng==",
"devOptional": true,
"license": "MIT",
"dependencies": {
"pg-int8": "1.0.1",
@ -6594,7 +6740,6 @@
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-3.0.2.tgz",
"integrity": "sha512-6faShkdFugNQCLwucjPcY5ARoW1SlbnrZjmGl0IrrqewpvxvhSLHimCVzqeuULCbG0fQv7Dtk1yDbG3xv7Veog==",
"devOptional": true,
"license": "MIT",
"engines": {
"node": ">=12"
@ -6604,7 +6749,6 @@
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-3.0.0.tgz",
"integrity": "sha512-CNd4jim9RFPkObHSjVHlVrxoVQXz7quwNFpz7RY1okNNme49+sVyiTvTRobiLV548Hx/hb1BG+iE7h9493WzFw==",
"devOptional": true,
"license": "MIT",
"dependencies": {
"obuf": "~1.1.2"
@ -6617,7 +6761,6 @@
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-2.1.0.tgz",
"integrity": "sha512-K7Juri8gtgXVcDfZttFKVmhglp7epKb1K4pgrkLxehjqkrgPhfG6OO8LHLkfaqkbpjNRnra018XwAr1yQFWGcA==",
"devOptional": true,
"license": "MIT",
"engines": {
"node": ">=12"
@ -6627,7 +6770,6 @@
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-3.0.0.tgz",
"integrity": "sha512-BSNDnbyZCXSxgA+1f5UU2GmwhoI0aU5yMxRGO8CdFEcY2BQF9xm/7MqKnYoM1nJDk8nONNWDk9WeSmePFhQdlw==",
"devOptional": true,
"license": "MIT",
"engines": {
"node": ">=12"
@ -6637,7 +6779,6 @@
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/postgres-range/-/postgres-range-1.1.4.tgz",
"integrity": "sha512-i/hbxIE9803Alj/6ytL7UHQxRvZkI9O4Sy+J3HGc4F4oo/2eQAjTSNJ0bfxyse3bH0nuVesCk+3IRLaMtG3H6w==",
"devOptional": true,
"license": "MIT"
},
"node_modules/prop-types": {
@ -6933,6 +7074,20 @@
"pify": "^2.3.0"
}
},
"node_modules/readable-stream": {
"version": "3.6.2",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
"integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
"license": "MIT",
"dependencies": {
"inherits": "^2.0.3",
"string_decoder": "^1.1.1",
"util-deprecate": "^1.0.1"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/readdirp": {
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
@ -7370,6 +7525,23 @@
"node": ">= 0.8"
}
},
"node_modules/streamsearch": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz",
"integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==",
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/string_decoder": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
"integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
"license": "MIT",
"dependencies": {
"safe-buffer": "~5.2.0"
}
},
"node_modules/string-width": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz",
@ -7717,6 +7889,12 @@
"node": ">= 0.6"
}
},
"node_modules/typedarray": {
"version": "0.0.6",
"resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz",
"integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==",
"license": "MIT"
},
"node_modules/typescript": {
"version": "5.6.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.6.3.tgz",
@ -7747,7 +7925,6 @@
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
"devOptional": true,
"license": "MIT"
},
"node_modules/unpipe": {

View File

@ -13,6 +13,7 @@
"dependencies": {
"@hookform/resolvers": "^3.10.0",
"@jridgewell/trace-mapping": "^0.3.25",
"@neondatabase/serverless": "^1.0.2",
"@radix-ui/react-accordion": "^1.2.4",
"@radix-ui/react-alert-dialog": "^1.1.7",
"@radix-ui/react-aspect-ratio": "^1.1.3",
@ -41,11 +42,14 @@
"@radix-ui/react-toggle-group": "^1.1.3",
"@radix-ui/react-tooltip": "^1.2.0",
"@tanstack/react-query": "^5.60.5",
"@types/dompurify": "^3.0.5",
"@types/multer": "^2.0.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"connect-pg-simple": "^10.0.0",
"date-fns": "^3.6.0",
"dompurify": "^3.3.1",
"drizzle-orm": "^0.39.3",
"drizzle-zod": "^0.7.0",
"embla-carousel-react": "^8.6.0",
@ -55,6 +59,7 @@
"input-otp": "^1.4.2",
"lucide-react": "^0.453.0",
"memorystore": "^1.6.7",
"multer": "^2.1.0",
"next-themes": "^0.4.6",
"passport": "^0.7.0",
"passport-local": "^1.0.0",

58
replit.md Normal file
View File

@ -0,0 +1,58 @@
# news.folx.tv - Blog Platform
## Overview
A clean, modern blog/news platform for Folx Music Television (news.folx.tv). Dark-themed, content-first design inspired by Medium and The Verge, with support for video embeds from bunny.net, Facebook, Instagram, and TikTok.
## Architecture
- **Frontend**: React + Vite + TailwindCSS + shadcn/ui (dark mode)
- **Backend**: Express.js + Node.js
- **Database**: PostgreSQL with Drizzle ORM
- **Routing**: wouter (frontend), Express (backend API)
## Key Features
- Article listing with featured carousel, grid layout, and popular sidebar
- Individual article pages with full HTML content rendering
- Category filtering (News, Star-News)
- HTML content supports embedded iframes (bunny.net, YouTube, Facebook, Instagram, TikTok)
- DOMPurify sanitization for safe HTML rendering
- Image upload endpoint (multer) for article images
- Responsive design with mobile navigation
- SEO meta tags
## Data Model
- `articles`: id (serial), title, slug (unique), excerpt, content (HTML), coverImage, category, author, featured, views, publishedAt
## API Endpoints
- `GET /api/articles` - All articles
- `GET /api/articles/featured` - Featured articles
- `GET /api/articles/popular` - Popular articles by views
- `GET /api/articles/category/:category` - Filter by category
- `GET /api/articles/:slug` - Single article (increments views)
- `POST /api/articles` - Create article
- `PATCH /api/articles/:id` - Update article
- `DELETE /api/articles/:id` - Delete article
- `POST /api/upload` - Upload image file
## File Structure
- `shared/schema.ts` - Drizzle schema + Zod validation
- `server/db.ts` - Database connection (pg Pool)
- `server/storage.ts` - Storage interface + DatabaseStorage implementation
- `server/routes.ts` - API routes + file upload (multer)
- `server/seed.ts` - Seed data for initial articles
- `client/src/pages/home.tsx` - Homepage
- `client/src/pages/article.tsx` - Article detail page
- `client/src/pages/category.tsx` - Category listing page
- `client/src/components/header.tsx` - Site header with nav
- `client/src/components/footer.tsx` - Site footer
## Video Embeds
Article content (HTML) supports iframe embeds. Allowed domains:
- iframe.mediadelivery.net / video.bunny.net (Bunny.net)
- www.facebook.com, www.instagram.com, www.tiktok.com
- www.youtube.com, player.vimeo.com
## Branding
- Dark theme by default (class="dark" on html)
- Primary color: crimson/red (hsl 342 85% 53% light, hsl 9 75% 61% dark)
- Font: Poppins
- Logo: Folx TV branding image in header

10
server/db.ts Normal file
View File

@ -0,0 +1,10 @@
import { drizzle } from "drizzle-orm/node-postgres";
import pg from "pg";
import * as schema from "@shared/schema";
if (!process.env.DATABASE_URL) {
throw new Error("DATABASE_URL must be set.");
}
const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL });
export const db = drizzle(pool, { schema });

View File

@ -1,16 +1,106 @@
import type { Express } from "express";
import { createServer, type Server } from "http";
import { storage } from "./storage";
import { insertArticleSchema } from "@shared/schema";
import { seedDatabase } from "./seed";
import multer from "multer";
import path from "path";
import fs from "fs";
const uploadDir = path.join(process.cwd(), "client/public/uploads");
if (!fs.existsSync(uploadDir)) {
fs.mkdirSync(uploadDir, { recursive: true });
}
const upload = multer({
storage: multer.diskStorage({
destination: (_req, _file, cb) => cb(null, uploadDir),
filename: (_req, file, cb) => {
const uniqueSuffix = Date.now() + "-" + Math.round(Math.random() * 1e9);
const ext = path.extname(file.originalname);
cb(null, uniqueSuffix + ext);
},
}),
limits: { fileSize: 10 * 1024 * 1024 },
fileFilter: (_req, file, cb) => {
const allowed = /jpeg|jpg|png|gif|webp/;
const ext = allowed.test(path.extname(file.originalname).toLowerCase());
const mime = allowed.test(file.mimetype);
cb(null, ext && mime);
},
});
export async function registerRoutes(
httpServer: Server,
app: Express
): Promise<Server> {
// put application routes here
// prefix all routes with /api
await seedDatabase();
// use storage to perform CRUD operations on the storage interface
// e.g. storage.insertUser(user) or storage.getUserByUsername(username)
app.get("/api/articles", async (_req, res) => {
const articles = await storage.getArticles();
res.json(articles);
});
app.get("/api/articles/featured", async (_req, res) => {
const articles = await storage.getFeaturedArticles();
res.json(articles);
});
app.get("/api/articles/popular", async (req, res) => {
const limit = parseInt(req.query.limit as string) || 5;
const articles = await storage.getPopularArticles(limit);
res.json(articles);
});
app.get("/api/articles/category/:category", async (req, res) => {
const articles = await storage.getArticlesByCategory(req.params.category);
res.json(articles);
});
app.get("/api/articles/:slug", async (req, res) => {
const article = await storage.getArticleBySlug(req.params.slug);
if (!article) {
return res.status(404).json({ message: "Article not found" });
}
await storage.incrementViews(article.id);
res.json({ ...article, views: article.views + 1 });
});
app.post("/api/articles", async (req, res) => {
const parsed = insertArticleSchema.safeParse(req.body);
if (!parsed.success) {
return res.status(400).json({ message: "Invalid article data", errors: parsed.error.flatten() });
}
const article = await storage.createArticle(parsed.data);
res.status(201).json(article);
});
app.patch("/api/articles/:id", async (req, res) => {
const id = parseInt(req.params.id);
const partial = insertArticleSchema.partial().safeParse(req.body);
if (!partial.success) {
return res.status(400).json({ message: "Invalid article data", errors: partial.error.flatten() });
}
const article = await storage.updateArticle(id, partial.data);
if (!article) {
return res.status(404).json({ message: "Article not found" });
}
res.json(article);
});
app.delete("/api/articles/:id", async (req, res) => {
const id = parseInt(req.params.id);
await storage.deleteArticle(id);
res.status(204).send();
});
app.post("/api/upload", upload.single("image"), (req, res) => {
if (!req.file) {
return res.status(400).json({ message: "No file uploaded" });
}
const url = `/uploads/${req.file.filename}`;
res.json({ url });
});
return httpServer;
}

113
server/seed.ts Normal file
View File

@ -0,0 +1,113 @@
import { storage } from "./storage";
export async function seedDatabase() {
const existing = await storage.getArticles();
if (existing.length > 0) return;
const seedArticles = [
{
title: "Oberkrainer goes royal!",
slug: "oberkrainer-goes-royal",
excerpt: "Wusstest du, dass die Musik von Slavko Avsenik sogar im Buckingham Palace gespielt wurde? Ja, richtig gelesen. Der Sound aus den slowenischen Alpen hat es bis in die Hallen der britischen Monarchie geschafft.",
content: `<p>Wusstest du, dass die Musik von Slavko Avsenik sogar im Buckingham Palace gespielt wurde? Ja, richtig gelesen &ndash; der Sound aus den slowenischen Alpen hat es bis in die Hallen der britischen Monarchie geschafft.</p>
<p>Die Oberkrainer Musik, die in den 1950er Jahren von Slavko Avsenik und seinen Oberkrainern begr&uuml;ndet wurde, hat sich &uuml;ber die Jahrzehnte zu einem wahren Weltph&auml;nomen entwickelt. Was als regionale Volksmusik begann, wurde zu einem internationalen Erfolg, der Millionen von Menschen begeistert.</p>
<h2>Die Geschichte einer Legende</h2>
<p>Slavko Avsenik wurde am 26. November 1929 in Begunje na Gorenjskem geboren. Schon fr&uuml;h zeigte er ein au&szlig;ergew&ouml;hnliches musikalisches Talent. Mit seinem Bruder Vilko, der als Texter und Arrangeur fungierte, gr&uuml;ndete er die Original Oberkrainer, die zu einer der erfolgreichsten Volksmusikgruppen aller Zeiten werden sollten.</p>
<p>Ihr Markenzeichen: die einzigartige Kombination aus Akkordeon, Klarinette, Trompete, Gitarre und Bariton. Diese Besetzung wurde zum Standard f&uuml;r die gesamte Oberkrainer-Bewegung und inspirierte unz&auml;hlige Musiker weltweit.</p>
<h2>Ein Erbe, das weiterlebt</h2>
<p>Heute wird die Tradition der Oberkrainer Musik von einer neuen Generation von Musikern weitergef&uuml;hrt. Bands wie die Saso Avsenik Band und viele andere halten das musikalische Erbe lebendig und bringen es in die moderne Zeit.</p>`,
coverImage: "/images/article-1.png",
category: "Star-News",
author: "Folx Music Television",
featured: true,
},
{
title: "Folx Stadl - Sendung 23: Gaudi und Herzenskl\u00e4nge",
slug: "folx-stadl-sendung-23",
excerpt: "Folx Stadl, die beliebte TV-Show, die seit November 2017 Fans der Volks- und Schlagermusik begeistert, bringt 2025 spannende Neuerungen.",
content: `<p>Folx Stadl, die beliebte TV-Show, die seit November 2017 Fans der Volks- und Schlagermusik begeistert, bringt 2025 spannende Neuerungen. Die Sendung 23 steht ganz im Zeichen von Gaudi und Herzenskl&auml;ngen.</p>
<h2>Was erwartet die Zuschauer?</h2>
<p>In der neuesten Ausgabe des Folx Stadl pr&auml;sentieren sich zahlreiche K&uuml;nstler aus der Volks- und Schlagermusikszene. Die Moderatoren f&uuml;hren gewohnt charmant durch den Abend und sorgen f&uuml;r beste Unterhaltung.</p>
<p>Die Show bietet eine perfekte Mischung aus traditioneller Volksmusik und modernem Schlager. Neben den musikalischen Darbietungen gibt es auch wieder unterhaltsame Gespr&auml;che und &Uuml;berraschungen f&uuml;r das Publikum.</p>
<h2>Die K&uuml;nstler</h2>
<p>Unter den auftretenden K&uuml;nstlern befinden sich sowohl etablierte Gr&ouml;&szlig;en der Szene als auch aufstrebende Talente. Diese Vielfalt macht den besonderen Reiz des Folx Stadl aus und garantiert einen abwechslungsreichen Abend voller Musik und guter Laune.</p>`,
coverImage: "/images/article-2.png",
category: "News",
author: "Folx Music Television",
featured: true,
},
{
title: "Volksmusik, Pop und jede Menge Gaudi",
slug: "volksmusik-pop-gaudi",
excerpt: "Eine einzigartige Mischung aus traditioneller Volksmusik und modernem Pop erobert die Herzen der Zuschauer. Das neue Format verbindet Generationen.",
content: `<p>Eine einzigartige Mischung aus traditioneller Volksmusik und modernem Pop erobert die Herzen der Zuschauer. Das neue Format verbindet Generationen und zeigt, dass Volksmusik und Pop keine Gegens&auml;tze sein m&uuml;ssen.</p>
<h2>Tradition trifft Moderne</h2>
<p>Die Verbindung von Volksmusik und Pop ist kein neues Ph&auml;nomen, aber sie erlebt derzeit eine Renaissance. Immer mehr K&uuml;nstler experimentieren mit der Fusion beider Genres und schaffen dabei etwas v&ouml;llig Neues.</p>
<p>Was fr&uuml;her undenkbar schien, ist heute Realit&auml;t: Traditionelle Instrumente wie Akkordeon und Zither treffen auf elektronische Beats und moderne Produktionstechniken. Das Ergebnis ist eine Musik, die sowohl die &auml;ltere als auch die j&uuml;ngere Generation anspricht.</p>
<h2>Die Zukunft der Volksmusik</h2>
<p>Die Zukunft der Volksmusik liegt in der Offenheit f&uuml;r neue Einfl&uuml;sse. W&auml;hrend die Wurzeln bewahrt werden, &ouml;ffnen sich immer mehr K&uuml;nstler f&uuml;r kreative Experimente. Diese Entwicklung verspricht eine spannende Zukunft f&uuml;r die Volksmusik.</p>`,
coverImage: "/images/article-3.png",
category: "News",
author: "Folx Music Television",
featured: true,
},
{
title: "Frischer Wind bei Folx Stadl - Die 5. Staffel startet in 2025",
slug: "folx-stadl-staffel-5",
excerpt: "Folx Stadl, die beliebte TV-Show, die seit November 2017 Fans der Volks- und Schlagermusik begeistert, bringt 2025 spannende Neuerungen.",
content: `<p>Die f&uuml;nfte Staffel des Folx Stadl steht in den Startl&ouml;chern und verspricht, die bisher aufregendste zu werden. Mit neuen K&uuml;nstlern, &uuml;berarbeiteten B&uuml;hnensets und noch mehr musikalischer Vielfalt geht die Show in eine neue Runde.</p>
<h2>Was ist neu in Staffel 5?</h2>
<p>Die Produzenten haben einige &Uuml;berraschungen vorbereitet. Das B&uuml;hnendesign wurde komplett &uuml;berarbeitet und bietet nun noch mehr Platz f&uuml;r spektakul&auml;re Auftritte. Auch die Lichttechnik wurde modernisiert und sorgt f&uuml;r eine noch beeindruckendere Atmosph&auml;re.</p>
<p>Besonders spannend: Erstmals wird es in jeder Sendung ein spezielles Segment geben, in dem junge Nachwuchsk&uuml;nstler die M&ouml;glichkeit erhalten, sich einem breiten Publikum zu pr&auml;sentieren.</p>`,
coverImage: "/images/article-4.png",
category: "News",
author: "Folx Music Television",
featured: false,
},
{
title: "Folx TV wieder \u00fcber Satellit Astra 19,2\u00b0 Ost zu empfangen",
slug: "folx-tv-satellit-astra",
excerpt: "Gute Nachrichten f\u00fcr alle Volksmusik-Fans: Folx TV ist ab sofort wieder \u00fcber den Satelliten Astra 19,2\u00b0 Ost zu empfangen. Der Sender ist damit in ganz Europa verf\u00fcgbar.",
content: `<p>Gute Nachrichten f&uuml;r alle Volksmusik-Fans: Folx TV ist ab sofort wieder &uuml;ber den Satelliten Astra 19,2&deg; Ost zu empfangen. Der Sender ist damit in ganz Europa verf&uuml;gbar und kann kostenlos empfangen werden.</p>
<h2>Empfangsparameter</h2>
<p>F&uuml;r den Empfang von Folx TV &uuml;ber Astra 19,2&deg; Ost ben&ouml;tigen Sie lediglich eine handels&uuml;bliche Satellitenanlage. Der Sender ist unverschl&uuml;sselt und kann ohne zus&auml;tzliche Kosten empfangen werden.</p>
<p>Die Empfangsparameter sind auf der Website von Folx TV verf&uuml;gbar. Alternativ k&ouml;nnen Sie auch einen automatischen Sendersuchlauf durchf&uuml;hren, um Folx TV zu finden.</p>
<h2>24 Stunden Volksmusik</h2>
<p>Folx TV sendet rund um die Uhr Volksmusik, Schlager und Unterhaltung. Das Programm umfasst Musiksendungen, Konzertmitschnitte, Dokumentationen und nat&uuml;rlich den beliebten Folx Stadl.</p>`,
coverImage: "/images/article-5.png",
category: "News",
author: "Folx Music Television",
featured: false,
},
];
for (const article of seedArticles) {
await storage.createArticle(article);
}
console.log("Database seeded with sample articles.");
}

View File

@ -1,38 +1,64 @@
import { type User, type InsertUser } from "@shared/schema";
import { randomUUID } from "crypto";
// modify the interface with any CRUD methods
// you might need
import { type Article, type InsertArticle, articles } from "@shared/schema";
import { db } from "./db";
import { eq, desc, sql } from "drizzle-orm";
export interface IStorage {
getUser(id: string): Promise<User | undefined>;
getUserByUsername(username: string): Promise<User | undefined>;
createUser(user: InsertUser): Promise<User>;
getArticles(): Promise<Article[]>;
getArticleBySlug(slug: string): Promise<Article | undefined>;
getArticleById(id: number): Promise<Article | undefined>;
getFeaturedArticles(): Promise<Article[]>;
getPopularArticles(limit: number): Promise<Article[]>;
getArticlesByCategory(category: string): Promise<Article[]>;
createArticle(article: InsertArticle): Promise<Article>;
updateArticle(id: number, article: Partial<InsertArticle>): Promise<Article | undefined>;
incrementViews(id: number): Promise<void>;
deleteArticle(id: number): Promise<void>;
}
export class MemStorage implements IStorage {
private users: Map<string, User>;
constructor() {
this.users = new Map();
export class DatabaseStorage implements IStorage {
async getArticles(): Promise<Article[]> {
return db.select().from(articles).orderBy(desc(articles.publishedAt));
}
async getUser(id: string): Promise<User | undefined> {
return this.users.get(id);
async getArticleBySlug(slug: string): Promise<Article | undefined> {
const [article] = await db.select().from(articles).where(eq(articles.slug, slug));
return article;
}
async getUserByUsername(username: string): Promise<User | undefined> {
return Array.from(this.users.values()).find(
(user) => user.username === username,
);
async getArticleById(id: number): Promise<Article | undefined> {
const [article] = await db.select().from(articles).where(eq(articles.id, id));
return article;
}
async createUser(insertUser: InsertUser): Promise<User> {
const id = randomUUID();
const user: User = { ...insertUser, id };
this.users.set(id, user);
return user;
async getFeaturedArticles(): Promise<Article[]> {
return db.select().from(articles).where(eq(articles.featured, true)).orderBy(desc(articles.publishedAt)).limit(3);
}
async getPopularArticles(limit: number): Promise<Article[]> {
return db.select().from(articles).orderBy(desc(articles.views)).limit(limit);
}
async getArticlesByCategory(category: string): Promise<Article[]> {
return db.select().from(articles).where(eq(articles.category, category)).orderBy(desc(articles.publishedAt));
}
async createArticle(article: InsertArticle): Promise<Article> {
const [created] = await db.insert(articles).values(article).returning();
return created;
}
async updateArticle(id: number, article: Partial<InsertArticle>): Promise<Article | undefined> {
const [updated] = await db.update(articles).set(article).where(eq(articles.id, id)).returning();
return updated;
}
async incrementViews(id: number): Promise<void> {
await db.update(articles).set({ views: sql`${articles.views} + 1` }).where(eq(articles.id, id));
}
async deleteArticle(id: number): Promise<void> {
await db.delete(articles).where(eq(articles.id, id));
}
}
export const storage = new MemStorage();
export const storage = new DatabaseStorage();

View File

@ -1,8 +1,31 @@
import { sql } from "drizzle-orm";
import { pgTable, text, varchar } from "drizzle-orm/pg-core";
import { pgTable, text, varchar, integer, boolean, timestamp, serial } from "drizzle-orm/pg-core";
import { createInsertSchema } from "drizzle-zod";
import { z } from "zod";
export const articles = pgTable("articles", {
id: serial("id").primaryKey(),
title: text("title").notNull(),
slug: varchar("slug", { length: 255 }).notNull().unique(),
excerpt: text("excerpt").notNull(),
content: text("content").notNull(),
coverImage: text("cover_image"),
category: varchar("category", { length: 100 }).notNull().default("News"),
author: varchar("author", { length: 255 }).notNull().default("Folx Music Television"),
featured: boolean("featured").notNull().default(false),
views: integer("views").notNull().default(0),
publishedAt: timestamp("published_at").notNull().defaultNow(),
});
export const insertArticleSchema = createInsertSchema(articles).omit({
id: true,
views: true,
publishedAt: true,
});
export type InsertArticle = z.infer<typeof insertArticleSchema>;
export type Article = typeof articles.$inferSelect;
export const users = pgTable("users", {
id: varchar("id").primaryKey().default(sql`gen_random_uuid()`),
username: text("username").notNull().unique(),