folx-tv/client/src/pages/category.tsx

217 lines
7.9 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, PageSideAds, TopBannerAd } 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 und Nachrichten aus der Volksmusik- und Schlagerszene. Neueste Volksmusik-Meldungen 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 />
<PageSideAds />
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4">
<TopBannerAd />
<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 xl:grid-cols-4 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 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-5">
{(() => {
const items: React.ReactNode[] = [];
const articles = data.articles;
let ai = 0;
let row = 0;
while (ai < articles.length) {
const adCol = row % 4;
for (let col = 0; col < 4; col++) {
if (col === adCol) {
items.push(<ArticleCardAd key={`ad-row-${row}`} />);
} else if (ai < articles.length) {
const a = articles[ai];
items.push(
<Link key={a.id} href={`/article/${a.slug}`}>
<article
className="group cursor-pointer bg-card rounded-md border border-card-border transition-all duration-300 h-full flex flex-col"
data-testid={`card-article-${a.id}`}
>
<div className="overflow-hidden rounded-t-md">
<img
src={a.coverImage || "/images/article-1.jpg"}
alt={a.title}
className="w-full aspect-video object-cover transition-transform duration-500 group-hover:scale-105"
style={{ objectPosition: "center 25%" }}
loading="lazy"
/>
</div>
<div className="p-4 flex flex-col flex-1">
<h3 className="font-semibold text-card-foreground mb-1 line-clamp-2 group-hover:text-primary transition-colors text-sm leading-snug">
{a.title}
</h3>
<span className="text-muted-foreground text-xs">
{format(new Date(a.publishedAt), "d. MMMM yyyy", { locale: de })}
</span>
</div>
</article>
</Link>
);
ai++;
}
}
row++;
}
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>
);
}