Implement paginated API endpoints for articles and videos, add pagination UI to category and video pages, and inject additional ads and content widgets into the home page. Replit-Commit-Author: Agent Replit-Commit-Session-Id: 517dfa7b-26ac-463d-a6e1-a58c6df97188 Replit-Commit-Checkpoint-Type: full_checkpoint Replit-Commit-Event-Id: c201f10b-bbd3-4402-9212-e0b79bfa670f Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/f209e72a-0939-48fa-84fc-57854de71967/517dfa7b-26ac-463d-a6e1-a58c6df97188/EtK2Sno Replit-Helium-Checkpoint-Created: true
218 lines
8.1 KiB
TypeScript
218 lines
8.1 KiB
TypeScript
import { useQuery } from "@tanstack/react-query";
|
||
import { useParams, Link, useLocation, useSearch } from "wouter";
|
||
import { type Article } from "@shared/schema";
|
||
import { format } from "date-fns";
|
||
import { de } from "date-fns/locale";
|
||
import { usePageMeta } from "@/hooks/use-page-meta";
|
||
import { Eye, ArrowLeft, ChevronLeft, ChevronRight } from "lucide-react";
|
||
import { Button } from "@/components/ui/button";
|
||
import { Skeleton } from "@/components/ui/skeleton";
|
||
import Header from "@/components/header";
|
||
import Footer from "@/components/footer";
|
||
import { ArticleCardAd } from "@/components/adsense";
|
||
import { useEffect } from "react";
|
||
|
||
interface PaginatedResponse {
|
||
articles: Article[];
|
||
total: number;
|
||
page: number;
|
||
totalPages: number;
|
||
hasMore: boolean;
|
||
}
|
||
|
||
export default function CategoryPage() {
|
||
const { category } = useParams<{ category: string }>();
|
||
const searchString = useSearch();
|
||
const [, setLocation] = useLocation();
|
||
|
||
const searchParams = new URLSearchParams(searchString);
|
||
const currentPage = Math.max(1, parseInt(searchParams.get("page") || "1"));
|
||
|
||
usePageMeta(
|
||
`${category}${currentPage > 1 ? ` – Seite ${currentPage}` : ""} - Volksmusik & Schlager`,
|
||
`Aktuelle ${category}-Beiträge aus der Volksmusik- und Schlagerszene bei FOLX TV.`
|
||
);
|
||
|
||
const { data, isLoading } = useQuery<PaginatedResponse>({
|
||
queryKey: ["/api/articles/category", category, { page: currentPage }],
|
||
queryFn: async () => {
|
||
const res = await fetch(`/api/articles/category/${category}?page=${currentPage}&limit=12`);
|
||
if (!res.ok) throw new Error("Failed to fetch articles");
|
||
return res.json();
|
||
},
|
||
});
|
||
|
||
useEffect(() => {
|
||
window.scrollTo({ top: 0, behavior: "smooth" });
|
||
}, [currentPage]);
|
||
|
||
const goToPage = (page: number) => {
|
||
if (page === 1) {
|
||
setLocation(`/category/${category}`);
|
||
} else {
|
||
setLocation(`/category/${category}?page=${page}`);
|
||
}
|
||
};
|
||
|
||
const renderPagination = () => {
|
||
if (!data || data.totalPages <= 1) return null;
|
||
|
||
const pages: (number | string)[] = [];
|
||
const total = data.totalPages;
|
||
const current = data.page;
|
||
|
||
pages.push(1);
|
||
if (current > 3) pages.push("...");
|
||
for (let i = Math.max(2, current - 1); i <= Math.min(total - 1, current + 1); i++) {
|
||
pages.push(i);
|
||
}
|
||
if (current < total - 2) pages.push("...");
|
||
if (total > 1) pages.push(total);
|
||
|
||
return (
|
||
<nav className="flex items-center justify-center gap-1 mt-10" data-testid="pagination-nav">
|
||
<Button
|
||
variant="outline"
|
||
size="icon"
|
||
onClick={() => goToPage(current - 1)}
|
||
disabled={current <= 1}
|
||
data-testid="button-prev-page"
|
||
>
|
||
<ChevronLeft className="w-4 h-4" />
|
||
</Button>
|
||
{pages.map((p, i) =>
|
||
typeof p === "string" ? (
|
||
<span key={`ellipsis-${i}`} className="px-2 text-muted-foreground">
|
||
{p}
|
||
</span>
|
||
) : (
|
||
<Button
|
||
key={p}
|
||
variant={p === current ? "default" : "outline"}
|
||
size="sm"
|
||
onClick={() => goToPage(p)}
|
||
data-testid={`button-page-${p}`}
|
||
>
|
||
{p}
|
||
</Button>
|
||
)
|
||
)}
|
||
<Button
|
||
variant="outline"
|
||
size="icon"
|
||
onClick={() => goToPage(current + 1)}
|
||
disabled={!data.hasMore}
|
||
data-testid="button-next-page"
|
||
>
|
||
<ChevronRight className="w-4 h-4" />
|
||
</Button>
|
||
</nav>
|
||
);
|
||
};
|
||
|
||
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ück
|
||
</Button>
|
||
</Link>
|
||
|
||
<div className="flex items-center justify-between gap-2 mb-6 flex-wrap">
|
||
<h1 className="text-2xl font-bold text-foreground" data-testid="text-category-title">
|
||
{category}
|
||
</h1>
|
||
{data && data.total > 0 && (
|
||
<span className="text-sm text-muted-foreground" data-testid="text-article-count">
|
||
{data.total} Beiträge
|
||
{data.totalPages > 1 && ` · Seite ${data.page} von ${data.totalPages}`}
|
||
</span>
|
||
)}
|
||
</div>
|
||
|
||
{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>
|
||
) : data && data.articles.length > 0 ? (
|
||
<>
|
||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||
{data.articles.flatMap((article, index) => {
|
||
const items = [
|
||
<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 h-full"
|
||
data-testid={`card-article-${article.id}`}
|
||
>
|
||
<div className="relative rounded-t-md">
|
||
<div className="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"
|
||
style={{ objectPosition: "center 25%" }}
|
||
loading="lazy"
|
||
/>
|
||
</div>
|
||
</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>,
|
||
];
|
||
if ((index + 1) % 3 === 0) {
|
||
items.push(<ArticleCardAd key={`ad-cat-${index}`} />);
|
||
}
|
||
return items;
|
||
})}
|
||
</div>
|
||
{renderPagination()}
|
||
</>
|
||
) : (
|
||
<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>
|
||
);
|
||
}
|