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
This commit is contained in:
@@ -15,6 +15,8 @@
|
||||
<meta name="robots" content="index, follow" />
|
||||
<link rel="canonical" href="https://www.folx.tv/" />
|
||||
<link rel="icon" type="image/png" href="/favicon.png" />
|
||||
<link rel="apple-touch-icon" href="/favicon.png" />
|
||||
<link rel="shortcut icon" href="/favicon.png" />
|
||||
<script async src="https://fundingchoicesmessages.google.com/i/pub-4465464714854276?ers=1" nonce=""></script>
|
||||
<script nonce="">(function() {function signalGooglefcPresent() {if (!window.frames['googlefcPresent']) {if (document.body) {const iframe = document.createElement('iframe'); iframe.style = 'width: 0; height: 0; border: none; z-index: -1000; left: -1000px; top: -1000px;'; iframe.style.display = 'none'; iframe.name = 'googlefcPresent'; iframe.id = 'googlefcPresent'; document.body.appendChild(iframe);} else {setTimeout(signalGooglefcPresent, 0);}}}signalGooglefcPresent();})();</script>
|
||||
<script async src="https://pagead2.googlesyndication.com/pagead/js/adsbygoogle.js?client=ca-pub-4465464714854276" crossorigin="anonymous"></script>
|
||||
|
||||
+159
-57
@@ -1,24 +1,115 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useParams, Link } from "wouter";
|
||||
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 } from "lucide-react";
|
||||
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 }>();
|
||||
usePageMeta(`${category} - Volksmusik & Schlager`, `Aktuelle ${category}-Beiträge aus der Volksmusik- und Schlagerszene bei FOLX TV.`);
|
||||
const searchString = useSearch();
|
||||
const [, setLocation] = useLocation();
|
||||
|
||||
const { data: articles, isLoading } = useQuery<Article[]>({
|
||||
queryKey: ["/api/articles/category", category],
|
||||
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 />
|
||||
@@ -30,9 +121,17 @@ export default function CategoryPage() {
|
||||
</Button>
|
||||
</Link>
|
||||
|
||||
<h1 className="text-2xl font-bold text-foreground mb-6" data-testid="text-category-title">
|
||||
{category}
|
||||
</h1>
|
||||
<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">
|
||||
@@ -47,57 +146,60 @@ export default function CategoryPage() {
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : articles && articles.length > 0 ? (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{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"
|
||||
/>
|
||||
) : 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>
|
||||
<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 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>
|
||||
<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 === 2) {
|
||||
items.push(<ArticleCardAd key="ad-cat-1" />);
|
||||
}
|
||||
return items;
|
||||
})}
|
||||
</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">
|
||||
|
||||
@@ -4,6 +4,7 @@ import GalleryPage from "@/components/photo-gallery";
|
||||
import { ArrowLeft } from "lucide-react";
|
||||
import { Link } from "wouter";
|
||||
import { usePageMeta } from "@/hooks/use-page-meta";
|
||||
import { InArticleAd } from "@/components/adsense";
|
||||
|
||||
export default function GalleryPageWrapper() {
|
||||
usePageMeta("Fotogalerie - Volksmusik & Schlager Bilder", "Fotos und Bilder von Volksmusik- und Schlager-Stars bei FOLX TV.");
|
||||
@@ -20,7 +21,9 @@ export default function GalleryPageWrapper() {
|
||||
<h1 className="text-2xl font-bold text-foreground mb-6" data-testid="text-gallery-title">
|
||||
Fotogalerie
|
||||
</h1>
|
||||
<InArticleAd />
|
||||
<GalleryPage />
|
||||
<InArticleAd />
|
||||
</main>
|
||||
<Footer />
|
||||
</div>
|
||||
|
||||
+63
-19
@@ -8,7 +8,7 @@ import { Eye, Play, Images } from "lucide-react";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import Header from "@/components/header";
|
||||
import Footer from "@/components/footer";
|
||||
import AdSense, { ArticleCardAd, MultiplexAd, SidebarAd } from "@/components/adsense";
|
||||
import AdSense, { ArticleCardAd, InArticleAd, MultiplexAd, SidebarAd } from "@/components/adsense";
|
||||
import ArtistPatternBg from "@/components/artist-pattern-bg";
|
||||
import { PhotoGalleryWidget } from "@/components/photo-gallery";
|
||||
import { HoroscopeWidget } from "@/components/horoscope-widget";
|
||||
@@ -516,6 +516,10 @@ export default function Home() {
|
||||
{ id: "recipe", el: <RecipeWidget key="recipe" /> },
|
||||
{ id: "breaking", el: <div key="breaking" className="flex flex-col gap-4"><BreakingNewsWidget /></div> },
|
||||
{ id: "gallery2", el: <PhotoGalleryWidget key="gallery2" reverseOrder={true} /> },
|
||||
{ id: "horoscope2", el: <HoroscopeWidget key="horoscope2" /> },
|
||||
{ id: "news2", el: <div key="news2" className="flex flex-col gap-4"><NewsWidget /></div> },
|
||||
{ id: "recipe2", el: <RecipeWidget key="recipe2" /> },
|
||||
{ id: "breaking2", el: <div key="breaking2" className="flex flex-col gap-4"><BreakingNewsWidget /></div> },
|
||||
], []);
|
||||
|
||||
const gridItems = useMemo(() => {
|
||||
@@ -546,7 +550,7 @@ export default function Home() {
|
||||
}
|
||||
|
||||
const totalRows = items.length / 4;
|
||||
const adRows = [1, 3, 5, 7];
|
||||
const adRows = [1, 3, 5, 7, 9, 11, 13];
|
||||
let adCount = 0;
|
||||
for (const row of adRows) {
|
||||
if (row >= totalRows) continue;
|
||||
@@ -599,6 +603,16 @@ export default function Home() {
|
||||
return articles.filter((a) => !usedIds.has(a.id)).slice(0, 3);
|
||||
}, [articles, widePickedArticles]);
|
||||
|
||||
const extraBottomArticles = useMemo(() => {
|
||||
if (!articles || articles.length < 15) return [];
|
||||
const usedIds = new Set([
|
||||
...articles.slice(0, 9).map((a) => a.id),
|
||||
...widePickedArticles.map((a) => a.id),
|
||||
...bottomArticles.map((a) => a.id),
|
||||
]);
|
||||
return articles.filter((a) => !usedIds.has(a.id)).slice(0, 6);
|
||||
}, [articles, widePickedArticles, bottomArticles]);
|
||||
|
||||
const bottomSection = useMemo(() => {
|
||||
const items: { type: "widget" | "ad" | "article"; el: JSX.Element }[] = [
|
||||
{ type: "widget", el: <NewsWidget key="bottom-news" /> },
|
||||
@@ -611,9 +625,14 @@ export default function Home() {
|
||||
})),
|
||||
{ type: "ad", el: <div key="ad-bottom-1"><ArticleCardAd /></div> },
|
||||
{ type: "widget", el: <PhotoGalleryWidget key="bottom-gallery" /> },
|
||||
{ type: "ad", el: <div key="ad-bottom-2"><ArticleCardAd /></div> },
|
||||
...extraBottomArticles.slice(0, 3).map((a) => ({
|
||||
type: "article" as const,
|
||||
el: <BlogCard key={`bottom-extra-${a.id}`} article={a} focalPoints={focalPoints} />,
|
||||
})),
|
||||
];
|
||||
return items;
|
||||
}, [bottomArticles, focalPoints]);
|
||||
}, [bottomArticles, extraBottomArticles, focalPoints]);
|
||||
|
||||
if (isLoading || !articles) {
|
||||
return (
|
||||
@@ -661,6 +680,8 @@ export default function Home() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<InArticleAd />
|
||||
|
||||
{widePickedArticles.length > 0 && (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-4 gap-4">
|
||||
<ArtistPatternBg className="hidden lg:block rounded-lg overflow-hidden bg-card border border-card-border" seed={99}>
|
||||
@@ -683,25 +704,30 @@ export default function Home() {
|
||||
)}
|
||||
|
||||
{gridRows.map((row, ri) => (
|
||||
<div key={`row-${ri}`} className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{row.map((item) =>
|
||||
item.type === "widget"
|
||||
? <div key={item.key}>{item.widget!.el}</div>
|
||||
: item.type === "ad"
|
||||
? <div key={item.key} className="h-full" data-testid={`ad-grid-${item.key}`}><ArticleCardAd /></div>
|
||||
: item.article
|
||||
? <MediumCard key={item.key} article={item.article} focalPoints={focalPoints} />
|
||||
: null
|
||||
)}
|
||||
{ri === gridRows.length - 1 && widePickedArticles.length > 0 && (
|
||||
<div className="sm:col-span-2 lg:col-span-4 grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<WideCardClassic article={widePickedArticles[0]} focalPoints={focalPoints} />
|
||||
{widePickedArticles[1] && <WideCardClassic article={widePickedArticles[1]} focalPoints={focalPoints} />}
|
||||
</div>
|
||||
)}
|
||||
<div key={`row-group-${ri}`}>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{row.map((item) =>
|
||||
item.type === "widget"
|
||||
? <div key={item.key}>{item.widget!.el}</div>
|
||||
: item.type === "ad"
|
||||
? <div key={item.key} className="h-full" data-testid={`ad-grid-${item.key}`}><ArticleCardAd /></div>
|
||||
: item.article
|
||||
? <MediumCard key={item.key} article={item.article} focalPoints={focalPoints} />
|
||||
: null
|
||||
)}
|
||||
{ri === gridRows.length - 1 && widePickedArticles.length > 0 && (
|
||||
<div className="sm:col-span-2 lg:col-span-4 grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<WideCardClassic article={widePickedArticles[0]} focalPoints={focalPoints} />
|
||||
{widePickedArticles[1] && <WideCardClassic article={widePickedArticles[1]} focalPoints={focalPoints} />}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{ri % 3 === 2 && ri < gridRows.length - 1 && <InArticleAd />}
|
||||
</div>
|
||||
))}
|
||||
|
||||
<InArticleAd />
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{bottomSection.map((item, i) => (
|
||||
<div key={`bottom-${i}`}>
|
||||
@@ -710,6 +736,24 @@ export default function Home() {
|
||||
))}
|
||||
</div>
|
||||
|
||||
<InArticleAd />
|
||||
|
||||
{extraBottomArticles.length > 3 && (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{extraBottomArticles.slice(3, 6).map((a) => (
|
||||
<BlogCard key={`extra-bottom-${a.id}`} article={a} focalPoints={focalPoints} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<MultiplexAd />
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
<PhotoGalleryWidget key="extra-gallery" />
|
||||
<NewsWidget key="extra-news" />
|
||||
<RecipeWidget key="extra-recipe" />
|
||||
</div>
|
||||
|
||||
<MultiplexAd />
|
||||
|
||||
</main>
|
||||
|
||||
@@ -420,6 +420,8 @@ export default function HoroscopePage() {
|
||||
|
||||
<AstroEventsSection />
|
||||
|
||||
<InArticleAd />
|
||||
|
||||
<SignGrid onSelect={handleSelect} selectedIndex={selected} aiHoroscopes={aiHoroscopes} />
|
||||
|
||||
<div ref={detailRef}>
|
||||
|
||||
@@ -350,7 +350,7 @@ export default function RecipesPage() {
|
||||
))}
|
||||
</div>
|
||||
|
||||
{ri % 2 === 0 && <InArticleAd />}
|
||||
<InArticleAd />
|
||||
</div>
|
||||
))}
|
||||
</main>
|
||||
|
||||
+111
-17
@@ -1,14 +1,24 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Link } from "wouter";
|
||||
import { 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 { Play, ArrowLeft } from "lucide-react";
|
||||
import { Play, 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;
|
||||
}
|
||||
|
||||
function VideoCard({ article }: { article: Article }) {
|
||||
const thumbSrc = article.coverImage
|
||||
@@ -63,10 +73,48 @@ function VideoCardSkeleton() {
|
||||
|
||||
export default function VideosPage() {
|
||||
usePageMeta("Volksmusik & Schlager Videos", "Musikvideos und Live-Auftritte aus der Volksmusik- und Schlagerszene bei FOLX TV.");
|
||||
const { data: articles, isLoading } = useQuery<Article[]>({
|
||||
queryKey: ["/api/articles/category/Video"],
|
||||
|
||||
const searchString = useSearch();
|
||||
const [, setLocation] = useLocation();
|
||||
const params = new URLSearchParams(searchString);
|
||||
const currentPage = Math.max(1, parseInt(params.get("page") || "1"));
|
||||
const limit = 12;
|
||||
|
||||
const { data, isLoading } = useQuery<PaginatedResponse>({
|
||||
queryKey: [`/api/articles/category/Video?page=${currentPage}&limit=${limit}`],
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
window.scrollTo({ top: 0, behavior: "smooth" });
|
||||
}, [currentPage]);
|
||||
|
||||
const goToPage = (page: number) => {
|
||||
if (page === 1) {
|
||||
setLocation("/videos");
|
||||
} else {
|
||||
setLocation(`/videos?page=${page}`);
|
||||
}
|
||||
};
|
||||
|
||||
const totalPages = data?.totalPages || 1;
|
||||
const articles = data?.articles || [];
|
||||
|
||||
const getPageNumbers = () => {
|
||||
const pages: (number | "...")[] = [];
|
||||
if (totalPages <= 7) {
|
||||
for (let i = 1; i <= totalPages; i++) pages.push(i);
|
||||
} else {
|
||||
pages.push(1);
|
||||
if (currentPage > 3) pages.push("...");
|
||||
const start = Math.max(2, currentPage - 1);
|
||||
const end = Math.min(totalPages - 1, currentPage + 1);
|
||||
for (let i = start; i <= end; i++) pages.push(i);
|
||||
if (currentPage < totalPages - 2) pages.push("...");
|
||||
pages.push(totalPages);
|
||||
}
|
||||
return pages;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<Header />
|
||||
@@ -78,24 +126,70 @@ export default function VideosPage() {
|
||||
</button>
|
||||
</Link>
|
||||
|
||||
<h1 className="text-2xl font-bold text-foreground mb-6" data-testid="text-videos-title">
|
||||
Videos
|
||||
</h1>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-5">
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
{Array.from({ length: 8 }).map((_, i) => (
|
||||
<VideoCardSkeleton key={i} />
|
||||
))}
|
||||
</div>
|
||||
) : articles && articles.length > 0 ? (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-5">
|
||||
{articles.flatMap((article, index) => {
|
||||
const items = [
|
||||
<VideoCard key={article.id} article={article} />,
|
||||
];
|
||||
if (index === 2) {
|
||||
items.push(<ArticleCardAd key="ad-video-1" />);
|
||||
}
|
||||
return items;
|
||||
})}
|
||||
</div>
|
||||
) : articles.length > 0 ? (
|
||||
<>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-5">
|
||||
{articles.flatMap((article, index) => {
|
||||
const items: JSX.Element[] = [
|
||||
<VideoCard key={article.id} article={article} />,
|
||||
];
|
||||
if ((index + 1) % 3 === 0) {
|
||||
items.push(<ArticleCardAd key={`ad-video-${index}`} />);
|
||||
}
|
||||
return items;
|
||||
})}
|
||||
</div>
|
||||
|
||||
{totalPages > 1 && (
|
||||
<nav className="flex items-center justify-center gap-1 mt-10" data-testid="pagination-videos">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => goToPage(currentPage - 1)}
|
||||
disabled={currentPage <= 1}
|
||||
data-testid="button-prev-page"
|
||||
>
|
||||
<ChevronLeft className="w-4 h-4" />
|
||||
</Button>
|
||||
|
||||
{getPageNumbers().map((p, i) =>
|
||||
p === "..." ? (
|
||||
<span key={`ellipsis-${i}`} className="px-2 text-muted-foreground">...</span>
|
||||
) : (
|
||||
<Button
|
||||
key={p}
|
||||
variant={p === currentPage ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => goToPage(p as number)}
|
||||
data-testid={`button-page-${p}`}
|
||||
>
|
||||
{p}
|
||||
</Button>
|
||||
)
|
||||
)}
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => goToPage(currentPage + 1)}
|
||||
disabled={currentPage >= totalPages}
|
||||
data-testid="button-next-page"
|
||||
>
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
</Button>
|
||||
</nav>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div className="text-center py-16">
|
||||
<p className="text-muted-foreground">Noch keine Videos vorhanden.</p>
|
||||
|
||||
Reference in New Issue
Block a user