folx-tv/client/src/pages/category.tsx
sebastjanartic 3d672766b8 Add pagination to article categories and video pages, and enhance home page with more content and ads
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
2026-03-05 09:48:56 +00:00

218 lines
8.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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>&middot;</span>
<span>{format(new Date(article.publishedAt), "d. MMMM yyyy", { locale: de })}</span>
</div>
<h3 className="font-semibold text-card-foreground mb-2 line-clamp-2 group-hover:text-primary transition-colors">
{article.title}
</h3>
<p className="text-muted-foreground text-sm line-clamp-3 mb-3">
{article.excerpt}
</p>
<div className="flex items-center justify-between gap-2">
<span className="flex items-center gap-1 text-xs text-muted-foreground">
<Eye className="w-3.5 h-3.5" />
{article.views.toLocaleString()}
</span>
<Button size="sm" data-testid={`button-read-${article.id}`}>
Weiterlesen
</Button>
</div>
</div>
</article>
</Link>,
];
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>
);
}