217 lines
7.9 KiB
TypeScript
217 lines
7.9 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, 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>
|
||
);
|
||
}
|