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
|
After Width: | Height: | Size: 121 KiB |
BIN
attached_assets/folx_MT_neg_1772296711965.jpg
Normal file
|
After Width: | Height: | Size: 358 KiB |
BIN
attached_assets/folx_MT_neg_1772296711966.png
Normal file
|
After Width: | Height: | Size: 50 KiB |
BIN
attached_assets/folx_MT_neg_b_1772296711966.png
Normal file
|
After Width: | Height: | Size: 49 KiB |
BIN
attached_assets/folx_MT_poz_1772296711966.pdf
Normal file
BIN
attached_assets/folx_MT_poz_1772296711967.png
Normal file
|
After Width: | Height: | Size: 48 KiB |
|
After Width: | Height: | Size: 206 KiB |
|
After Width: | Height: | Size: 149 KiB |
|
After Width: | Height: | Size: 91 KiB |
@ -1,8 +1,13 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="en">
|
<html lang="de" class="dark">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1" />
|
<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="icon" type="image/png" href="/favicon.png" />
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
|
|||||||
BIN
client/public/images/article-1.png
Normal file
|
After Width: | Height: | Size: 1.7 MiB |
BIN
client/public/images/article-2.png
Normal file
|
After Width: | Height: | Size: 1.5 MiB |
BIN
client/public/images/article-3.png
Normal file
|
After Width: | Height: | Size: 1.7 MiB |
BIN
client/public/images/article-4.png
Normal file
|
After Width: | Height: | Size: 1.4 MiB |
BIN
client/public/images/article-5.png
Normal file
|
After Width: | Height: | Size: 1.3 MiB |
@ -4,13 +4,16 @@ import { QueryClientProvider } from "@tanstack/react-query";
|
|||||||
import { Toaster } from "@/components/ui/toaster";
|
import { Toaster } from "@/components/ui/toaster";
|
||||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||||
import NotFound from "@/pages/not-found";
|
import NotFound from "@/pages/not-found";
|
||||||
|
import Home from "@/pages/home";
|
||||||
|
import ArticlePage from "@/pages/article";
|
||||||
|
import CategoryPage from "@/pages/category";
|
||||||
|
|
||||||
function Router() {
|
function Router() {
|
||||||
return (
|
return (
|
||||||
<Switch>
|
<Switch>
|
||||||
{/* Add pages below */}
|
<Route path="/" component={Home} />
|
||||||
{/* <Route path="/" component={Home}/> */}
|
<Route path="/article/:slug" component={ArticlePage} />
|
||||||
{/* Fallback to 404 */}
|
<Route path="/category/:category" component={CategoryPage} />
|
||||||
<Route component={NotFound} />
|
<Route component={NotFound} />
|
||||||
</Switch>
|
</Switch>
|
||||||
);
|
);
|
||||||
|
|||||||
59
client/src/components/footer.tsx
Normal 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">
|
||||||
|
© {new Date().getFullYear()} Folx Music Television. Alle Rechte vorbehalten.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
);
|
||||||
|
}
|
||||||
77
client/src/components/header.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
194
client/src/pages/article.tsx
Normal 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
client/src/pages/category.tsx
Normal 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>·</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
@ -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 { Link } from "wouter";
|
||||||
import { AlertCircle } from "lucide-react";
|
import { Button } from "@/components/ui/button";
|
||||||
|
import Header from "@/components/header";
|
||||||
|
|
||||||
export default function NotFound() {
|
export default function NotFound() {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen w-full flex items-center justify-center bg-gray-50">
|
<div className="min-h-screen bg-background">
|
||||||
<Card className="w-full max-w-md mx-4">
|
<Header />
|
||||||
<CardContent className="pt-6">
|
<div className="flex flex-col items-center justify-center py-32 px-4">
|
||||||
<div className="flex mb-4 gap-2">
|
<h1 className="text-6xl font-bold text-primary mb-4" data-testid="text-404">404</h1>
|
||||||
<AlertCircle className="h-8 w-8 text-red-500" />
|
<p className="text-xl text-foreground mb-2">Seite nicht gefunden</p>
|
||||||
<h1 className="text-2xl font-bold text-gray-900">404 Page Not Found</h1>
|
<p className="text-muted-foreground mb-8 text-center max-w-md">
|
||||||
</div>
|
Die gesuchte Seite existiert nicht oder wurde verschoben.
|
||||||
|
</p>
|
||||||
<p className="mt-4 text-sm text-gray-600">
|
<Link href="/">
|
||||||
Did you forget to add the page to the router?
|
<Button data-testid="button-back-home">Zur Startseite</Button>
|
||||||
</p>
|
</Link>
|
||||||
</CardContent>
|
</div>
|
||||||
</Card>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
217
package-lock.json
generated
@ -11,6 +11,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@hookform/resolvers": "^3.10.0",
|
"@hookform/resolvers": "^3.10.0",
|
||||||
"@jridgewell/trace-mapping": "^0.3.25",
|
"@jridgewell/trace-mapping": "^0.3.25",
|
||||||
|
"@neondatabase/serverless": "^1.0.2",
|
||||||
"@radix-ui/react-accordion": "^1.2.4",
|
"@radix-ui/react-accordion": "^1.2.4",
|
||||||
"@radix-ui/react-alert-dialog": "^1.1.7",
|
"@radix-ui/react-alert-dialog": "^1.1.7",
|
||||||
"@radix-ui/react-aspect-ratio": "^1.1.3",
|
"@radix-ui/react-aspect-ratio": "^1.1.3",
|
||||||
@ -39,11 +40,14 @@
|
|||||||
"@radix-ui/react-toggle-group": "^1.1.3",
|
"@radix-ui/react-toggle-group": "^1.1.3",
|
||||||
"@radix-ui/react-tooltip": "^1.2.0",
|
"@radix-ui/react-tooltip": "^1.2.0",
|
||||||
"@tanstack/react-query": "^5.60.5",
|
"@tanstack/react-query": "^5.60.5",
|
||||||
|
"@types/dompurify": "^3.0.5",
|
||||||
|
"@types/multer": "^2.0.0",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"cmdk": "^1.1.1",
|
"cmdk": "^1.1.1",
|
||||||
"connect-pg-simple": "^10.0.0",
|
"connect-pg-simple": "^10.0.0",
|
||||||
"date-fns": "^3.6.0",
|
"date-fns": "^3.6.0",
|
||||||
|
"dompurify": "^3.3.1",
|
||||||
"drizzle-orm": "^0.39.3",
|
"drizzle-orm": "^0.39.3",
|
||||||
"drizzle-zod": "^0.7.0",
|
"drizzle-zod": "^0.7.0",
|
||||||
"embla-carousel-react": "^8.6.0",
|
"embla-carousel-react": "^8.6.0",
|
||||||
@ -53,6 +57,7 @@
|
|||||||
"input-otp": "^1.4.2",
|
"input-otp": "^1.4.2",
|
||||||
"lucide-react": "^0.453.0",
|
"lucide-react": "^0.453.0",
|
||||||
"memorystore": "^1.6.7",
|
"memorystore": "^1.6.7",
|
||||||
|
"multer": "^2.1.0",
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
"passport": "^0.7.0",
|
"passport": "^0.7.0",
|
||||||
"passport-local": "^1.0.0",
|
"passport-local": "^1.0.0",
|
||||||
@ -961,6 +966,28 @@
|
|||||||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
"@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": {
|
"node_modules/@nodelib/fs.scandir": {
|
||||||
"version": "2.1.5",
|
"version": "2.1.5",
|
||||||
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
|
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
|
||||||
@ -3014,7 +3041,6 @@
|
|||||||
"version": "1.19.5",
|
"version": "1.19.5",
|
||||||
"resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.5.tgz",
|
"resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.5.tgz",
|
||||||
"integrity": "sha512-fB3Zu92ucau0iQ0JMCFQE7b/dv8Ot07NI3KaZIkIUNXq82k4eBAqUaneXfleGY9JWskeS9y+u0nXMyspcuQrCg==",
|
"integrity": "sha512-fB3Zu92ucau0iQ0JMCFQE7b/dv8Ot07NI3KaZIkIUNXq82k4eBAqUaneXfleGY9JWskeS9y+u0nXMyspcuQrCg==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/connect": "*",
|
"@types/connect": "*",
|
||||||
@ -3025,7 +3051,6 @@
|
|||||||
"version": "3.4.38",
|
"version": "3.4.38",
|
||||||
"resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz",
|
"resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz",
|
||||||
"integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==",
|
"integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/node": "*"
|
"@types/node": "*"
|
||||||
@ -3106,6 +3131,15 @@
|
|||||||
"integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==",
|
"integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/@types/estree": {
|
||||||
"version": "1.0.8",
|
"version": "1.0.8",
|
||||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
|
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
|
||||||
@ -3117,7 +3151,6 @@
|
|||||||
"version": "5.0.6",
|
"version": "5.0.6",
|
||||||
"resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.6.tgz",
|
"resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.6.tgz",
|
||||||
"integrity": "sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==",
|
"integrity": "sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/body-parser": "*",
|
"@types/body-parser": "*",
|
||||||
@ -3129,7 +3162,6 @@
|
|||||||
"version": "5.1.1",
|
"version": "5.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-5.1.1.tgz",
|
"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==",
|
"integrity": "sha512-v4zIMr/cX7/d2BpAEX3KNKL/JrT1s43s96lLvvdTmza1oEvDudCqK9aF/djc/SWgy8Yh0h30TZx5VpzqFCxk5A==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/node": "*",
|
"@types/node": "*",
|
||||||
@ -3152,14 +3184,21 @@
|
|||||||
"version": "2.0.5",
|
"version": "2.0.5",
|
||||||
"resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz",
|
"resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz",
|
||||||
"integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==",
|
"integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/@types/node": {
|
||||||
"version": "20.19.27",
|
"version": "20.19.27",
|
||||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.27.tgz",
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.27.tgz",
|
||||||
"integrity": "sha512-N2clP5pJhB2YnZJ3PIHFk5RkygRX5WO/5f0WC08tp0wd+sv0rsJk3MqWn3CbNmT2J505a5336jaQj4ph1AdMug==",
|
"integrity": "sha512-N2clP5pJhB2YnZJ3PIHFk5RkygRX5WO/5f0WC08tp0wd+sv0rsJk3MqWn3CbNmT2J505a5336jaQj4ph1AdMug==",
|
||||||
"devOptional": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"undici-types": "~6.21.0"
|
"undici-types": "~6.21.0"
|
||||||
@ -3202,7 +3241,6 @@
|
|||||||
"version": "8.11.6",
|
"version": "8.11.6",
|
||||||
"resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.11.6.tgz",
|
"resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.11.6.tgz",
|
||||||
"integrity": "sha512-/2WmmBXHLsfRqzfHW7BNZ8SbYzE8OSk7i3WjFYvfgRHj7S1xj+16Je5fUKv3lVdVzk/zn9TXOqf+avFCFIE0yQ==",
|
"integrity": "sha512-/2WmmBXHLsfRqzfHW7BNZ8SbYzE8OSk7i3WjFYvfgRHj7S1xj+16Je5fUKv3lVdVzk/zn9TXOqf+avFCFIE0yQ==",
|
||||||
"devOptional": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/node": "*",
|
"@types/node": "*",
|
||||||
@ -3221,14 +3259,12 @@
|
|||||||
"version": "6.14.0",
|
"version": "6.14.0",
|
||||||
"resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.14.0.tgz",
|
"resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.14.0.tgz",
|
||||||
"integrity": "sha512-eOunJqu0K1923aExK6y8p6fsihYEn/BYuQ4g0CxAAgFc4b/ZLN4CrsRZ55srTdqoiLzU2B2evC+apEIxprEzkQ==",
|
"integrity": "sha512-eOunJqu0K1923aExK6y8p6fsihYEn/BYuQ4g0CxAAgFc4b/ZLN4CrsRZ55srTdqoiLzU2B2evC+apEIxprEzkQ==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@types/range-parser": {
|
"node_modules/@types/range-parser": {
|
||||||
"version": "1.2.7",
|
"version": "1.2.7",
|
||||||
"resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz",
|
"resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz",
|
||||||
"integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==",
|
"integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@types/react": {
|
"node_modules/@types/react": {
|
||||||
@ -3256,7 +3292,6 @@
|
|||||||
"version": "1.2.1",
|
"version": "1.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz",
|
||||||
"integrity": "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==",
|
"integrity": "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/node": "*"
|
"@types/node": "*"
|
||||||
@ -3266,13 +3301,18 @@
|
|||||||
"version": "2.2.0",
|
"version": "2.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-2.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-2.2.0.tgz",
|
||||||
"integrity": "sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ==",
|
"integrity": "sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/http-errors": "*",
|
"@types/http-errors": "*",
|
||||||
"@types/node": "*"
|
"@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": {
|
"node_modules/@types/ws": {
|
||||||
"version": "8.5.13",
|
"version": "8.5.13",
|
||||||
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.5.13.tgz",
|
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.5.13.tgz",
|
||||||
@ -3360,6 +3400,12 @@
|
|||||||
"node": ">= 8"
|
"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": {
|
"node_modules/arg": {
|
||||||
"version": "5.0.2",
|
"version": "5.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz",
|
"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": "^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": {
|
"node_modules/bufferutil": {
|
||||||
"version": "4.1.0",
|
"version": "4.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/bufferutil/-/bufferutil-4.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/bufferutil/-/bufferutil-4.1.0.tgz",
|
||||||
@ -3543,6 +3595,17 @@
|
|||||||
"node": ">=6.14.2"
|
"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": {
|
"node_modules/bytes": {
|
||||||
"version": "3.1.2",
|
"version": "3.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
|
||||||
@ -3708,6 +3771,21 @@
|
|||||||
"node": ">= 6"
|
"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": {
|
"node_modules/connect-pg-simple": {
|
||||||
"version": "10.0.0",
|
"version": "10.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/connect-pg-simple/-/connect-pg-simple-10.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/connect-pg-simple/-/connect-pg-simple-10.0.0.tgz",
|
||||||
@ -3996,6 +4074,15 @@
|
|||||||
"csstype": "^3.0.2"
|
"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": {
|
"node_modules/drizzle-kit": {
|
||||||
"version": "0.31.8",
|
"version": "0.31.8",
|
||||||
"resolved": "https://registry.npmjs.org/drizzle-kit/-/drizzle-kit-0.31.8.tgz",
|
"resolved": "https://registry.npmjs.org/drizzle-kit/-/drizzle-kit-0.31.8.tgz",
|
||||||
@ -5998,6 +6085,68 @@
|
|||||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/mz": {
|
||||||
"version": "2.7.0",
|
"version": "2.7.0",
|
||||||
"resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz",
|
"resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz",
|
||||||
@ -6117,7 +6266,6 @@
|
|||||||
"version": "1.1.2",
|
"version": "1.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/obuf/-/obuf-1.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/obuf/-/obuf-1.1.2.tgz",
|
||||||
"integrity": "sha512-PX1wu0AmAdPqOL1mWhqmlOd8kOIZQwGZw6rh7uby9fTc5lhaOWFLX3I6R1hrF9k3zUY40e6igsLGkDXK92LJNg==",
|
"integrity": "sha512-PX1wu0AmAdPqOL1mWhqmlOd8kOIZQwGZw6rh7uby9fTc5lhaOWFLX3I6R1hrF9k3zUY40e6igsLGkDXK92LJNg==",
|
||||||
"devOptional": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/on-finished": {
|
"node_modules/on-finished": {
|
||||||
@ -6307,7 +6455,6 @@
|
|||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/pg-numeric/-/pg-numeric-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/pg-numeric/-/pg-numeric-1.0.2.tgz",
|
||||||
"integrity": "sha512-BM/Thnrw5jm2kKLE5uJkXqqExRUY/toLHda65XgFTBTFYZyopbKjBe29Ii3RbkvlsMoFwD+tHeGaCjjv0gHlyw==",
|
"integrity": "sha512-BM/Thnrw5jm2kKLE5uJkXqqExRUY/toLHda65XgFTBTFYZyopbKjBe29Ii3RbkvlsMoFwD+tHeGaCjjv0gHlyw==",
|
||||||
"devOptional": true,
|
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=4"
|
"node": ">=4"
|
||||||
@ -6332,7 +6479,6 @@
|
|||||||
"version": "4.0.2",
|
"version": "4.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/pg-types/-/pg-types-4.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/pg-types/-/pg-types-4.0.2.tgz",
|
||||||
"integrity": "sha512-cRL3JpS3lKMGsKaWndugWQoLOCoP+Cic8oseVcbr0qhPzYD5DWXK+RZ9LY9wxRf7RQia4SCwQlXk0q6FCPrVng==",
|
"integrity": "sha512-cRL3JpS3lKMGsKaWndugWQoLOCoP+Cic8oseVcbr0qhPzYD5DWXK+RZ9LY9wxRf7RQia4SCwQlXk0q6FCPrVng==",
|
||||||
"devOptional": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"pg-int8": "1.0.1",
|
"pg-int8": "1.0.1",
|
||||||
@ -6594,7 +6740,6 @@
|
|||||||
"version": "3.0.2",
|
"version": "3.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-3.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-3.0.2.tgz",
|
||||||
"integrity": "sha512-6faShkdFugNQCLwucjPcY5ARoW1SlbnrZjmGl0IrrqewpvxvhSLHimCVzqeuULCbG0fQv7Dtk1yDbG3xv7Veog==",
|
"integrity": "sha512-6faShkdFugNQCLwucjPcY5ARoW1SlbnrZjmGl0IrrqewpvxvhSLHimCVzqeuULCbG0fQv7Dtk1yDbG3xv7Veog==",
|
||||||
"devOptional": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
@ -6604,7 +6749,6 @@
|
|||||||
"version": "3.0.0",
|
"version": "3.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-3.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-3.0.0.tgz",
|
||||||
"integrity": "sha512-CNd4jim9RFPkObHSjVHlVrxoVQXz7quwNFpz7RY1okNNme49+sVyiTvTRobiLV548Hx/hb1BG+iE7h9493WzFw==",
|
"integrity": "sha512-CNd4jim9RFPkObHSjVHlVrxoVQXz7quwNFpz7RY1okNNme49+sVyiTvTRobiLV548Hx/hb1BG+iE7h9493WzFw==",
|
||||||
"devOptional": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"obuf": "~1.1.2"
|
"obuf": "~1.1.2"
|
||||||
@ -6617,7 +6761,6 @@
|
|||||||
"version": "2.1.0",
|
"version": "2.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-2.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-2.1.0.tgz",
|
||||||
"integrity": "sha512-K7Juri8gtgXVcDfZttFKVmhglp7epKb1K4pgrkLxehjqkrgPhfG6OO8LHLkfaqkbpjNRnra018XwAr1yQFWGcA==",
|
"integrity": "sha512-K7Juri8gtgXVcDfZttFKVmhglp7epKb1K4pgrkLxehjqkrgPhfG6OO8LHLkfaqkbpjNRnra018XwAr1yQFWGcA==",
|
||||||
"devOptional": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
@ -6627,7 +6770,6 @@
|
|||||||
"version": "3.0.0",
|
"version": "3.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-3.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-3.0.0.tgz",
|
||||||
"integrity": "sha512-BSNDnbyZCXSxgA+1f5UU2GmwhoI0aU5yMxRGO8CdFEcY2BQF9xm/7MqKnYoM1nJDk8nONNWDk9WeSmePFhQdlw==",
|
"integrity": "sha512-BSNDnbyZCXSxgA+1f5UU2GmwhoI0aU5yMxRGO8CdFEcY2BQF9xm/7MqKnYoM1nJDk8nONNWDk9WeSmePFhQdlw==",
|
||||||
"devOptional": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
@ -6637,7 +6779,6 @@
|
|||||||
"version": "1.1.4",
|
"version": "1.1.4",
|
||||||
"resolved": "https://registry.npmjs.org/postgres-range/-/postgres-range-1.1.4.tgz",
|
"resolved": "https://registry.npmjs.org/postgres-range/-/postgres-range-1.1.4.tgz",
|
||||||
"integrity": "sha512-i/hbxIE9803Alj/6ytL7UHQxRvZkI9O4Sy+J3HGc4F4oo/2eQAjTSNJ0bfxyse3bH0nuVesCk+3IRLaMtG3H6w==",
|
"integrity": "sha512-i/hbxIE9803Alj/6ytL7UHQxRvZkI9O4Sy+J3HGc4F4oo/2eQAjTSNJ0bfxyse3bH0nuVesCk+3IRLaMtG3H6w==",
|
||||||
"devOptional": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/prop-types": {
|
"node_modules/prop-types": {
|
||||||
@ -6933,6 +7074,20 @@
|
|||||||
"pify": "^2.3.0"
|
"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": {
|
"node_modules/readdirp": {
|
||||||
"version": "3.6.0",
|
"version": "3.6.0",
|
||||||
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
|
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
|
||||||
@ -7370,6 +7525,23 @@
|
|||||||
"node": ">= 0.8"
|
"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": {
|
"node_modules/string-width": {
|
||||||
"version": "5.1.2",
|
"version": "5.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz",
|
||||||
@ -7717,6 +7889,12 @@
|
|||||||
"node": ">= 0.6"
|
"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": {
|
"node_modules/typescript": {
|
||||||
"version": "5.6.3",
|
"version": "5.6.3",
|
||||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.6.3.tgz",
|
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.6.3.tgz",
|
||||||
@ -7747,7 +7925,6 @@
|
|||||||
"version": "6.21.0",
|
"version": "6.21.0",
|
||||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
|
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
|
||||||
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
|
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
|
||||||
"devOptional": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/unpipe": {
|
"node_modules/unpipe": {
|
||||||
|
|||||||
@ -13,6 +13,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@hookform/resolvers": "^3.10.0",
|
"@hookform/resolvers": "^3.10.0",
|
||||||
"@jridgewell/trace-mapping": "^0.3.25",
|
"@jridgewell/trace-mapping": "^0.3.25",
|
||||||
|
"@neondatabase/serverless": "^1.0.2",
|
||||||
"@radix-ui/react-accordion": "^1.2.4",
|
"@radix-ui/react-accordion": "^1.2.4",
|
||||||
"@radix-ui/react-alert-dialog": "^1.1.7",
|
"@radix-ui/react-alert-dialog": "^1.1.7",
|
||||||
"@radix-ui/react-aspect-ratio": "^1.1.3",
|
"@radix-ui/react-aspect-ratio": "^1.1.3",
|
||||||
@ -41,11 +42,14 @@
|
|||||||
"@radix-ui/react-toggle-group": "^1.1.3",
|
"@radix-ui/react-toggle-group": "^1.1.3",
|
||||||
"@radix-ui/react-tooltip": "^1.2.0",
|
"@radix-ui/react-tooltip": "^1.2.0",
|
||||||
"@tanstack/react-query": "^5.60.5",
|
"@tanstack/react-query": "^5.60.5",
|
||||||
|
"@types/dompurify": "^3.0.5",
|
||||||
|
"@types/multer": "^2.0.0",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"cmdk": "^1.1.1",
|
"cmdk": "^1.1.1",
|
||||||
"connect-pg-simple": "^10.0.0",
|
"connect-pg-simple": "^10.0.0",
|
||||||
"date-fns": "^3.6.0",
|
"date-fns": "^3.6.0",
|
||||||
|
"dompurify": "^3.3.1",
|
||||||
"drizzle-orm": "^0.39.3",
|
"drizzle-orm": "^0.39.3",
|
||||||
"drizzle-zod": "^0.7.0",
|
"drizzle-zod": "^0.7.0",
|
||||||
"embla-carousel-react": "^8.6.0",
|
"embla-carousel-react": "^8.6.0",
|
||||||
@ -55,6 +59,7 @@
|
|||||||
"input-otp": "^1.4.2",
|
"input-otp": "^1.4.2",
|
||||||
"lucide-react": "^0.453.0",
|
"lucide-react": "^0.453.0",
|
||||||
"memorystore": "^1.6.7",
|
"memorystore": "^1.6.7",
|
||||||
|
"multer": "^2.1.0",
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
"passport": "^0.7.0",
|
"passport": "^0.7.0",
|
||||||
"passport-local": "^1.0.0",
|
"passport-local": "^1.0.0",
|
||||||
|
|||||||
58
replit.md
Normal 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
@ -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 });
|
||||||
@ -1,16 +1,106 @@
|
|||||||
import type { Express } from "express";
|
import type { Express } from "express";
|
||||||
import { createServer, type Server } from "http";
|
import { createServer, type Server } from "http";
|
||||||
import { storage } from "./storage";
|
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(
|
export async function registerRoutes(
|
||||||
httpServer: Server,
|
httpServer: Server,
|
||||||
app: Express
|
app: Express
|
||||||
): Promise<Server> {
|
): Promise<Server> {
|
||||||
// put application routes here
|
await seedDatabase();
|
||||||
// prefix all routes with /api
|
|
||||||
|
|
||||||
// use storage to perform CRUD operations on the storage interface
|
app.get("/api/articles", async (_req, res) => {
|
||||||
// e.g. storage.insertUser(user) or storage.getUserByUsername(username)
|
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;
|
return httpServer;
|
||||||
}
|
}
|
||||||
|
|||||||
113
server/seed.ts
Normal 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 – 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ündet wurde, hat sich über die Jahrzehnte zu einem wahren Weltphä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üh zeigte er ein außergewöhnliches musikalisches Talent. Mit seinem Bruder Vilko, der als Texter und Arrangeur fungierte, grü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ür die gesamte Oberkrainer-Bewegung und inspirierte unzä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ü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ängen.</p>
|
||||||
|
|
||||||
|
<h2>Was erwartet die Zuschauer?</h2>
|
||||||
|
|
||||||
|
<p>In der neuesten Ausgabe des Folx Stadl präsentieren sich zahlreiche Künstler aus der Volks- und Schlagermusikszene. Die Moderatoren führen gewohnt charmant durch den Abend und sorgen fü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äche und Überraschungen für das Publikum.</p>
|
||||||
|
|
||||||
|
<h2>Die Künstler</h2>
|
||||||
|
|
||||||
|
<p>Unter den auftretenden Künstlern befinden sich sowohl etablierte Größ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ätze sein müssen.</p>
|
||||||
|
|
||||||
|
<h2>Tradition trifft Moderne</h2>
|
||||||
|
|
||||||
|
<p>Die Verbindung von Volksmusik und Pop ist kein neues Phänomen, aber sie erlebt derzeit eine Renaissance. Immer mehr Künstler experimentieren mit der Fusion beider Genres und schaffen dabei etwas völlig Neues.</p>
|
||||||
|
|
||||||
|
<p>Was früher undenkbar schien, ist heute Realität: Traditionelle Instrumente wie Akkordeon und Zither treffen auf elektronische Beats und moderne Produktionstechniken. Das Ergebnis ist eine Musik, die sowohl die ältere als auch die jüngere Generation anspricht.</p>
|
||||||
|
|
||||||
|
<h2>Die Zukunft der Volksmusik</h2>
|
||||||
|
|
||||||
|
<p>Die Zukunft der Volksmusik liegt in der Offenheit für neue Einflüsse. Während die Wurzeln bewahrt werden, öffnen sich immer mehr Künstler für kreative Experimente. Diese Entwicklung verspricht eine spannende Zukunft fü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ünfte Staffel des Folx Stadl steht in den Startlöchern und verspricht, die bisher aufregendste zu werden. Mit neuen Künstlern, überarbeiteten Bü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 Überraschungen vorbereitet. Das Bühnendesign wurde komplett überarbeitet und bietet nun noch mehr Platz für spektakuläre Auftritte. Auch die Lichttechnik wurde modernisiert und sorgt für eine noch beeindruckendere Atmosphäre.</p>
|
||||||
|
|
||||||
|
<p>Besonders spannend: Erstmals wird es in jeder Sendung ein spezielles Segment geben, in dem junge Nachwuchskünstler die Möglichkeit erhalten, sich einem breiten Publikum zu prä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ür alle Volksmusik-Fans: Folx TV ist ab sofort wieder über den Satelliten Astra 19,2° Ost zu empfangen. Der Sender ist damit in ganz Europa verfügbar und kann kostenlos empfangen werden.</p>
|
||||||
|
|
||||||
|
<h2>Empfangsparameter</h2>
|
||||||
|
|
||||||
|
<p>Für den Empfang von Folx TV über Astra 19,2° Ost benötigen Sie lediglich eine handelsübliche Satellitenanlage. Der Sender ist unverschlüsselt und kann ohne zusätzliche Kosten empfangen werden.</p>
|
||||||
|
|
||||||
|
<p>Die Empfangsparameter sind auf der Website von Folx TV verfügbar. Alternativ können Sie auch einen automatischen Sendersuchlauf durchfü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ü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.");
|
||||||
|
}
|
||||||
@ -1,38 +1,64 @@
|
|||||||
import { type User, type InsertUser } from "@shared/schema";
|
import { type Article, type InsertArticle, articles } from "@shared/schema";
|
||||||
import { randomUUID } from "crypto";
|
import { db } from "./db";
|
||||||
|
import { eq, desc, sql } from "drizzle-orm";
|
||||||
// modify the interface with any CRUD methods
|
|
||||||
// you might need
|
|
||||||
|
|
||||||
export interface IStorage {
|
export interface IStorage {
|
||||||
getUser(id: string): Promise<User | undefined>;
|
getArticles(): Promise<Article[]>;
|
||||||
getUserByUsername(username: string): Promise<User | undefined>;
|
getArticleBySlug(slug: string): Promise<Article | undefined>;
|
||||||
createUser(user: InsertUser): Promise<User>;
|
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 {
|
export class DatabaseStorage implements IStorage {
|
||||||
private users: Map<string, User>;
|
async getArticles(): Promise<Article[]> {
|
||||||
|
return db.select().from(articles).orderBy(desc(articles.publishedAt));
|
||||||
constructor() {
|
|
||||||
this.users = new Map();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async getUser(id: string): Promise<User | undefined> {
|
async getArticleBySlug(slug: string): Promise<Article | undefined> {
|
||||||
return this.users.get(id);
|
const [article] = await db.select().from(articles).where(eq(articles.slug, slug));
|
||||||
|
return article;
|
||||||
}
|
}
|
||||||
|
|
||||||
async getUserByUsername(username: string): Promise<User | undefined> {
|
async getArticleById(id: number): Promise<Article | undefined> {
|
||||||
return Array.from(this.users.values()).find(
|
const [article] = await db.select().from(articles).where(eq(articles.id, id));
|
||||||
(user) => user.username === username,
|
return article;
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async createUser(insertUser: InsertUser): Promise<User> {
|
async getFeaturedArticles(): Promise<Article[]> {
|
||||||
const id = randomUUID();
|
return db.select().from(articles).where(eq(articles.featured, true)).orderBy(desc(articles.publishedAt)).limit(3);
|
||||||
const user: User = { ...insertUser, id };
|
}
|
||||||
this.users.set(id, user);
|
|
||||||
return user;
|
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();
|
||||||
|
|||||||
@ -1,8 +1,31 @@
|
|||||||
import { sql } from "drizzle-orm";
|
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 { createInsertSchema } from "drizzle-zod";
|
||||||
import { z } from "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", {
|
export const users = pgTable("users", {
|
||||||
id: varchar("id").primaryKey().default(sql`gen_random_uuid()`),
|
id: varchar("id").primaryKey().default(sql`gen_random_uuid()`),
|
||||||
username: text("username").notNull().unique(),
|
username: text("username").notNull().unique(),
|
||||||
|
|||||||